Skip to content
Merged
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.
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.
Expand Down
8 changes: 7 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 @@ -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`
Expand Down
204 changes: 204 additions & 0 deletions packages/flint-js/src/chartjs/templates/bump.ts
Original file line number Diff line number Diff line change
@@ -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;
},
};
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 @@ -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';
Expand All @@ -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],
};
Expand Down
81 changes: 81 additions & 0 deletions packages/flint-js/tests/bump-chartjs.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
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.
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.
Expand Down