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.