From 41b750b7b86bd5f278be7d092aee0649d692e35c Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sun, 19 Jul 2026 18:54:55 +0800 Subject: [PATCH 1/2] feat(vegalite): add Calendar Heatmap template MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Port the Calendar Heatmap to the Vega-Lite backend, mirroring the ECharts implementation (ecCalendarHeatmapDef). ECharts uses a first-class calendar coordinate system; Vega-Lite has none, but it needs no computed week/day fields either — timeUnit expresses the GitHub-style grid from a single date field: yearweek on x (one ordinal column per calendar week), day on y (Sun–Sat rows, Monday-first to match the ECharts dayLabel), and sum on color collapses rows sharing a calendar day into one cell. The upstream ECharts calendar.ts header notes VL "has no first-class calendar; would fake it with rect + computed week/day fields" — timeUnit avoids the computed fields, so no core changes are needed. Scheme handling mirrors the ECharts template's encodingActions: named Vega-Lite schemes (viridis/blues/greens/reds/oranges/purples) pass through as scale.scheme; "github" has no built-in Vega-Lite equivalent, so it resolves to an explicit scale.range (the same low→high ramp the ECharts template uses). - register in vlTemplateDefs (Tables & Maps group, next to Heatmap) - tests: 8 cases (registry, dual-axis timeUnit projection, Monday-first row order, per-day sum, count fallback, github range vs named scheme, cross-backend parity); assembled spec also verified through vl.compile - docs: regenerate reference-vegalite.md; move Calendar Heatmap from the SKILL.md "ECharts adds" list into the shared template table (+ synced bundled asset) Co-Authored-By: Claude Opus 4.8 (1M context) --- agent-skills/flint-chart-author/SKILL.md | 3 +- docs/reference-vegalite.md | 8 +- .../src/vegalite/templates/calendar.ts | 114 ++++++++++++++++++ .../flint-js/src/vegalite/templates/index.ts | 3 +- .../flint-js/tests/calendar-vegalite.test.ts | 95 +++++++++++++++ .../assets/flint-chart-author.SKILL.md | 3 +- 6 files changed, 222 insertions(+), 4 deletions(-) create mode 100644 packages/flint-js/src/vegalite/templates/calendar.ts create mode 100644 packages/flint-js/tests/calendar-vegalite.test.ts diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index 3b3fbed..a1f959e 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -193,6 +193,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -246,7 +247,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index 574c4ac..2fd428c 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -6,7 +6,7 @@ The Vega-Lite backend serves as Flint's reference implementation and offers the ## What this page covers -This reference lists the 34 chart types currently supported by the Vega-Lite backend, grouped into 6 categories. Each chart entry shows: +This reference lists the 35 chart types currently supported by the Vega-Lite backend, grouped into 6 categories. Each chart entry shows: - **Encoding channels** — the visual roles accepted in `chart_spec.encodings`, such as `x`, `y`, `color`, `size`, `column`, or `row`. - **Options** — template-specific `chart_spec.chartProperties` keys, including control type, domain, default, availability, and description. @@ -384,6 +384,12 @@ _No template-specific parameters._ | `xAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the x-axis as a continuous time scale or discrete bands. | | `yAxisType` | choice | `temporal` (Temporal), `nominal` (Discrete) | — | conditional | Interpret the y-axis as a continuous time scale or discrete bands. | +### ![](chart-icon-calendar.svg) Calendar Heatmap + +**Encoding channels:** `x`, `color` + +_No template-specific parameters._ + ### ![](chart-icon-bar-table.svg) Bar Table **Encoding channels:** `y`, `x`, `color`, `column`, `row` diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts new file mode 100644 index 0000000..5d7ced5 --- /dev/null +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -0,0 +1,114 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Vega-Lite Calendar Heatmap template. + * + * The ECharts backend has a first-class calendar coordinate system; Vega-Lite + * has none, but it does not need computed week/day fields either — `timeUnit` + * expresses the GitHub-style grid directly from a single date field: + * x → timeUnit 'yearweek' (one ordinal column per calendar week) + * y → timeUnit 'day' (Sun–Sat, one ordinal row per weekday) + * so the same date field drives both axes and the sum-per-cell aggregation + * collapses to one value per calendar day. + * + * Encoding: + * x (temporal) → the date of each cell + * color (quantitative) → the cell value (defaults to a count of 1) + */ + +import { ChartTemplateDef, EncodingActionDef } from '../../core/types'; + +/** Weekday row order, Monday-first — mirrors the ECharts template's dayLabel.firstDay = 1. */ +const WEEKDAY_ORDER = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']; + +/** + * Sequential schemes. Named Vega-Lite schemes pass through as `scale.scheme`; + * 'github' has no built-in Vega-Lite equivalent, so it resolves to an explicit + * `scale.range` (the same low→high ramp the ECharts template uses). + */ +const GITHUB_RANGE = ['#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39']; +const VL_SCHEMES = new Set(['viridis', 'blues', 'greens', 'reds', 'oranges', 'purples']); + +export const vlCalendarHeatmapDef: ChartTemplateDef = { + chart: 'Calendar Heatmap', + template: { mark: { type: 'rect', cornerRadius: 2 }, encoding: {} }, + channels: ['x', 'color'], + markCognitiveChannel: 'color', + declareLayoutMode: () => ({ + // Both axes are ordinal bands (week columns × weekday rows); square-ish + // cells read as a calendar rather than a stretched grid. + axisFlags: { x: { banded: true }, y: { banded: true } }, + }), + instantiate: (spec, ctx) => { + const dateField = ctx.channelSemantics.x?.field; + const valueField = ctx.channelSemantics.color?.field; + if (!dateField) return; + + const encScheme = ctx.encodings?.color?.scheme; + const scheme = encScheme && encScheme !== 'default' ? encScheme : 'viridis'; + const colorScale = + scheme === 'github' + ? { range: GITHUB_RANGE } + : { scheme: VL_SCHEMES.has(scheme) ? scheme : 'viridis' }; + + spec.encoding = { + // One ordinal column per calendar week; month initials label the axis. + x: { + field: dateField, + timeUnit: 'yearweek', + type: 'ordinal', + title: null, + axis: { + format: '%b', + labelAngle: 0, + labelOverlap: true, + tickBand: 'extent', + domain: false, + ticks: false, + }, + }, + // Sun–Sat rows, Monday-first to match the ECharts calendar. + y: { + field: dateField, + timeUnit: 'day', + type: 'ordinal', + title: null, + sort: WEEKDAY_ORDER, + axis: { domain: false, ticks: false }, + }, + // Sum collapses multiple rows sharing a calendar day into one cell. + color: { + ...(valueField + ? { field: valueField, aggregate: 'sum' } + : { aggregate: 'count' }), + type: 'quantitative', + legend: { title: null }, + scale: colorScale, + }, + }; + }, + encodingActions: [ + { + key: 'colorScheme', + label: 'Scheme', + isApplicable: (ctx) => !!ctx.encodings.color?.field, + dependencies: ['color'], + control: { + type: 'discrete', + options: [ + { value: undefined, label: 'Default (Viridis)' }, + { value: 'viridis', label: 'Viridis' }, + { value: 'github', label: 'GitHub' }, + { value: 'blues', label: 'Blues' }, + { value: 'greens', label: 'Greens' }, + { value: 'reds', label: 'Reds' }, + { value: 'oranges', label: 'Oranges' }, + { value: 'purples', label: 'Purples' }, + ], + }, + get: (enc) => enc.color?.scheme, + set: (enc, value) => ({ ...enc, color: { ...enc.color, scheme: value } }), + }, + ] as EncodingActionDef[], +}; diff --git a/packages/flint-js/src/vegalite/templates/index.ts b/packages/flint-js/src/vegalite/templates/index.ts index 56c3c0a..7aa61af 100644 --- a/packages/flint-js/src/vegalite/templates/index.ts +++ b/packages/flint-js/src/vegalite/templates/index.ts @@ -37,6 +37,7 @@ import { radarChartDef } from './radar'; import { roseChartDef } from './rose'; import { mapDef, choroplethDef } from './map'; import { kpiCardDef } from './kpi-card'; +import { vlCalendarHeatmapDef } from './calendar'; /** * Cross-cutting properties injected into every template that supports @@ -251,7 +252,7 @@ export const vlTemplateDefs: { [key: string]: ChartTemplateDef[] } = Object.from "Distributions": [histogramDef, densityPlotDef, ecdfPlotDef, violinPlotDef, boxplotDef, pyramidChartDef, candlestickChartDef], "Lines & Areas": [lineChartDef, sparklineDef, bumpChartDef, slopeChartDef, areaChartDef, streamgraphDef, rangeAreaChartDef], "Circular": [pieChartDef, roseChartDef, radarChartDef], - "Tables & Maps": [heatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], + "Tables & Maps": [heatmapDef, vlCalendarHeatmapDef, barTableDef, kpiCardDef, mapDef, choroplethDef], }).map(([category, defs]) => [category, defs.map(withInjectedProperties)]), ); diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts new file mode 100644 index 0000000..02201e5 --- /dev/null +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -0,0 +1,95 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleVegaLite, assembleECharts, vlGetTemplateDef } from '../src'; + +/** + * Vega-Lite Calendar Heatmap — parity with the ECharts calendar template. + * + * Vega-Lite has no first-class calendar coordinate system, so the grid is + * expressed with `timeUnit`: the same date field drives `yearweek` (week + * columns) on x and `day` (weekday rows) on y, and `sum` collapses rows that + * share a calendar day into one cell. + */ + +const DAILY = [ + { date: '2024-01-01', value: 5 }, + { date: '2024-01-02', value: 9 }, + { date: '2024-01-02', value: 1 }, // same day → summed with the row above + { date: '2024-01-08', value: 12 }, + { date: '2024-02-05', value: 3 }, +]; + +function calInput(extraEnc?: Record) { + return { + data: { values: DAILY }, + semantic_types: { date: 'Date', value: 'Amount' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' }, color: { field: 'value', ...extraEnc } }, + baseSize: { width: 420, height: 160 }, + }, + }; +} + +describe('Vega-Lite Calendar Heatmap', () => { + it('is registered in the Vega-Lite template registry', () => { + expect(vlGetTemplateDef('Calendar Heatmap')).toBeDefined(); + }); + + it('drives both axes from the one date field via yearweek × day', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.mark?.type ?? spec.mark).toBe('rect'); + expect(spec.encoding.x.field).toBe('date'); + expect(spec.encoding.x.timeUnit).toBe('yearweek'); + expect(spec.encoding.y.field).toBe('date'); + expect(spec.encoding.y.timeUnit).toBe('day'); + }); + + it('orders weekday rows Monday-first (matches the ECharts calendar)', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.y.sort).toEqual(['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']); + }); + + it('sums the value per calendar day', () => { + const spec = assembleVegaLite(calInput()) as any; + expect(spec.encoding.color.field).toBe('value'); + expect(spec.encoding.color.aggregate).toBe('sum'); + expect(spec.encoding.color.type).toBe('quantitative'); + }); + + it('falls back to a per-day count when no value field is given', () => { + const input = { + data: { values: DAILY }, + semantic_types: { date: 'Date' }, + chart_spec: { + chartType: 'Calendar Heatmap', + encodings: { x: { field: 'date' } }, + baseSize: { width: 420, height: 160 }, + }, + }; + const spec = assembleVegaLite(input) as any; + expect(spec.encoding.color.aggregate).toBe('count'); + expect(spec.encoding.color.field).toBeUndefined(); + }); + + it('resolves the github scheme to an explicit range (no built-in Vega-Lite scheme)', () => { + const spec = assembleVegaLite(calInput({ scheme: 'github' })) as any; + expect(spec.encoding.color.scale.scheme).toBeUndefined(); + expect(Array.isArray(spec.encoding.color.scale.range)).toBe(true); + expect(spec.encoding.color.scale.range[0]).toBe('#ebedf0'); + }); + + it('passes a named scheme straight through to scale.scheme', () => { + const spec = assembleVegaLite(calInput({ scheme: 'blues' })) as any; + expect(spec.encoding.color.scale.scheme).toBe('blues'); + }); + + it('assembles the same chart type on both backends (registry parity)', () => { + const vl = assembleVegaLite(calInput()) as any; + const ec = assembleECharts(calInput()) as any; + expect(vl.encoding.x.timeUnit).toBe('yearweek'); // VL: timeUnit grid + expect(ec.series?.[0]?.coordinateSystem).toBe('calendar'); // EC: calendar coord + }); +}); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index 3b3fbed..a1f959e 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -193,6 +193,7 @@ properties"). Required channels are noted. | `"Boxplot"` | x, y, color, opacity, column, row | category + measure; props `whiskerMethod`, `showOutliers`, `dodge` | | `"ECDF Plot"` | x, color, detail, column, row | x = measure; cumulative distribution (step line); prop `showPoints` | | `"Heatmap"` | x, y, color, column, row | color = the measure | +| `"Calendar Heatmap"` | x, color | x = date; color = daily value (summed per day); GitHub-style week × weekday grid | | `"Line Chart"` | x, y, color, strokeDash, detail, opacity, column, row | props `interpolate`, `showPoints` | | `"Sparkline"` | x, y, color, detail, row, column | x + y required; small-multiple mini trend lines, one per series (series from `color` or `detail`); props `interpolate`, `baseline`, `trendWidth` | | `"Bump Chart"` | x, y, color, detail, column, row | rank-over-time lines | @@ -246,7 +247,7 @@ type column. **Backend coverage.** Vega-Lite supports all of the above. Other backends support a subset (verify if targeting a non-VL backend): -- **ECharts** adds: `"Calendar Heatmap"`, `"Gauge"`, +- **ECharts** adds: `"Gauge"`, `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, From 3c97faf50fdfdb01dc05b6c8ad539323b4d050b5 Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sun, 19 Jul 2026 21:40:00 +0800 Subject: [PATCH 2/2] feat(vegalite): canonical GitHub calendar via quantile color scale MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The `github` scheme now maps counts through a quantile scale (equal-count bins → the 5 canonical GitHub buckets) instead of a continuous ramp, so a Calendar Heatmap with `color.scheme: 'github'` renders the discrete, snapped levels people recognize from the GitHub contribution graph. Co-Authored-By: Claude Fable 5 --- packages/flint-js/src/vegalite/templates/calendar.ts | 4 +++- packages/flint-js/tests/calendar-vegalite.test.ts | 9 ++++++--- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/packages/flint-js/src/vegalite/templates/calendar.ts b/packages/flint-js/src/vegalite/templates/calendar.ts index 5d7ced5..dfb4b7c 100644 --- a/packages/flint-js/src/vegalite/templates/calendar.ts +++ b/packages/flint-js/src/vegalite/templates/calendar.ts @@ -49,7 +49,9 @@ export const vlCalendarHeatmapDef: ChartTemplateDef = { const scheme = encScheme && encScheme !== 'default' ? encScheme : 'viridis'; const colorScale = scheme === 'github' - ? { range: GITHUB_RANGE } + // Quantile scale snaps counts into the 5 canonical GitHub buckets + // (equal-count bins → discrete levels), rather than a smooth ramp. + ? { type: 'quantile' as const, range: GITHUB_RANGE } : { scheme: VL_SCHEMES.has(scheme) ? scheme : 'viridis' }; spec.encoding = { diff --git a/packages/flint-js/tests/calendar-vegalite.test.ts b/packages/flint-js/tests/calendar-vegalite.test.ts index 02201e5..7660bef 100644 --- a/packages/flint-js/tests/calendar-vegalite.test.ts +++ b/packages/flint-js/tests/calendar-vegalite.test.ts @@ -74,11 +74,14 @@ describe('Vega-Lite Calendar Heatmap', () => { expect(spec.encoding.color.field).toBeUndefined(); }); - it('resolves the github scheme to an explicit range (no built-in Vega-Lite scheme)', () => { + it('resolves the github scheme to a quantile scale over the canonical 5-bucket range', () => { const spec = assembleVegaLite(calInput({ scheme: 'github' })) as any; expect(spec.encoding.color.scale.scheme).toBeUndefined(); - expect(Array.isArray(spec.encoding.color.scale.range)).toBe(true); - expect(spec.encoding.color.scale.range[0]).toBe('#ebedf0'); + // Quantile scale → discrete GitHub buckets, not a continuous ramp. + expect(spec.encoding.color.scale.type).toBe('quantile'); + expect(spec.encoding.color.scale.range).toEqual([ + '#ebedf0', '#9be9a8', '#40c463', '#30a14e', '#216e39', + ]); }); it('passes a named scheme straight through to scale.scheme', () => {