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', },