diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json
index 167841f7..e1bb0876 100644
--- a/packages/flint-js/package.json
+++ b/packages/flint-js/package.json
@@ -65,6 +65,11 @@
"import": "./dist/excel/index.js",
"require": "./dist/excel/index.cjs"
},
+ "./image-charts": {
+ "types": "./dist/image-charts/index.d.ts",
+ "import": "./dist/image-charts/index.js",
+ "require": "./dist/image-charts/index.cjs"
+ },
"./test-data": {
"types": "./dist/test-data/index.d.ts",
"import": "./dist/test-data/index.js",
diff --git a/packages/flint-js/src/image-charts/assemble.ts b/packages/flint-js/src/image-charts/assemble.ts
new file mode 100644
index 00000000..740ffb73
--- /dev/null
+++ b/packages/flint-js/src/image-charts/assemble.ts
@@ -0,0 +1,362 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Image-Charts chart assembly — a hosted-image-URL backend.
+ *
+ * Unlike the other backends, Image-Charts does not emit a spec object that a
+ * local renderer draws: it emits a single permanent `https://image-charts.com`
+ * URL that renders the chart server-side. That URL is embeddable anywhere an
+ * `
` works (email, PDF, Slack, no-code tools) with no runtime JavaScript.
+ *
+ * Contract:
+ * - PURE. No network I/O, no crypto, no npm dependencies. `assembleImageCharts`
+ * only builds a string; the data reaches Image-Charts only if something later
+ * loads the `
` — an explicit choice by the caller, exactly as choosing
+ * the Excel backend chooses Office.js.
+ * - FREE TIER ONLY. Unsigned URLs (no `icac`/`ichm` account/HMAC pair, no
+ * `chof` output override). Signed enterprise URLs need a server-side secret
+ * that has no place in a pure, offline compiler function.
+ *
+ * Reuses the SAME core analysis pipeline as the other backends (Phase 0 semantic
+ * resolution + banded-axis overflow filtering), then serializes the resolved
+ * channel semantics, category/series roles, and values into the Image-Charts
+ * query grammar (`cht`, `chd=a:`, `chs`, `chxt`/`chxl`, `chco`, `chdl`, `chm`,
+ * `chtt`). Like the Excel backend it does the work inline rather than through a
+ * template registry, and it gates chart types to the ones with a faithful `cht`.
+ */
+
+import type { ChartAssemblyInput, ChartEncoding, SemanticResult } from '../core/types';
+import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics';
+import { detectBandedAxisFromSemantics } from '../core/axis-detection';
+import { computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout';
+import { filterOverflow } from '../core/filter-overflow';
+import type { LayoutDeclaration } from '../core/types';
+import { IMAGE_CHARTS_TYPE_MAP } from './chart-types';
+
+/** A backend-native Image-Charts artifact: a permanent hosted-image URL. */
+export interface ImageChartsArtifact {
+ type: 'image-charts';
+ url: string;
+}
+
+type Cell = string | number;
+
+/** Image-Charts base endpoint (public free tier). */
+const IMAGE_CHARTS_ENDPOINT = 'https://image-charts.com/chart?';
+
+/** Free-tier size ceilings: each side ≤ 999px and area ≤ 998001px². */
+const MAX_SIDE = 999;
+const MAX_AREA = 998001;
+
+/** Default target size when the spec provides no `baseSize`. */
+const DEFAULT_SIZE = { width: 700, height: 400 };
+
+/**
+ * Categorical palette (hex, no `#`) used for `chco`. Emitted only when color is
+ * meaningful (multiple series, pie slices, area fill, scatter markers); a single
+ * plain series keeps Image-Charts' own default color.
+ */
+const SERIES_COLORS = [
+ '4472C4', 'ED7D31', '70AD47', 'FFC000', '5B9BD5',
+ 'A5A5A5', '264478', '9E480E', '636363', '997300',
+];
+
+/** Normalize shorthand (`"x": "field"`) to `{ field }`. */
+function normalizeEncodings(raw: Record): Record {
+ const out: Record = {};
+ for (const [ch, v] of Object.entries(raw ?? {})) {
+ if (v == null) continue;
+ out[ch] = typeof v === 'string' ? { field: v } : (v as ChartEncoding);
+ }
+ return out;
+}
+
+/** Clamp a target size to the free-tier ceilings (side ≤ 999, area ≤ 998001). */
+function clampChartSize(width: number, height: number): { width: number; height: number } {
+ let w = Math.min(MAX_SIDE, Math.max(1, Math.round(width)));
+ let h = Math.min(MAX_SIDE, Math.max(1, Math.round(height)));
+ if (w * h > MAX_AREA) {
+ const scale = Math.sqrt(MAX_AREA / (w * h));
+ w = Math.max(1, Math.floor(w * scale));
+ h = Math.max(1, Math.floor(h * scale));
+ }
+ return { width: w, height: h };
+}
+
+/**
+ * Encode one label/title/legend segment: keep ASCII alphanumerics, map spaces to
+ * `+`, percent-encode everything else (UTF-8). Structural separators (`|`, `,`,
+ * `:`) are added by the caller between segments and never pass through here, so
+ * a label that literally contains them stays escaped and cannot break parsing.
+ */
+function encodeSegment(text: string): string {
+ let out = '';
+ for (const ch of text) {
+ if (/[0-9A-Za-z]/.test(ch)) out += ch;
+ else if (ch === ' ') out += '+';
+ else out += encodeURIComponent(ch);
+ }
+ return out;
+}
+
+/** Format one datum for the `a:` (awesome) encoding; `_` marks a gap/null. */
+function formatValue(value: number | null): string {
+ if (value == null || !Number.isFinite(value)) return '_';
+ if (Number.isInteger(value)) return String(value);
+ return String(Number(value.toFixed(4)));
+}
+
+/** Distinct values of a field in first-seen order (nulls skipped). */
+function distinct(rows: any[], field: string): Cell[] {
+ const seen = new Set();
+ const out: Cell[] = [];
+ for (const r of rows) {
+ const v = r[field];
+ if (v == null) continue;
+ if (!seen.has(v)) { seen.add(v); out.push(v as Cell); }
+ }
+ return out;
+}
+
+/**
+ * Aggregate the long/tidy rows into a per-series × per-category value matrix,
+ * summing (or averaging) duplicates. `seriesField` undefined ⇒ one implicit
+ * series holding the whole measure column.
+ */
+function pivotValues(
+ rows: any[],
+ catField: string,
+ measField: string,
+ seriesField: string | undefined,
+ categories: Cell[],
+ seriesKeys: Cell[],
+ aggregate: 'sum' | 'average',
+): (number | null)[][] {
+ const SINGLE = '__single__';
+ const acc = new Map();
+ for (const r of rows) {
+ const cv = r[catField];
+ if (cv == null) continue;
+ const sv = seriesField ? r[seriesField] : SINGLE;
+ const num = Number(r[measField]);
+ if (!Number.isFinite(num)) continue;
+ const key = JSON.stringify([String(cv), String(sv)]);
+ const e = acc.get(key) ?? { sum: 0, count: 0 };
+ e.sum += num; e.count += 1; acc.set(key, e);
+ }
+ const valueAt = (cv: Cell, sv: Cell): number | null => {
+ const e = acc.get(JSON.stringify([String(cv), String(seriesField ? sv : SINGLE)]));
+ if (!e) return null;
+ return aggregate === 'average' ? e.sum / e.count : e.sum;
+ };
+ return seriesKeys.map((sv) => categories.map((cv) => valueAt(cv, sv)));
+}
+
+/**
+ * Assemble an {@link ImageChartsArtifact} (a permanent hosted-image URL) from a
+ * {@link ChartAssemblyInput}.
+ *
+ * @throws if the chart type has no faithful Image-Charts `cht` equivalent
+ * (e.g. Boxplot, Sankey, Heatmap) or its roles cannot be resolved.
+ */
+export function assembleImageCharts(input: ChartAssemblyInput): ImageChartsArtifact {
+ const flintType = input.chart_spec.chartType;
+ const mapping = IMAGE_CHARTS_TYPE_MAP[flintType];
+ if (!mapping) {
+ throw new Error(`Image-Charts backend does not support chart type "${flintType}".`);
+ }
+
+ const semanticTypes = input.semantic_types ?? {};
+ const rawData: any[] = input.data.values ?? [];
+ const encodings = normalizeEncodings(input.chart_spec.encodings);
+
+ if (encodings.column?.field || encodings.row?.field) {
+ throw new Error(`Image-Charts backend does not support faceting in one chart: "${flintType}".`);
+ }
+
+ // ── Phase 0 (reused core): resolve per-channel semantics ────────────────
+ let table = convertTemporalData(rawData, semanticTypes);
+ const sem: SemanticResult = resolveChannelSemantics(encodings, rawData, semanticTypes, table);
+ const typeOf = (ch: string) => sem[ch]?.type;
+ const isMeasure = (ch: string) => typeOf(ch) === 'quantitative';
+ const fieldOf = (ch: string) => encodings[ch]?.field;
+
+ // A categorical color/group binding becomes the series (legend) dimension;
+ // a quantitative color is not a series and is ignored on this tier.
+ const seriesCh = encodings.group?.field
+ ? 'group'
+ : encodings.color?.field && !isMeasure('color')
+ ? 'color'
+ : undefined;
+ const seriesField = seriesCh ? fieldOf(seriesCh) : undefined;
+
+ // ── Overflow filtering for banded (bar) families, so URLs stay bounded ──
+ const keptCategoryOrder = new Map();
+ if (mapping.cht === 'bvg' || mapping.cht === 'bhg' || mapping.cht === 'bvs' || mapping.cht === 'bhs') {
+ const detected = detectBandedAxisFromSemantics(sem, table, { preferAxis: 'x' });
+ const declaration: LayoutDeclaration = {
+ axisFlags: detected ? { [detected.axis]: { banded: true } } : { x: { banded: true } },
+ resolvedTypes: detected?.resolvedTypes,
+ };
+ const baseSize = resolveBaseSize(input.chart_spec.baseSize, input.chart_spec.canvasSize);
+ const options = {
+ facetFixedPadding: { width: 50, height: 40 },
+ facetGap: 10,
+ targetBandAR: 10,
+ ...deriveStretchCaps(baseSize, input.chart_spec.canvasSize, {}),
+ };
+ const budgets = computeChannelBudgets(sem, declaration, table, baseSize, options);
+ const overflow = filterOverflow(sem, declaration, encodings, table, budgets, new Set(['bar']));
+ table = overflow.filteredData;
+ overflow.truncations.forEach((t) => keptCategoryOrder.set(t.field, t.keptValues as Cell[]));
+ }
+
+ const params: string[] = [];
+ const size = clampChartSize(
+ input.chart_spec.baseSize?.width ?? DEFAULT_SIZE.width,
+ input.chart_spec.baseSize?.height ?? DEFAULT_SIZE.height,
+ );
+
+ if (mapping.noAxes) {
+ buildPartToWhole(params, mapping.cht, sem, table, fieldOf);
+ } else if (mapping.xy) {
+ buildScatter(params, table, fieldOf, isMeasure, seriesField, flintType);
+ } else {
+ buildAxes(
+ params, mapping, flintType, sem, table,
+ fieldOf, typeOf, isMeasure, seriesField, keptCategoryOrder,
+ );
+ }
+
+ params.push(`chs=${size.width}x${size.height}`);
+ const title = input.chart_spec.title?.trim();
+ if (title) params.push(`chtt=${encodeSegment(title)}`);
+
+ return { type: 'image-charts', url: IMAGE_CHARTS_ENDPOINT + params.join('&') };
+}
+
+/** Pie / doughnut: one series of slices, each with its own label and color. */
+function buildPartToWhole(
+ params: string[],
+ cht: string,
+ sem: SemanticResult,
+ table: any[],
+ fieldOf: (ch: string) => string | undefined,
+): void {
+ const catField = fieldOf('color') ?? fieldOf('x');
+ const measField = fieldOf('size') ?? fieldOf('theta') ?? fieldOf('y');
+ if (!catField || !measField) {
+ throw new Error(`Image-Charts backend could not resolve slice/value fields for a part-to-whole chart (category=${catField}, value=${measField}).`);
+ }
+ const slices = distinct(table, catField);
+ const measCh = fieldOf('size') === measField ? 'size' : fieldOf('theta') === measField ? 'theta' : 'y';
+ const aggregate = sem[measCh]?.aggregationDefault ?? 'sum';
+ const [values] = pivotValues(table, catField, measField, undefined, slices, ['__single__'], aggregate);
+
+ params.push(`cht=${cht}`);
+ params.push(`chd=a:${values.map(formatValue).join(',')}`);
+ params.push(`chl=${slices.map((s) => encodeSegment(String(s))).join('|')}`);
+ params.push(`chco=${slices.map((_s, i) => SERIES_COLORS[i % SERIES_COLORS.length]).join('|')}`);
+}
+
+/** Scatter: `lxy` with one (x-set, y-set) pair per series, drawn as markers. */
+function buildScatter(
+ params: string[],
+ table: any[],
+ fieldOf: (ch: string) => string | undefined,
+ isMeasure: (ch: string) => boolean,
+ seriesField: string | undefined,
+ flintType: string,
+): void {
+ const xField = fieldOf('x');
+ const yField = fieldOf('y');
+ if (!xField || !yField || !isMeasure('x') || !isMeasure('y')) {
+ throw new Error(`Image-Charts backend requires quantitative x and y fields for "${flintType}".`);
+ }
+ const seriesKeys = seriesField ? distinct(table, seriesField) : ['__single__'];
+ const datasets: string[] = [];
+ const markers: string[] = [];
+ const colors: string[] = [];
+ seriesKeys.forEach((key, index) => {
+ const rows = seriesField ? table.filter((r) => r[seriesField] === key) : table;
+ const xs = rows.map((r) => Number(r[xField]));
+ const ys = rows.map((r) => Number(r[yField]));
+ datasets.push(xs.map(formatValue).join(','));
+ datasets.push(ys.map(formatValue).join(','));
+ const color = SERIES_COLORS[index % SERIES_COLORS.length];
+ colors.push(color);
+ markers.push(`s,${color},${index},-1,6`);
+ });
+
+ params.push('cht=lxy');
+ params.push(`chd=a:${datasets.join('|')}`);
+ params.push(`chco=${colors.join(',')}`);
+ params.push(`chm=${markers.join('|')}`);
+ if (seriesField && seriesKeys.length > 1) {
+ params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`);
+ }
+}
+
+/** Bar / line / area / radar: a category axis plus one measure per series. */
+function buildAxes(
+ params: string[],
+ mapping: { cht: string; horizontal?: string; radar?: boolean; area?: boolean },
+ flintType: string,
+ sem: SemanticResult,
+ table: any[],
+ fieldOf: (ch: string) => string | undefined,
+ typeOf: (ch: string) => string | undefined,
+ isMeasure: (ch: string) => boolean,
+ seriesField: string | undefined,
+ keptCategoryOrder: Map,
+): void {
+ // Horizontal bar when the measure sits on x and the category on y.
+ const horizontal = Boolean(mapping.horizontal) && isMeasure('x') && !isMeasure('y');
+ const catCh = horizontal ? 'y' : 'x';
+ const measCh = horizontal ? 'x' : 'y';
+ const catField = fieldOf(catCh);
+ const measField = fieldOf(measCh);
+ if (!catField || !measField) {
+ throw new Error(`Image-Charts backend could not resolve category/measure for "${flintType}" (category=${catField}, measure=${measField}).`);
+ }
+
+ let categories = keptCategoryOrder.get(catField) ?? distinct(table, catField);
+ // Ordered domains (line / area over time or a numeric axis) sort ascending.
+ if (!mapping.radar && (flintType === 'Line Chart' || flintType === 'Area Chart' || flintType === 'Sparkline')) {
+ if (typeOf(catCh) === 'temporal') {
+ categories = [...categories].sort((a, b) => new Date(String(a)).getTime() - new Date(String(b)).getTime());
+ } else if (typeOf(catCh) === 'quantitative') {
+ categories = [...categories].sort((a, b) => Number(a) - Number(b));
+ }
+ }
+
+ const seriesKeys = seriesField ? distinct(table, seriesField) : [measField];
+ const aggregate = sem[measCh]?.aggregationDefault ?? 'sum';
+ const seriesValues = pivotValues(table, catField, measField, seriesField, categories, seriesKeys, aggregate);
+
+ const cht = horizontal ? (mapping.horizontal as string) : mapping.cht;
+ params.push(`cht=${cht}`);
+ params.push(`chd=a:${seriesValues.map((vals) => vals.map(formatValue).join(',')).join('|')}`);
+
+ // Category axis: index 0 (x) for vertical/radar, index 1 (y) for horizontal.
+ const categoryLabels = categories.map((c) => encodeSegment(String(c))).join('|');
+ if (mapping.radar) {
+ params.push('chxt=r');
+ params.push(`chxl=0:|${categoryLabels}`);
+ } else {
+ params.push('chxt=x,y');
+ params.push(`chxl=${horizontal ? 1 : 0}:|${categoryLabels}`);
+ }
+
+ const seriesColors = seriesKeys.map((_k, i) => SERIES_COLORS[i % SERIES_COLORS.length]);
+ if (seriesKeys.length > 1 || mapping.area) {
+ params.push(`chco=${seriesColors.join(',')}`);
+ }
+ if (mapping.area) {
+ params.push(`chm=${seriesColors.map((c, i) => `B,${c},${i},0,0`).join('|')}`);
+ }
+ if (seriesField && seriesKeys.length > 1) {
+ params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`);
+ }
+}
diff --git a/packages/flint-js/src/image-charts/chart-types.ts b/packages/flint-js/src/image-charts/chart-types.ts
new file mode 100644
index 00000000..7a66a373
--- /dev/null
+++ b/packages/flint-js/src/image-charts/chart-types.ts
@@ -0,0 +1,49 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Image-Charts chart-type mapping.
+ *
+ * Image-Charts renders through a fixed set of `cht` chart codes (the Google
+ * Image Charts / Image-Charts query grammar), so a Flint chart type maps to the
+ * closest native `cht`. Orientation (vertical vs horizontal) is decided by the
+ * assembler from channel semantics and selects the `bv*` vs `bh*` family.
+ *
+ * Coverage is partial by design (like the Excel backend): only chart types with
+ * a faithful `cht` equivalent are mapped. Everything else throws in `assemble`.
+ */
+
+/** Which Image-Charts `cht` family a Flint chart type maps to. */
+export interface ImageChartsTypeMapping {
+ /** Base Image-Charts `cht` value (vertical / category-on-x orientation). */
+ cht: string;
+ /** `cht` for the horizontal (category-on-y) variant, when supported. */
+ horizontal?: string;
+ /** True for pie/doughnut charts: slice labels, no value/category axes. */
+ noAxes?: boolean;
+ /** True for XY (both-measure) scatter charts rendered as `lxy`. */
+ xy?: boolean;
+ /** True for radar charts, which use the `chxt=r` polar axis. */
+ radar?: boolean;
+ /** True for area charts: a line (`lc`) plus a `chm=B` fill to the baseline. */
+ area?: boolean;
+}
+
+/** Flint chart type (display name) → Image-Charts `cht` family. */
+export const IMAGE_CHARTS_TYPE_MAP: Record = {
+ 'Bar Chart': { cht: 'bvg', horizontal: 'bhg' },
+ 'Grouped Bar Chart': { cht: 'bvg', horizontal: 'bhg' },
+ 'Stacked Bar Chart': { cht: 'bvs', horizontal: 'bhs' },
+ 'Line Chart': { cht: 'lc' },
+ 'Sparkline': { cht: 'ls' },
+ 'Area Chart': { cht: 'lc', area: true },
+ 'Scatter Plot': { cht: 'lxy', xy: true },
+ 'Pie Chart': { cht: 'p', noAxes: true },
+ 'Donut Chart': { cht: 'pd', noAxes: true },
+ 'Radar Chart': { cht: 'r', radar: true },
+};
+
+/** Chart types this backend can render as an Image-Charts URL. */
+export function isImageChartsSupported(flintChartType: string): boolean {
+ return flintChartType in IMAGE_CHARTS_TYPE_MAP;
+}
diff --git a/packages/flint-js/src/image-charts/index.ts b/packages/flint-js/src/image-charts/index.ts
new file mode 100644
index 00000000..ddc4957c
--- /dev/null
+++ b/packages/flint-js/src/image-charts/index.ts
@@ -0,0 +1,28 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * @module flint-chart/image-charts
+ *
+ * Image-Charts backend for flint-chart.
+ *
+ * Compiles the core semantic layer into a single permanent
+ * `https://image-charts.com` chart URL (the Google Image Charts / Image-Charts
+ * query grammar). The URL renders server-side and embeds anywhere an `
`
+ * works — email, PDF, Slack, no-code tools — with no runtime JavaScript.
+ *
+ * Architecture contrast with the other backends:
+ * VL: encoding-channel spec — { encoding: { x, y }, mark }
+ * EC: series-based option — { series: [...], xAxis, yAxis }
+ * CJS: dataset-based config — { type, data: { labels, datasets } }
+ * Excel: range/matrix spec — { chartType, data: [[...]], axes }
+ * Image-Charts: hosted-image URL — { type: 'image-charts', url }
+ *
+ * `assembleImageCharts` is PURE: it builds a string, performs no network I/O and
+ * no signing, and emits unsigned free-tier URLs only.
+ */
+
+export { assembleImageCharts } from './assemble';
+export type { ImageChartsArtifact } from './assemble';
+export { IMAGE_CHARTS_TYPE_MAP, isImageChartsSupported } from './chart-types';
+export type { ImageChartsTypeMapping } from './chart-types';
diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts
index 824f9746..eeb753d1 100644
--- a/packages/flint-js/src/index.ts
+++ b/packages/flint-js/src/index.ts
@@ -57,3 +57,6 @@ export * from './plotly';
// Excel backend: assembleExcel + Excel chart spec types
export * from './excel';
+
+// Image-Charts backend: assembleImageCharts + hosted-image-URL artifact type
+export * from './image-charts';
diff --git a/packages/flint-js/src/test-data/image-charts-tests.ts b/packages/flint-js/src/test-data/image-charts-tests.ts
new file mode 100644
index 00000000..abd64f6a
--- /dev/null
+++ b/packages/flint-js/src/test-data/image-charts-tests.ts
@@ -0,0 +1,123 @@
+// Copyright (c) Microsoft Corporation.
+// Licensed under the MIT License.
+
+/**
+ * Gallery generators for the Image-Charts backend.
+ *
+ * These cases exercise the URL-grammar paths the backend builds: a plain bar
+ * (`cht=bvg`, `chxl` categories), a multi-series grouped bar (`chco` + `chdl`
+ * legend), a line, a filled area (`chm=B`), a pie (per-slice `chl` + `chco`),
+ * and a scatter (`cht=lxy` + `chm=s` markers). The data is backend-agnostic —
+ * the gallery renders it through `assembleImageCharts`.
+ */
+
+import { Type } from './df-types';
+import { TestCase, makeField, makeEncodingItem } from './types';
+
+const CATEGORY_META = { type: Type.String, semanticType: 'Category', levels: [] as any[] };
+const QUANTITY_META = { type: Type.Number, semanticType: 'Quantity', levels: [] as any[] };
+
+export function genImageChartsTests(): TestCase[] {
+ return [
+ {
+ title: 'Bar — sales by region',
+ description: 'A single-series vertical bar, category labels on the x axis.',
+ tags: ['bar', 'nominal', 'quantitative', 'image-charts'],
+ chartType: 'Bar Chart',
+ data: [
+ { Region: 'North', Sales: 42 },
+ { Region: 'South', Sales: 35 },
+ { Region: 'East', Sales: 58 },
+ { Region: 'West', Sales: 27 },
+ ],
+ fields: [makeField('Region'), makeField('Sales')],
+ metadata: { Region: CATEGORY_META, Sales: QUANTITY_META },
+ encodingMap: { x: makeEncodingItem('Region'), y: makeEncodingItem('Sales') },
+ },
+ {
+ title: 'Grouped bar — sales by region and channel',
+ description: 'Two series dodge per category, driving a per-series palette and a legend.',
+ tags: ['bar', 'grouped', 'series', 'legend', 'image-charts'],
+ chartType: 'Grouped Bar Chart',
+ data: [
+ { Region: 'North', Sales: 42, Channel: 'Retail' },
+ { Region: 'North', Sales: 20, Channel: 'Online' },
+ { Region: 'South', Sales: 35, Channel: 'Retail' },
+ { Region: 'South', Sales: 31, Channel: 'Online' },
+ ],
+ fields: [makeField('Region'), makeField('Sales'), makeField('Channel')],
+ metadata: { Region: CATEGORY_META, Sales: QUANTITY_META, Channel: CATEGORY_META },
+ encodingMap: {
+ x: makeEncodingItem('Region'),
+ y: makeEncodingItem('Sales'),
+ group: makeEncodingItem('Channel'),
+ },
+ },
+ {
+ title: 'Line — monthly signups',
+ description: 'An ordered category axis with a single quantitative series.',
+ tags: ['line', 'temporal', 'quantitative', 'image-charts'],
+ chartType: 'Line Chart',
+ data: [
+ { Month: '2026-01', Signups: 120 },
+ { Month: '2026-02', Signups: 150 },
+ { Month: '2026-03', Signups: 138 },
+ { Month: '2026-04', Signups: 176 },
+ ],
+ fields: [makeField('Month'), makeField('Signups')],
+ metadata: {
+ Month: { type: Type.String, semanticType: 'YearMonth', levels: [] },
+ Signups: QUANTITY_META,
+ },
+ encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Signups') },
+ },
+ {
+ title: 'Area — traffic over time',
+ description: 'A line filled to the baseline via a chm=B marker.',
+ tags: ['area', 'temporal', 'quantitative', 'image-charts'],
+ chartType: 'Area Chart',
+ data: [
+ { Day: '2026-01-01', Visits: 30 },
+ { Day: '2026-01-02', Visits: 52 },
+ { Day: '2026-01-03', Visits: 41 },
+ { Day: '2026-01-04', Visits: 66 },
+ ],
+ fields: [makeField('Day'), makeField('Visits')],
+ metadata: {
+ Day: { type: Type.Date, semanticType: 'Date', levels: [] },
+ Visits: QUANTITY_META,
+ },
+ encodingMap: { x: makeEncodingItem('Day'), y: makeEncodingItem('Visits') },
+ },
+ {
+ title: 'Pie — market share',
+ description: 'Slice labels and a per-slice palette.',
+ tags: ['pie', 'part-to-whole', 'image-charts'],
+ chartType: 'Pie Chart',
+ data: [
+ { Vendor: 'Acme', Share: 45 },
+ { Vendor: 'Globex', Share: 30 },
+ { Vendor: 'Initech', Share: 15 },
+ { Vendor: 'Umbrella', Share: 10 },
+ ],
+ fields: [makeField('Vendor'), makeField('Share')],
+ metadata: { Vendor: CATEGORY_META, Share: QUANTITY_META },
+ encodingMap: { color: makeEncodingItem('Vendor'), size: makeEncodingItem('Share') },
+ },
+ {
+ title: 'Scatter — weight vs mpg',
+ description: 'Two measures on lxy, drawn as chm=s point markers.',
+ tags: ['scatter', 'quantitative', 'image-charts'],
+ chartType: 'Scatter Plot',
+ data: [
+ { Weight: 1.6, Mpg: 32 },
+ { Weight: 2.1, Mpg: 27 },
+ { Weight: 1.9, Mpg: 29 },
+ { Weight: 2.4, Mpg: 24 },
+ ],
+ fields: [makeField('Weight'), makeField('Mpg')],
+ metadata: { Weight: QUANTITY_META, Mpg: QUANTITY_META },
+ encodingMap: { x: makeEncodingItem('Weight'), y: makeEncodingItem('Mpg') },
+ },
+ ];
+}
diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts
index e59fc3ad..34c54324 100644
--- a/packages/flint-js/src/test-data/index.ts
+++ b/packages/flint-js/src/test-data/index.ts
@@ -41,6 +41,7 @@ export { genLineAreaStretchTests } from './line-area-stretch-tests';
export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests';
export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests';
export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests';
+export { genImageChartsTests } from './image-charts-tests';
export { genDiscreteAxisTests } from './discrete-axis-tests';
export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests';
export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests';
@@ -116,6 +117,7 @@ import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests';
import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests';
import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests';
import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests';
+import { genImageChartsTests } from './image-charts-tests';
import {
genGalleryRegionalSurveyScatterTests,
genGalleryRegionalSurveyLineTests,
@@ -256,6 +258,7 @@ export const TEST_GENERATORS: Record TestCase[]> = {
'Chart.js: Stress Tests': genChartJsStressTests,
'Plotly: Core Templates': genPlotlyCoreTests,
'Plotly: Facets': genPlotlyFacetTests,
+ 'Image-Charts: Core Templates': genImageChartsTests,
'Gallery: Scatter': genGalleryRegionalSurveyScatterTests,
'Gallery: Line': genGalleryRegionalSurveyLineTests,
'Gallery: Bar': genGalleryRegionalSurveyBarTests,
diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts
index 0b0bdb5f..c6aaad15 100644
--- a/packages/flint-js/tests/smoke.test.ts
+++ b/packages/flint-js/tests/smoke.test.ts
@@ -8,6 +8,7 @@ import {
assembleChartjs,
assemblePlotly,
assembleExcel,
+ assembleImageCharts,
} from '../src';
const DATA = [
@@ -98,6 +99,41 @@ describe('public API smoke', () => {
expect(spec.seriesBy).toBe('Columns');
});
+ it('assembleImageCharts returns a permanent free-tier Image-Charts URL', () => {
+ const artifact = assembleImageCharts({
+ data: { values: [
+ { Category: 'A', Value: 10 },
+ { Category: 'B', Value: 20 },
+ { Category: 'C', Value: 15 },
+ ] },
+ semantic_types: { Category: 'Category', Value: 'Quantity' },
+ chart_spec: {
+ chartType: 'Bar Chart',
+ encodings: { x: 'Category', y: 'Value' },
+ title: 'Sales by region',
+ },
+ });
+
+ expect(artifact.type).toBe('image-charts');
+ expect(artifact.url.startsWith('https://image-charts.com/chart?')).toBe(true);
+ expect(artifact.url).toContain('cht=bvg');
+ expect(artifact.url).toContain('chd=a:10,20,15');
+ expect(artifact.url).toContain('chxl=0:|A|B|C');
+ expect(artifact.url).toContain('chtt=Sales+by+region');
+ // Free tier only: never signed, never an output override.
+ expect(artifact.url).not.toContain('icac');
+ expect(artifact.url).not.toContain('ichm');
+ expect(artifact.url).not.toContain('chof');
+ });
+
+ it('assembleImageCharts throws on chart types with no faithful cht', () => {
+ expect(() => assembleImageCharts({
+ data: { values: [{ Group: 'A', Value: 1 }, { Group: 'A', Value: 5 }] },
+ semantic_types: { Group: 'Category', Value: 'Quantity' },
+ chart_spec: { chartType: 'Boxplot', encodings: { x: 'Group', y: 'Value' } },
+ })).toThrow('does not support chart type "Boxplot"');
+ });
+
it('assembleExcel uses field display names for native axis titles', () => {
const spec = assembleExcel({
data: { values: [
diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts
index 519b97fa..8d27d220 100644
--- a/packages/flint-js/tsup.config.ts
+++ b/packages/flint-js/tsup.config.ts
@@ -9,6 +9,7 @@ export default defineConfig({
'chartjs/index': 'src/chartjs/index.ts',
'plotly/index': 'src/plotly/index.ts',
'excel/index': 'src/excel/index.ts',
+ 'image-charts/index': 'src/image-charts/index.ts',
'test-data/index': 'src/test-data/index.ts',
'gallery/index': 'src/gallery/index.ts',
},