Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 2 additions & 2 deletions agent-skills/flint-chart-author/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
10 changes: 9 additions & 1 deletion docs/reference-chartjs.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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`
Expand Down
3 changes: 2 additions & 1 deletion packages/flint-js/src/chartjs/templates/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -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],
Expand Down
141 changes: 141 additions & 0 deletions packages/flint-js/src/chartjs/templates/lollipop.ts
Original file line number Diff line number Diff line change
@@ -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[],
};
113 changes: 113 additions & 0 deletions packages/flint-js/tests/lollipop-chartjs.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, unknown>) {
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();
});
});
4 changes: 2 additions & 2 deletions packages/flint-mcp/assets/flint-chart-author.SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down