From cf9575c86017a5aabf43e80b2e3b1e40ca2c88b4 Mon Sep 17 00:00:00 2001 From: jason-zl190 Date: Sat, 18 Jul 2026 18:25:34 +0800 Subject: [PATCH] feat(chartjs): add Lollipop Chart template Port the Lollipop template to the Chart.js backend, mirroring the ECharts implementation: a thin bar dataset as the stem (barThickness 1.5, black, same as the ECharts stem) plus a point-only line dataset as the dot, on the shared category scale. Supports horizontal transposition (indexAxis y), color grouping with one dot dataset per group, and the dotSize property on the same 6-16px visual scale as ECharts. The stem dataset is filtered out of legend and tooltip. - register in cjsTemplateDefs (Bar group, same position as ECharts) - tests: 8 cases (registry, stem/dot shape, zero baseline, transpose, color groups, legend/tooltip filters, dotSize mapping, 3-backend parity) - docs: regenerate reference-chartjs.md (npm run gen:reference); add Lollipop to the SKILL.md Chart.js coverage list (+ synced asset) Co-Authored-By: Claude Fable 5 --- agent-skills/flint-chart-author/SKILL.md | 4 +- docs/reference-chartjs.md | 10 +- .../flint-js/src/chartjs/templates/index.ts | 3 +- .../src/chartjs/templates/lollipop.ts | 141 ++++++++++++++++++ .../flint-js/tests/lollipop-chartjs.test.ts | 113 ++++++++++++++ .../assets/flint-chart-author.SKILL.md | 4 +- 6 files changed, 269 insertions(+), 6 deletions(-) create mode 100644 packages/flint-js/src/chartjs/templates/lollipop.ts create mode 100644 packages/flint-js/tests/lollipop-chartjs.test.ts diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index c05dee2..5619c23 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. + Lollipop, Combo, Line, 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..ddec218 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. @@ -85,6 +85,14 @@ _No template-specific parameters._ _No template-specific parameters._ +### ![](chart-icon-lollipop.svg) Lollipop Chart + +**Encoding channels:** `x`, `y`, `color`, `column`, `row` + +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `dotSize` | number | 20 – 300 (step 10) | `80` | always | Size of the dot mark. | + ### ![](chart-icon-combo.svg) Combo Chart **Encoding channels:** `x`, `y`, `column`, `row` diff --git a/packages/flint-js/src/chartjs/templates/index.ts b/packages/flint-js/src/chartjs/templates/index.ts index ce1e6a2..afb5be1 100644 --- a/packages/flint-js/src/chartjs/templates/index.ts +++ b/packages/flint-js/src/chartjs/templates/index.ts @@ -14,6 +14,7 @@ import { cjsConnectedScatterDef } from './connected-scatter'; import { cjsBubbleChartDef } from './bubble'; import { cjsStripPlotDef } from './jitter'; import { cjsBarChartDef, cjsStackedBarChartDef, cjsGroupedBarChartDef } from './bar'; +import { cjsLollipopChartDef } from './lollipop'; import { cjsComboChartDef } from './combo'; import { cjsLineChartDef } from './line'; import { cjsSlopeChartDef } from './slope'; @@ -33,7 +34,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], + 'Bar': [cjsBarChartDef, cjsGroupedBarChartDef, cjsStackedBarChartDef, cjsLollipopChartDef, cjsComboChartDef, cjsHistogramDef, cjsWaterfallChartDef, cjsGanttChartDef], 'Line & Area': [cjsLineChartDef, cjsSlopeChartDef, cjsAreaChartDef, cjsRangeAreaChartDef, cjsEcdfPlotDef], 'Part-to-Whole': [cjsPieChartDef, cjsDoughnutChartDef], 'Polar': [cjsRadarChartDef, cjsRoseChartDef], diff --git a/packages/flint-js/src/chartjs/templates/lollipop.ts b/packages/flint-js/src/chartjs/templates/lollipop.ts new file mode 100644 index 0000000..e53dded --- /dev/null +++ b/packages/flint-js/src/chartjs/templates/lollipop.ts @@ -0,0 +1,141 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Chart.js Lollipop Chart — thin bar stem from 0 to value + dot at the end + * (mirror of echarts/templates/lollipop.ts and vegalite/templates/lollipop.ts). + * + * Chart.js has no rule mark, so the stem is a `bar` dataset with a fixed + * `barThickness`, and the dot is a `line` dataset with `showLine: false` so it + * rides the shared category scale (a `scatter` dataset would require numeric + * `{x, y}` points instead of category labels). + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + extractCategories, + groupBy, + buildCategoryAlignedData, + getChartJsPalette, + getSeriesBorderColor, + detectAxes, +} from './utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; + +/** Stem styling mirrors the ECharts template: black, ~1.5px (the Vega-Lite rule look). */ +const STEM_COLOR = '#000000'; +const STEM_WIDTH_PX = 1.5; +/** Internal dataset label for the stem; filtered out of legend and tooltip. */ +const STEM_LABEL = '__stem__'; + +/** Same visual scale as the ECharts template: 6–16px dot diameter. */ +function dotRadiusFromProperty(dotSize: number): number { + const diameterPx = Math.max(6, Math.min(10 + (dotSize - 80) / 40, 16)); + return diameterPx / 2; +} + +export const cjsLollipopChartDef: ChartTemplateDef = { + chart: 'Lollipop Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'color', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: (cs, table) => { + const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); + return { + axisFlags: result ? { [result.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: result?.resolvedTypes, + }; + }, + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const { categoryAxis, valueAxis } = detectAxes(channelSemantics); + + const catField = channelSemantics[categoryAxis]?.field; + const valField = channelSemantics[valueAxis]?.field; + if (!catField || !valField || table.length === 0) return; + + const colorField = channelSemantics.color?.field; + const categories = extractCategories( + table, catField, channelSemantics[categoryAxis]?.ordinalSortOrder, + ); + const stemData = buildCategoryAlignedData(table, catField, valField, categories); + + const isHorizontal = categoryAxis === 'y'; + const pointRadius = dotRadiusFromProperty(Number(chartProperties?.dotSize ?? 80)); + const palette = getChartJsPalette(ctx, 'color'); + + const datasets: any[] = [{ + type: 'bar' as const, + label: STEM_LABEL, + data: stemData, + barThickness: STEM_WIDTH_PX, + backgroundColor: STEM_COLOR, + borderWidth: 0, + order: 2, + }]; + + const dotDataset = (label: string, data: (number | null)[], colorIndex: number) => ({ + type: 'line' as const, + label, + data, + showLine: false, + pointRadius, + pointHoverRadius: pointRadius + 2, + borderColor: getSeriesBorderColor(palette, colorIndex), + backgroundColor: getSeriesBorderColor(palette, colorIndex), + pointBorderColor: '#fff', + pointBorderWidth: 1, + order: 1, + }); + + if (colorField) { + let i = 0; + for (const [name, rows] of groupBy(table, colorField)) { + datasets.push(dotDataset( + name, buildCategoryAlignedData(rows, catField, valField, categories), i, + )); + i++; + } + } else { + datasets.push(dotDataset(valField, stemData, 0)); + } + + const zeroDecision = channelSemantics[valueAxis]?.zero; + const config: any = { + type: 'bar', + data: { labels: categories, datasets }, + options: { + responsive: true, + maintainAspectRatio: false, + ...(isHorizontal ? { indexAxis: 'y' as const } : {}), + scales: { + [categoryAxis]: { + title: { display: true, text: catField }, + }, + [valueAxis]: { + type: 'linear' as const, + beginAtZero: zeroDecision ? zeroDecision.zero !== false : true, + title: { display: true, text: valField }, + }, + }, + plugins: { + legend: { + display: !!colorField, + labels: { filter: (item: any) => item.text !== STEM_LABEL }, + }, + tooltip: { + enabled: true, + filter: (item: any) => item.dataset?.label !== STEM_LABEL, + }, + }, + }, + }; + + Object.assign(spec, config); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'dotSize', label: 'Dot Size', type: 'continuous', min: 20, max: 300, step: 10, defaultValue: 80 }, + ] as ChartPropertyDef[], +}; diff --git a/packages/flint-js/tests/lollipop-chartjs.test.ts b/packages/flint-js/tests/lollipop-chartjs.test.ts new file mode 100644 index 0000000..b9d4ee4 --- /dev/null +++ b/packages/flint-js/tests/lollipop-chartjs.test.ts @@ -0,0 +1,113 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assembleChartjs, assembleECharts, assembleVegaLite, cjsGetTemplateDef } from '../src'; + +const DATA = [ + { region: 'East', revenue: 168 }, + { region: 'South', revenue: 167 }, + { region: 'North', revenue: 145 }, +]; + +const GROUPED_DATA = [ + { region: 'East', revenue: 168, year: '2024' }, + { region: 'South', revenue: 167, year: '2024' }, + { region: 'East', revenue: 120, year: '2025' }, + { region: 'South', revenue: 131, year: '2025' }, +]; + +function verticalInput(chartProperties?: Record) { + return { + data: { values: DATA }, + semantic_types: { region: 'Region', revenue: 'Amount' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' } }, + baseSize: { width: 400, height: 300 }, + ...(chartProperties ? { chartProperties } : {}), + }, + }; +} + +describe('Chart.js Lollipop Chart', () => { + it('is registered in the Chart.js template registry', () => { + expect(cjsGetTemplateDef('Lollipop Chart')).toBeDefined(); + }); + + it('builds a thin bar stem plus a point-only line dataset', () => { + const config = assembleChartjs(verticalInput()) as any; + expect(config.type).toBe('bar'); + expect(config.data.labels).toEqual(['East', 'South', 'North']); + + const [stem, dots] = config.data.datasets; + expect(stem.type).toBe('bar'); + expect(stem.barThickness).toBe(1.5); + expect(stem.data).toEqual([168, 167, 145]); + + expect(dots.type).toBe('line'); + expect(dots.showLine).toBe(false); + expect(dots.pointRadius).toBe(5); // dotSize default 80 → 10px diameter + expect(dots.data).toEqual([168, 167, 145]); + }); + + it('anchors the value axis at zero (stems grow from 0)', () => { + const config = assembleChartjs(verticalInput()) as any; + expect(config.options.scales.y.beginAtZero).toBe(true); + }); + + it('transposes to indexAxis "y" when the category is on y', () => { + const config = assembleChartjs({ + data: { values: DATA }, + semantic_types: { region: 'Region', revenue: 'Amount' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'revenue' }, y: { field: 'region' } }, + baseSize: { width: 400, height: 300 }, + }, + }) as any; + expect(config.options.indexAxis).toBe('y'); + expect(config.options.scales.x.type).toBe('linear'); + expect(config.data.labels).toEqual(['East', 'South', 'North']); + }); + + it('adds one dot dataset per color group and shows the legend', () => { + const config = assembleChartjs({ + data: { values: GROUPED_DATA }, + semantic_types: { region: 'Region', revenue: 'Amount', year: 'Year' }, + chart_spec: { + chartType: 'Lollipop Chart', + encodings: { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, + baseSize: { width: 400, height: 300 }, + }, + }) as any; + + expect(config.data.datasets).toHaveLength(3); // stem + 2 groups + expect(config.data.datasets.slice(1).map((d: any) => d.label)).toEqual(['2024', '2025']); + expect(config.options.plugins.legend.display).toBe(true); + }); + + it('keeps the stem out of legend and tooltip', () => { + const config = assembleChartjs(verticalInput()) as any; + const stem = config.data.datasets[0]; + const { legend, tooltip } = config.options.plugins; + expect(legend.labels.filter({ text: stem.label })).toBe(false); + expect(tooltip.filter({ dataset: { label: stem.label } })).toBe(false); + expect(tooltip.filter({ dataset: { label: 'revenue' } })).toBe(true); + }); + + it('maps the dotSize property onto the point radius', () => { + const small = assembleChartjs(verticalInput({ dotSize: 20 })) as any; + const large = assembleChartjs(verticalInput({ dotSize: 300 })) as any; + expect(small.data.datasets[1].pointRadius).toBeLessThan(5); + expect(large.data.datasets[1].pointRadius).toBeGreaterThan(5); + expect(large.data.datasets[1].pointRadius).toBeLessThanOrEqual(8); // 16px diameter cap + }); + + it('compiles the same input on all three backends', () => { + const input = verticalInput(); + 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..5619c23 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. + Lollipop, Combo, Line, 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.