From aee8b6e3468bff361a8c32461ea82f5e839e4056 Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sat, 18 Jul 2026 23:01:44 +0800 Subject: [PATCH] feat(chartjs): add Bump Chart template Port the Bump template to the Chart.js backend, mirroring the ECharts implementation (ecBumpChartDef): line-with-points per series over a shared x axis, with the rank axis reversed so rank 1 sits on top. Chart.js reverses a linear scale in place (reverse: true), so unlike the ECharts category-index workaround the series keep their real rank values and the config stays pure JSON with no tooltip formatter. Rank detection mirrors the shared rule (Rank/Score/Level semantic on y and not on x); non-rank inputs fall back to a plain value axis with the semantic zero decision, like the Line template. Discrete/temporal/ linear x handling and the continuous-extent pinning follow line.ts. - register in cjsTemplateDefs (Line & Area group, same position as ECharts) - tests: 6 cases (registry, datasets shape, reversed rank axis pinned to [1, maxRank], non-rank fallback, JSON round-trip purity, 3-backend parity) - docs: regenerate reference-chartjs.md; add Bump to the SKILL.md Chart.js coverage list (+ synced bundled asset) Co-Authored-By: Claude Fable 5 --- agent-skills/flint-chart-author/SKILL.md | 4 +- docs/reference-chartjs.md | 8 +- .../flint-js/src/chartjs/templates/bump.ts | 204 ++++++++++++++++++ .../flint-js/src/chartjs/templates/index.ts | 3 +- packages/flint-js/tests/bump-chartjs.test.ts | 81 +++++++ .../assets/flint-chart-author.SKILL.md | 4 +- 6 files changed, 298 insertions(+), 6 deletions(-) create mode 100644 packages/flint-js/src/chartjs/templates/bump.ts create mode 100644 packages/flint-js/tests/bump-chartjs.test.ts diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index c05dee2..3b3fbed 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -250,8 +250,8 @@ support a subset (verify if targeting a non-VL backend): `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, - Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, Slope, - Connected Scatter. + Combo, Line, Bump, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, + Slope, Connected Scatter. You do not need to call the library or inspect its source to author the input — pick from this table. diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md index 81c5ecb..e4c85b6 100644 --- a/docs/reference-chartjs.md +++ b/docs/reference-chartjs.md @@ -6,7 +6,7 @@ The Chart.js backend is the lightweight embedding target for common chart famili ## What this page covers -This reference lists the 20 chart types currently supported by the Chart.js backend, grouped into 5 categories. Each chart entry shows: +This reference lists the 21 chart types currently supported by the Chart.js backend, grouped into 5 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. @@ -123,6 +123,12 @@ _No template-specific parameters._ |---|---|---|---|---|---| | `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | +### ![](chart-icon-bump.svg) Bump Chart + +**Encoding channels:** `x`, `y`, `color`, `detail`, `column`, `row` + +_No template-specific parameters._ + ### ![](chart-icon-slope.svg) Slope Chart **Encoding channels:** `x`, `y`, `color`, `detail`, `column`, `row` diff --git a/packages/flint-js/src/chartjs/templates/bump.ts b/packages/flint-js/src/chartjs/templates/bump.ts new file mode 100644 index 0000000..6f077cf --- /dev/null +++ b/packages/flint-js/src/chartjs/templates/bump.ts @@ -0,0 +1,204 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Chart.js Bump Chart — line with points, rank axis reversed so rank 1 sits on + * top (mirror of ecBumpChartDef in echarts/templates/line.ts and + * vegalite/templates/bump.ts). + * + * Unlike ECharts — where a value-axis `inverse` moves the x-axis to the top, + * forcing a category-index workaround — Chart.js reverses a linear scale in + * place (`reverse: true`), so series keep their real rank values and the + * config stays pure JSON with no tooltip formatter. + */ + +import { ChartTemplateDef } from '../../core/types'; +import { + extractCategories, + groupBy, + buildCategoryAlignedData, + getChartJsPalette, + getSeriesBorderColor, + coerceUnixMsForChartJs, +} from './utils'; +import { toTypeString } from '../../core/field-semantics'; + +/** Semantic types that indicate a rank-like field (mirror vegalite/templates/bump.ts). */ +const RANK_SEMANTIC_TYPES = new Set(['Rank', 'Score', 'Level']); + +const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; + +export const cjsBumpChartDef: ChartTemplateDef = { + chart: 'Bump Chart', + template: { mark: 'line', encoding: {} }, + channels: ['x', 'y', 'color', 'detail', 'column', 'row'], + markCognitiveChannel: 'position', + declareLayoutMode: () => ({ + paramOverrides: { continuousMarkCrossSection: { x: 80, y: 20, seriesCountAxis: 'auto' } }, + }), + instantiate: (spec, ctx) => { + const { channelSemantics, table, fullTable, semanticTypes } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + + if (!xCS?.field || !yCS?.field) return; + const xField = xCS.field; + const yField = yCS.field; + + const ySemType = toTypeString(semanticTypes?.[yField]); + const xSemType = toTypeString(semanticTypes?.[xField]); + const yIsRank = RANK_SEMANTIC_TYPES.has(ySemType); + const xIsRank = RANK_SEMANTIC_TYPES.has(xSemType); + const rankOnY = yIsRank && !xIsRank; + + const xIsDiscrete = isDiscrete(xCS.type); + const xIsTemporal = xCS.type === 'temporal'; + + const mapContinuousX = (raw: unknown) => + (xIsTemporal ? coerceUnixMsForChartJs(raw) : raw); + + // Pin continuous x to the data extent (same reasoning as line.ts: + // Chart.js "nice" ranges balloon on Unix-ms timestamps). + let continuousXExtent: { min: number; max: number } | undefined; + if (!xIsDiscrete) { + const xNums = (fullTable ?? table) + .map((r: any) => mapContinuousX(r[xField])) + .filter((v: any): v is number => typeof v === 'number' && Number.isFinite(v)); + if (xNums.length > 0) { + continuousXExtent = { min: Math.min(...xNums), max: Math.max(...xNums) }; + } + } + + const categories = xIsDiscrete + ? extractCategories(table, xField, xCS.ordinalSortOrder) + : undefined; + + const sortRowsByX = (rows: any[]) => + [...rows].sort((a, b) => { + const ax = mapContinuousX(a[xField]); + const bx = mapContinuousX(b[xField]); + if (typeof ax === 'number' && typeof bx === 'number') return ax - bx; + return String(ax).localeCompare(String(bx)); + }); + + const rankValues = table + .map((r: any) => Number(r[yField])) + .filter((v: number) => !isNaN(v) && isFinite(v)); + const maxRank = rankValues.length ? Math.max(...rankValues) : 1; + + const palette = getChartJsPalette(ctx, 'color'); + + const config: any = { + type: 'line', + data: { labels: categories || [], datasets: [] }, + options: { + responsive: true, + maintainAspectRatio: false, + scales: { + x: { + type: xIsDiscrete ? 'category' : 'linear', + title: { display: true, text: xField }, + ...(continuousXExtent + ? { min: continuousXExtent.min, max: continuousXExtent.max } + : {}), + ticks: { + font: { size: 10 }, + ...(xIsTemporal + ? { + maxTicksLimit: 4, + autoSkip: true, + maxRotation: 0, + callback(v: number | string) { + const n = typeof v === 'number' ? v : Number(v); + if (!Number.isFinite(n)) return String(v); + const spanDays = continuousXExtent + ? (continuousXExtent.max - continuousXExtent.min) / 86_400_000 + : 0; + const opts: Intl.DateTimeFormatOptions = spanDays > 60 + ? { month: 'short', year: 'numeric' } + : { month: 'short', day: 'numeric', year: 'numeric' }; + return new Date(n).toLocaleDateString(undefined, opts); + }, + } + : {}), + }, + }, + y: rankOnY + ? { + type: 'linear', + reverse: true, + min: 1, + max: maxRank, + title: { display: true, text: yField }, + ticks: { font: { size: 10 }, stepSize: 1, precision: 0 }, + } + : { + type: 'linear', + title: { display: true, text: yField }, + ticks: { font: { size: 10 } }, + }, + }, + plugins: { + tooltip: { enabled: true }, + }, + }, + }; + + // Zero-baseline only applies to the non-rank value axis; a reversed + // rank axis is pinned to [1, maxRank] instead. + if (!rankOnY && channelSemantics.y?.zero) { + config.options.scales.y.beginAtZero = channelSemantics.y.zero.zero !== false; + } + + const baseDataset = { + tension: 0.4, + pointRadius: 3, + fill: false, + backgroundColor: 'transparent', + }; + + if (colorField) { + const groups = groupBy(table, colorField); + config.options.plugins.legend = { display: true }; + + let colorIdx = 0; + for (const [name, rows] of groups) { + const data = xIsDiscrete + ? buildCategoryAlignedData(rows, xField, yField, categories!) + : sortRowsByX(rows) + .map(r => ({ x: mapContinuousX(r[xField]), y: r[yField] })) + .filter(p => p.y != null && (xIsTemporal ? Number.isFinite(p.x as number) : true)); + + config.data.datasets.push({ + label: name, + data, + borderColor: getSeriesBorderColor(palette, colorIdx), + ...baseDataset, + }); + colorIdx++; + } + } else { + const data = xIsDiscrete + ? categories!.map(cat => { + const row = table.find(r => String(r[xField]) === cat); + return row ? row[yField] : null; + }) + : sortRowsByX(table) + .map(r => ({ x: mapContinuousX(r[xField]), y: r[yField] })) + .filter(p => p.y != null && (xIsTemporal ? Number.isFinite(p.x as number) : true)); + + config.data.datasets.push({ + label: yField, + data, + borderColor: getSeriesBorderColor(palette, 0), + ...baseDataset, + }); + config.options.plugins.legend = { display: false }; + } + + Object.assign(spec, config); + delete spec.mark; + delete spec.encoding; + }, +}; diff --git a/packages/flint-js/src/chartjs/templates/index.ts b/packages/flint-js/src/chartjs/templates/index.ts index ce1e6a2..0a54c05 100644 --- a/packages/flint-js/src/chartjs/templates/index.ts +++ b/packages/flint-js/src/chartjs/templates/index.ts @@ -16,6 +16,7 @@ import { cjsStripPlotDef } from './jitter'; import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar'; import { cjsComboChartDef } from './combo'; import { cjsLineChartDef } from './line'; +import { cjsBumpChartDef } from './bump'; import { cjsSlopeChartDef } from './slope'; import { cjsAreaChartDef } from './area'; import { cjsRangeAreaChartDef } from './range-area'; @@ -34,7 +35,7 @@ import { cjsWaterfallChartDef } from './waterfall'; export const cjsTemplateDefs: { [key: string]: ChartTemplateDef[] } = { 'Scatter & Point': [cjsScatterPlotDef, cjsConnectedScatterDef, cjsBubbleChartDef, cjsStripPlotDef], 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef], - 'Line & Area': [cjsLineChartDef, cjsSlopeChartDef, cjsAreaChartDef, cjsRangeAreaChartDef, cjsEcdfPlotDef], + 'Line & Area': [cjsLineChartDef, cjsBumpChartDef, cjsSlopeChartDef, cjsAreaChartDef, cjsRangeAreaChartDef, cjsEcdfPlotDef], 'Part-to-Whole': [cjsPieChartDef, cjsDoughnutChartDef], 'Polar': [cjsRadarChartDef, cjsRoseChartDef], }; diff --git a/packages/flint-js/tests/bump-chartjs.test.ts b/packages/flint-js/tests/bump-chartjs.test.ts new file mode 100644 index 0000000..adf9170 --- /dev/null +++ b/packages/flint-js/tests/bump-chartjs.test.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleChartjs, assembleECharts, assembleVegaLite, cjsGetTemplateDef } from '../src'; + +const RANK_DATA = [ + { season: 'S1', rank: 1, team: 'Ferrari' }, + { season: 'S2', rank: 3, team: 'Ferrari' }, + { season: 'S3', rank: 2, team: 'Ferrari' }, + { season: 'S1', rank: 2, team: 'McLaren' }, + { season: 'S2', rank: 1, team: 'McLaren' }, + { season: 'S3', rank: 3, team: 'McLaren' }, + { season: 'S1', rank: 3, team: 'RedBull' }, + { season: 'S2', rank: 2, team: 'RedBull' }, + { season: 'S3', rank: 1, team: 'RedBull' }, +]; + +function rankInput() { + return { + data: { values: RANK_DATA }, + semantic_types: { season: 'Category', rank: 'Rank', team: 'Name' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: { field: 'season' }, y: { field: 'rank' }, color: { field: 'team' } }, + baseSize: { width: 420, height: 280 }, + }, + }; +} + +describe('Chart.js Bump Chart', () => { + it('is registered in the Chart.js template registry', () => { + expect(cjsGetTemplateDef('Bump Chart')).toBeDefined(); + }); + + it('builds one line dataset per series over the shared category axis', () => { + const config = assembleChartjs(rankInput()) as any; + expect(config.type).toBe('line'); + expect(config.data.labels).toEqual(['S1', 'S2', 'S3']); + expect(config.data.datasets).toHaveLength(3); + expect(config.data.datasets.map((d: any) => d.label)).toEqual(['Ferrari', 'McLaren', 'RedBull']); + expect(config.data.datasets[0].data).toEqual([1, 3, 2]); + expect(config.options.plugins.legend.display).toBe(true); + }); + + it('reverses the rank axis so rank 1 sits on top, pinned to [1, maxRank]', () => { + const config = assembleChartjs(rankInput()) as any; + const y = config.options.scales.y; + expect(y.reverse).toBe(true); + expect(y.min).toBe(1); + expect(y.max).toBe(3); + expect(y.beginAtZero).toBeUndefined(); + }); + + it('keeps a plain value axis when y is not rank-like', () => { + const config = assembleChartjs({ + data: { values: RANK_DATA.map(d => ({ ...d, points: d.rank * 10 })) }, + semantic_types: { season: 'Category', points: 'Quantity', team: 'Name' }, + chart_spec: { + chartType: 'Bump Chart', + encodings: { x: { field: 'season' }, y: { field: 'points' }, color: { field: 'team' } }, + baseSize: { width: 420, height: 280 }, + }, + }) as any; + expect(config.options.scales.y.reverse).toBeUndefined(); + expect(config.options.scales.y.min).toBeUndefined(); + }); + + it('stays pure JSON for the discrete-x rank case (no live functions)', () => { + const config = assembleChartjs(rankInput()) as any; + const roundTripped = JSON.parse(JSON.stringify(config)); + expect(roundTripped).toEqual(config); + }); + + it('compiles the same input on all three backends', () => { + const input = rankInput(); + expect(() => assembleVegaLite(input)).not.toThrow(); + expect(() => assembleECharts(input)).not.toThrow(); + expect(() => assembleChartjs(input)).not.toThrow(); + }); +}); diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index c05dee2..3b3fbed 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -250,8 +250,8 @@ support a subset (verify if targeting a non-VL backend): `"Funnel"`, `"Treemap"`, `"Sunburst"`, `"Sankey"`, `"Parallel Coordinates"`, `"Graph"`, `"Tree"`. - **Chart.js** supports: Scatter, Bubble, Bar, Grouped Bar, Stacked Bar, - Combo, Line, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, Slope, - Connected Scatter. + Combo, Line, Bump, Area, Range Area, Pie, Doughnut, Histogram, Radar, Rose, + Slope, Connected Scatter. You do not need to call the library or inspect its source to author the input — pick from this table.