diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 27028335..53745756 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -24,10 +24,12 @@ jobs: node-version: ${{ matrix.node }} cache: npm - run: npm ci + - run: npm run check:chart-types -w packages/flint-js - run: npm run typecheck - run: npm run lint - run: npm run test - run: npm run build + - run: npm run test:types -w packages/flint-js site: name: Build site diff --git a/README.md b/README.md index f61ef7e1..72648126 100644 --- a/README.md +++ b/README.md @@ -5,6 +5,8 @@ [![arXiv: 2607.20775](https://img.shields.io/badge/arXiv-2607.20775-b31b1b.svg)](https://arxiv.org/abs/2607.20775) [![CI](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml/badge.svg)](https://github.com/microsoft/flint-chart/actions/workflows/ci.yml) [![License: MIT](https://img.shields.io/badge/License-MIT-yellow.svg)](LICENSE) +[![Discord](https://img.shields.io/badge/discord-chat-green?logo=discord)](https://discord.gg/hnm4Y8YX8) +[![WeChat](https://img.shields.io/badge/wechat-group-07C160?logo=wechat&logoColor=white)](#wechat) **Please visit:** [**Flint Project Site**](https://microsoft.github.io/flint-chart/) | [**Visual Themes**](https://microsoft.github.io/flint-chart/#/themes) | [**MCP Server Guide**](https://microsoft.github.io/flint-chart/#/mcp) | [**中文主页**](https://microsoft.github.io/flint-chart/#/zh) @@ -297,6 +299,19 @@ Flint is built by [Microsoft Research](https://www.microsoft.com/en-us/research/ in collaboration with the [IDEAS Lab](https://ideas-lab.net/), Renmin University of China. We welcome you to join us — see [Contributing](#contributing) to get involved. +## Community + +Chat with the team and other users: + +- **Discord** — [join the server](https://discord.gg/hnm4Y8YX8) +- **WeChat** — scan the QR code to join the user group + + + +

+ Flint WeChat user group QR code +

+ A research paper describing Flint is coming soon. ## Trademarks @@ -311,3 +326,4 @@ or logos is subject to those third parties' policies. ## License [MIT](LICENSE) © Microsoft Corporation + diff --git a/docs/adding-a-chart-template.md b/docs/adding-a-chart-template.md index fbb6b50b..0ed6190f 100644 --- a/docs/adding-a-chart-template.md +++ b/docs/adding-a-chart-template.md @@ -91,6 +91,34 @@ In `packages/flint-js/src//templates/index.ts`: Vega-Lite also runs `withInjectedProperties()` to attach shared facet and log-scale properties across templates. Follow existing entries in that file when your chart needs the same hooks. +### Regenerate Vega-Lite authoring types + +After changing Vega-Lite registry names, channels, properties, or encoding-action +controls, run from the repository root: + +```bash +npm run gen:chart-types -w packages/flint-js +npm run check:chart-types -w packages/flint-js +npm run typecheck:js +npm run test -w packages/flint-js -- tests/vegalite-chart-types.test.ts +npm run build:js +npm run test:types -w packages/flint-js +``` + +Check in `packages/flint-js/src/vegalite/chart-types.generated.ts`; do not edit it +by hand. The generator in `packages/flint-js/scripts/vegalite-chart-types.ts` +reads the source registry (including injected properties), not `dist`. Builds +regenerate before declaration emission. CI checks drift **before** any build can +rewrite the output, then runs source and built-package TypeScript fixtures. +The built-package check uses public package entrypoints without path aliases, +with `skipLibCheck: false` and `exactOptionalPropertyTypes: true`. + +New metadata/value shapes need explicit generator support and tests; unsupported +shapes fail generation rather than producing loose types. Keep the two +assembler-backed supplements (`facetColumns` and deprecated `showTextLabels`) +aligned with `vegalite/assemble.ts`. Do not infer static domains from runtime +applicability callbacks or dynamic pivot/arrangement IDs. + --- # §4 Add test data and gallery coverage diff --git a/docs/figs/QR-code.png b/docs/figs/QR-code.png new file mode 100644 index 00000000..e0047a3e Binary files /dev/null and b/docs/figs/QR-code.png differ diff --git a/package-lock.json b/package-lock.json index 6b9a3687..e77aae31 100644 --- a/package-lock.json +++ b/package-lock.json @@ -6139,7 +6139,6 @@ "os": [ "android" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6161,7 +6160,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6183,7 +6181,6 @@ "os": [ "darwin" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6205,7 +6202,6 @@ "os": [ "freebsd" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6227,7 +6223,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6249,7 +6244,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6271,7 +6265,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6293,7 +6286,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6315,7 +6307,6 @@ "os": [ "linux" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6337,7 +6328,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, @@ -6359,7 +6349,6 @@ "os": [ "win32" ], - "peer": true, "engines": { "node": ">= 12.0.0" }, diff --git a/packages/flint-js/README.md b/packages/flint-js/README.md index a7c19877..6ad67c09 100644 --- a/packages/flint-js/README.md +++ b/packages/flint-js/README.md @@ -87,6 +87,65 @@ const xl = assembleExcel(input); // Native Excel chart artifact Both ESM (`import`) and CommonJS (`require`) builds are published, with type declarations for every entry point. +## Typed Vega-Lite authoring + +Use `satisfies VegaLiteChartSpec` to check an authored spec without changing +`ChartAssemblyInput` or the assembly functions: + +```ts +import { assembleVegaLite, type VegaLiteChartSpec } from 'flint-chart'; + +const chart = { + chartType: 'Area Chart', + title: 'Revenue by quarter', + encodings: { x: 'quarter', y: 'revenue', color: 'region' }, + chartProperties: { stackMode: 'layered', interpolate: 'monotone' }, +} satisfies VegaLiteChartSpec; + +const spec = assembleVegaLite({ + data: { values: [{ quarter: 'Q1', revenue: 120, region: 'North' }] }, + chart_spec: chart, +}); +``` + +The package root and `flint-chart/vegalite` export three **type-only** helpers: + +| Type | Contract | +|---|---| +| `VegaLiteChartType` | Literal names from the Vega-Lite template registry | +| `VegaLiteChartPropertiesMap` | Per-chart optional property shapes, e.g. `VegaLiteChartPropertiesMap['Bar Chart']` | +| `VegaLiteChartSpec` | Nongeneric union discriminated by `chartType`, preserving the native title, subtitle, sizes, and encodings (including shorthand and static series) | + +Properties come from the registry's `properties` and `encodingActions` controls; +both are stored in `chart_spec.chartProperties`. For example, bar `sort` accepts +`'value-asc'` or `'value-desc'`, and only Area Chart accepts the `'layered'` +stack mode. Discrete values are literals, not display labels; registered array +options retain their tuple values. Continuous controls remain `number`, not a +UI slider range. Omit a property or pass `undefined` to request default behavior, +including when using TypeScript's `exactOptionalPropertyTypes`. + +Known limitation: `projectionCenter` types describe registered presets, but +runtime normalization still compares arrays by reference, so fresh or +JSON-round-tripped tuples can warn and fall back to the default; this behavior +is unchanged. + +Two assembler-supported inputs supplement that metadata: `facetColumns?: number` +on templates declaring a `column` channel, and deprecated `showTextLabels?: boolean` +where `showValueLabels` is declared. Legacy `showTextLabels: true` requests labels; +`false` or omission leaves the automatic choice intact. An explicit boolean +`showValueLabels` takes precedence over the legacy input. The alias adds no +second UI control. + +This is an opt-in **static authoring** contract, not runtime validation or a +guarantee of applicability. Data and encodings still determine which options +apply; inspect `getChartOptions` for the current chart. It does not restrict +encoding channels beyond the native encoding types. Data-dependent +`chartProperties.chartType`, `pivot`, and `arrange` IDs, and other runtime inputs +outside the declared metadata, are deliberately excluded. Dynamic callers can +continue using the unchanged, broader `ChartAssemblyInput`. As with other +TypeScript object types, excess-key checks apply to fresh literals; these types +do not validate external JSON. + ## Rendering The web backends produce **specs**, not pixels. To render those specs to PNG or SVG diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index a8aefdc6..984909dd 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -118,8 +118,11 @@ "LICENSE" ], "scripts": { - "build": "tsup", - "dev": "tsup --watch", + "build": "npm run gen:chart-types && tsup", + "dev": "npm run gen:chart-types && tsup --watch", + "gen:chart-types": "node scripts/gen-vegalite-chart-types.mjs", + "check:chart-types": "node scripts/gen-vegalite-chart-types.mjs --check", + "test:types": "tsc --noEmit -p tsconfig.package-test.json", "typecheck": "tsc --noEmit && tsc --noEmit -p tsconfig.test.json", "test": "vitest run", "test:watch": "vitest", @@ -127,7 +130,7 @@ "lint:fix": "eslint \"src/**/*.{ts,tsx}\" --fix", "format": "prettier --write \"src/**/*.{ts,tsx,json,md}\"", "clean": "rimraf dist", - "prepublishOnly": "npm run clean && npm run typecheck && npm run test && npm run build", + "prepublishOnly": "npm run clean && npm run typecheck && npm run test && npm run build && npm run test:types", "release": "npm publish --access public" }, "peerDependencies": { diff --git a/packages/flint-js/scripts/gen-vegalite-chart-types.mjs b/packages/flint-js/scripts/gen-vegalite-chart-types.mjs new file mode 100644 index 00000000..8a1ab0b4 --- /dev/null +++ b/packages/flint-js/scripts/gen-vegalite-chart-types.mjs @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { existsSync, readFileSync, writeFileSync } from 'node:fs'; +import { fileURLToPath } from 'node:url'; +import { build } from 'esbuild'; + +const args = process.argv.slice(2); +if (args.length > 1 || (args.length === 1 && args[0] !== '--check')) { + throw new Error('Usage: node scripts/gen-vegalite-chart-types.mjs [--check]'); +} + +// Bootstrap from source, as gen-chart-reference does, without requiring dist. +// Keeping the bundle in memory avoids temporary files and platform-specific cleanup. +const bundle = await build({ + entryPoints: [fileURLToPath(new URL('./vegalite-chart-types.ts', import.meta.url))], + bundle: true, + platform: 'node', + format: 'esm', + write: false, +}); +const { generateVegaLiteChartTypes } = await import( + `data:text/javascript;base64,${Buffer.from(bundle.outputFiles[0].text).toString('base64')}` +); +const output = new URL('../src/vegalite/chart-types.generated.ts', import.meta.url); +const expected = generateVegaLiteChartTypes(); +const actual = existsSync(output) ? readFileSync(output, 'utf8').replace(/\r\n/g, '\n') : undefined; +if (args[0] === '--check') { + if (actual !== expected) { + throw new Error('Vega-Lite authoring types are stale. Run npm run gen:chart-types -w packages/flint-js.'); + } +} else if (actual !== expected) { + writeFileSync(output, expected); +} diff --git a/packages/flint-js/scripts/vegalite-chart-types.ts b/packages/flint-js/scripts/vegalite-chart-types.ts new file mode 100644 index 00000000..aab6efc8 --- /dev/null +++ b/packages/flint-js/scripts/vegalite-chart-types.ts @@ -0,0 +1,147 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import type { ChartPropertyVariant, ChartTemplateDef, EncodingActionDef } from '../src/core/types'; +import { vlAllTemplateDefs } from '../src/vegalite/templates'; + +type PropertyDomain = ChartPropertyVariant | EncodingActionDef['control']; +const dynamicKeys = new Set(['chartType', 'pivot', 'arrange']); + +// Discrete metadata values are untyped in the registry. Validate that boundary +// rather than silently emitting a wider type when a new value shape is added. +function literalType(value: unknown, context: string, ancestors = new Set()): string { + if (value === undefined) return 'undefined'; + if (value === null) return 'null'; + if (typeof value === 'string') return JSON.stringify(value); + if (typeof value === 'boolean') return String(value); + if (typeof value === 'number' && Number.isFinite(value)) return String(value); + if (Array.isArray(value)) { + if (ancestors.has(value)) throw new Error(`${context}: cyclic enum array`); + ancestors.add(value); + const items: readonly unknown[] = value; + const types: string[] = []; + for (let i = 0; i < items.length; i++) { + if (!Object.prototype.hasOwnProperty.call(items, i)) throw new Error(`${context}: sparse enum array`); + types.push(literalType(items[i], `${context}[${i}]`, ancestors)); + } + ancestors.delete(value); + return `[${types.join(', ')}]`; + } + throw new Error(`${context}: unsupported enum value (${typeof value})`); +} + +export function propertyType(domain: PropertyDomain, context: string): string { + switch (domain.type) { + case 'binary': + case 'continuous': { + const type = domain.type === 'binary' ? 'boolean' : 'number'; + if ('defaultValue' in domain && domain.defaultValue !== undefined) { + if (typeof domain.defaultValue !== type || + (type === 'number' && !Number.isFinite(domain.defaultValue))) { + throw new Error(`${context}: invalid ${domain.type} default`); + } + } + return `${type} | undefined`; + } + case 'discrete': { + if (!Array.isArray(domain.options) || domain.options.length === 0) { + throw new Error(`${context}: discrete options must be nonempty`); + } + const values = new Set(['undefined']); + for (const option of domain.options) { + if (!option || !Object.prototype.hasOwnProperty.call(option, 'value')) { + throw new Error(`${context}: enum option must declare a value`); + } + values.add(literalType(option.value, context)); + } + if ('defaultValue' in domain) { + values.add(literalType(domain.defaultValue, `${context} default`)); + } + return [...values].sort().join(' | '); + } + default: + throw new Error(`${context}: unsupported property metadata`); + } +} + +export function generateVegaLiteChartTypes( + definitions: readonly ChartTemplateDef[] = vlAllTemplateDefs, +): string { + if (definitions.length === 0) throw new Error('Vega-Lite registry is empty'); + const charts = new Map>(); + for (const definition of definitions) { + if (!definition.chart || charts.has(definition.chart)) { + throw new Error(`Invalid or duplicate chart name: ${definition.chart}`); + } + const properties = new Map(); + const add = (key: string, type: string): void => { + if (!key || properties.has(key)) { + throw new Error(`${definition.chart}: invalid or duplicate property ${key}`); + } + properties.set(key, type); + }; + for (const property of definition.properties ?? []) { + if (!dynamicKeys.has(property.key)) { + add(property.key, propertyType(property, `${definition.chart}.${property.key}`)); + } + } + for (const action of definition.encodingActions ?? []) { + if (!dynamicKeys.has(action.key)) { + add(action.key, propertyType(action.control, `${definition.chart}.${action.key}`)); + } + } + // assembleVegaLite exposes facetColumns dynamically for column facets. + if (definition.channels.includes('column')) add('facetColumns', 'number | undefined'); + // resolveValueLabelChoice accepts this legacy caller input, not a second UI control. + if (properties.has('showValueLabels')) add('showTextLabels', 'boolean | undefined'); + charts.set(definition.chart, properties); + } + + const names = [...charts.keys()].sort(); + const lines = [ + '// Copyright (c) Microsoft Corporation.', + '// Licensed under the MIT License.', + '', + '// Generated by npm run gen:chart-types. Do not edit by hand.', + "import type { ChartAssemblyInput } from '../core/types';", + '', + '/** Registered Vega-Lite template names. */', + 'export type VegaLiteChartType =', + ...names.map((name, i) => ` | ${JSON.stringify(name)}${i === names.length - 1 ? ';' : ''}`), + '', + '/** Static property domains; runtime applicability still depends on data and encodings. */', + 'export interface VegaLiteChartPropertiesMap {', + ]; + for (const name of names) { + const properties = charts.get(name)!; + if (properties.size === 0) { + lines.push(` ${JSON.stringify(name)}: never;`); + continue; + } + lines.push(` ${JSON.stringify(name)}: {`); + for (const key of [...properties.keys()].sort()) { + if (key === 'facetColumns') { + lines.push(' /** Column facet layout; availability is data dependent. */'); + } + if (key === 'showTextLabels') { + lines.push(' /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */'); + } + lines.push(` ${JSON.stringify(key)}?: ${properties.get(key)};`); + } + lines.push(' };'); + } + lines.push( + '}', + '', + '/** Opt-in authoring union. All other native chart_spec fields are preserved. */', + 'export type VegaLiteChartSpec = {', + ' [C in VegaLiteChartType]:', + " Omit & {", + ' chartType: C;', + ' chartProperties?: VegaLiteChartPropertiesMap[C];', + ' };', + '}[VegaLiteChartType];', + '', + ); + return lines.join('\n'); +} diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 1112f31d..15d98451 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -52,6 +52,7 @@ export * from './core'; // Vega-Lite backend: assembleVegaLite, templates, spec instantiation export * from './vegalite'; +export type { VegaLiteChartType, VegaLiteChartPropertiesMap, VegaLiteChartSpec } from './vegalite/chart-types.generated'; // ECharts backend: assembleECharts, templates, spec instantiation export * from './echarts'; diff --git a/packages/flint-js/src/vegalite/chart-types.generated.ts b/packages/flint-js/src/vegalite/chart-types.generated.ts new file mode 100644 index 00000000..1fae6ffd --- /dev/null +++ b/packages/flint-js/src/vegalite/chart-types.generated.ts @@ -0,0 +1,388 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Generated by npm run gen:chart-types. Do not edit by hand. +import type { ChartAssemblyInput } from '../core/types'; + +/** Registered Vega-Lite template names. */ +export type VegaLiteChartType = + | "Area Chart" + | "Bar Chart" + | "Bar Table" + | "Boxplot" + | "Bullet Chart" + | "Bump Chart" + | "Calendar Heatmap" + | "Candlestick Chart" + | "Choropleth" + | "Connected Scatter Plot" + | "Density Plot" + | "Donut Chart" + | "ECDF Plot" + | "Gantt Chart" + | "Grouped Bar Chart" + | "Heatmap" + | "Histogram" + | "KPI Card" + | "Line Chart" + | "Lollipop Chart" + | "Map" + | "Pie Chart" + | "Pyramid Chart" + | "Radar Chart" + | "Range Area Chart" + | "Ranged Dot Plot" + | "Regression" + | "Rose Chart" + | "Scatter Plot" + | "Slope Chart" + | "Sparkline" + | "Stacked Bar Chart" + | "Streamgraph" + | "Strip Plot" + | "Violin Plot" + | "Waterfall Chart"; + +/** Static property domains; runtime applicability still depends on data and encodings. */ +export interface VegaLiteChartPropertiesMap { + "Area Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "interpolate"?: "basis" | "cardinal" | "catmull-rom" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + "opacity"?: number | undefined; + "stackMode"?: "center" | "layered" | "normalize" | undefined; + "xAxisType"?: "nominal" | "temporal" | undefined; + "yAxisType"?: "nominal" | "temporal" | undefined; + }; + "Bar Chart": { + "cornerRadius"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sort"?: "value-asc" | "value-desc" | undefined; + "xAxisType"?: "nominal" | "temporal" | undefined; + "yAxisType"?: "nominal" | "temporal" | undefined; + }; + "Bar Table": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "maxRows"?: number | undefined; + "showPercent"?: boolean | undefined; + }; + "Boxplot": { + "dodge"?: "auto" | "global" | "local" | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "showOutliers"?: boolean | undefined; + "showPoints"?: boolean | undefined; + "whiskerMethod"?: "iqr" | "minmax" | undefined; + }; + "Bullet Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + }; + "Bump Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "interpolate"?: "basis" | "cardinal" | "catmull-rom" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + }; + "Calendar Heatmap": { + "colorScheme"?: "blues" | "github" | "greens" | "oranges" | "purples" | "reds" | "viridis" | undefined; + "cornerRadius"?: number | undefined; + }; + "Candlestick Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + }; + "Choropleth": { + "level"?: "auto" | "county" | "state" | undefined; + "region"?: "auto" | "us" | "world" | undefined; + }; + "Connected Scatter Plot": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + }; + "Density Plot": { + "bandwidth"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + }; + "Donut Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "innerRadius"?: number | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sortSlices"?: "ascending" | "descending" | "none" | undefined; + }; + "ECDF Plot": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "showPoints"?: boolean | undefined; + }; + "Gantt Chart": { + "cornerRadius"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "intervalLabels"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "taskHeight"?: number | undefined; + }; + "Grouped Bar Chart": { + "dodge"?: "auto" | "global" | "local" | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sort"?: "value-asc" | "value-desc" | undefined; + }; + "Heatmap": { + "colorScheme"?: "blueorange" | "blues" | "greens" | "greys" | "inferno" | "magma" | "oranges" | "plasma" | "purples" | "redblue" | "reds" | "turbo" | "viridis" | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "xAxisType"?: "nominal" | "temporal" | undefined; + "yAxisType"?: "nominal" | "temporal" | undefined; + }; + "Histogram": { + "binCount"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + }; + "KPI Card": { + "behindThreshold"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "layout"?: "grid" | "horizontal" | "vertical" | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "style"?: boolean | undefined; + }; + "Line Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "interpolate"?: "basis" | "cardinal" | "catmull-rom" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "showPoints"?: boolean | undefined; + "xAxisType"?: "nominal" | "temporal" | undefined; + "yAxisType"?: "nominal" | "temporal" | undefined; + }; + "Lollipop Chart": { + "dotSize"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sort"?: "value-asc" | "value-desc" | undefined; + "xAxisType"?: "nominal" | "temporal" | undefined; + "yAxisType"?: "nominal" | "temporal" | undefined; + }; + "Map": { + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "projection"?: "azimuthalEquidistant" | "conicEqualArea" | "conicEquidistant" | "default" | "equalEarth" | "mercator" | "mollweide" | "orthographic" | "stereographic" | undefined; + "projectionCenter"?: [-100, 45] | [-2, 54] | [-52, -14] | [-60, -15] | [-98, 39] | [0, 0] | [10, 50] | [10, 51] | [100, 60] | [105, 35] | [115, 5] | [128, 36] | [134, -25] | [138, 36] | [150, 0] | [2, 47] | [20, 0] | [45, 28] | [78, 22] | undefined; + "region"?: "auto" | "us" | "world" | undefined; + }; + "Pie Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "innerRadius"?: number | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sortSlices"?: "ascending" | "descending" | "none" | undefined; + }; + "Pyramid Chart": { + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + }; + "Radar Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "fillOpacity"?: number | undefined; + "filled"?: boolean | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "strokeWidth"?: number | undefined; + }; + "Range Area Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "interpolate"?: "basis" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + "opacity"?: number | undefined; + }; + "Ranged Dot Plot": { + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + }; + "Regression": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "polyOrder"?: number | undefined; + "regressionMethod"?: "exp" | "linear" | "log" | "poly" | "pow" | "quad" | undefined; + }; + "Rose Chart": { + "alignment"?: "center" | "left" | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "padAngle"?: number | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sortSlices"?: "ascending" | "descending" | "none" | undefined; + }; + "Scatter Plot": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "opacity"?: number | undefined; + }; + "Slope Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "showSeriesInLabel"?: boolean | undefined; + "showText"?: boolean | undefined; + }; + "Sparkline": { + "baseline"?: "mean" | "median" | "none" | "zero" | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "interpolate"?: "basis" | "cardinal" | "catmull-rom" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "trendWidth"?: number | undefined; + }; + "Stacked Bar Chart": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "sort"?: "value-asc" | "value-desc" | undefined; + "stackMode"?: "center" | "normalize" | undefined; + }; + "Streamgraph": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "interpolate"?: "basis" | "cardinal" | "catmull-rom" | "linear" | "monotone" | "step" | "step-after" | "step-before" | undefined; + }; + "Strip Plot": { + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "includeZero_x"?: boolean | undefined; + "includeZero_y"?: boolean | undefined; + "independentYAxis"?: boolean | undefined; + "logScale_x"?: boolean | undefined; + "logScale_y"?: boolean | undefined; + "opacity"?: number | undefined; + "pointSize"?: number | undefined; + "stepWidth"?: number | undefined; + }; + "Violin Plot": { + "bandwidth"?: number | undefined; + "independentYAxis"?: boolean | undefined; + "medianWidth"?: number | undefined; + "showContour"?: boolean | undefined; + "showMedian"?: boolean | undefined; + "showPoints"?: boolean | undefined; + }; + "Waterfall Chart": { + "cornerRadius"?: number | undefined; + /** Column facet layout; availability is data dependent. */ + "facetColumns"?: number | undefined; + "independentYAxis"?: boolean | undefined; + /** @deprecated Use showValueLabels. Legacy true requests labels; false means auto. */ + "showTextLabels"?: boolean | undefined; + "showValueLabels"?: boolean | undefined; + "totals"?: "auto" | "both" | "first" | "last" | "none" | undefined; + }; +} + +/** Opt-in authoring union. All other native chart_spec fields are preserved. */ +export type VegaLiteChartSpec = { + [C in VegaLiteChartType]: + Omit & { + chartType: C; + chartProperties?: VegaLiteChartPropertiesMap[C]; + }; +}[VegaLiteChartType]; diff --git a/packages/flint-js/src/vegalite/index.ts b/packages/flint-js/src/vegalite/index.ts index e837543f..2e6e2504 100644 --- a/packages/flint-js/src/vegalite/index.ts +++ b/packages/flint-js/src/vegalite/index.ts @@ -13,6 +13,8 @@ // VL assembly function export { assembleVegaLite, getChartOptions, getChartPivot, getChartTransform } from './assemble'; +export type { VegaLiteChartType, VegaLiteChartPropertiesMap, VegaLiteChartSpec } from './chart-types.generated'; + // VL spec instantiation (Phase 2) export { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; diff --git a/packages/flint-js/tests/types/vegalite-chart-spec.ts b/packages/flint-js/tests/types/vegalite-chart-spec.ts new file mode 100644 index 00000000..3999aae5 --- /dev/null +++ b/packages/flint-js/tests/types/vegalite-chart-spec.ts @@ -0,0 +1,133 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Compiled by tsc, both against source and the built package's public exports. +import type { + ChartAssemblyInput, + VegaLiteChartType, + VegaLiteChartPropertiesMap, + VegaLiteChartSpec, +} from 'flint-chart'; +import type { VegaLiteChartSpec as BackendChartSpec } from 'flint-chart/vegalite'; + +type Equal = + (() => T extends A ? 1 : 2) extends (() => T extends B ? 1 : 2) ? true : false; +type Assert = T; +type NativeSpec = ChartAssemblyInput['chart_spec']; +type NativeFields = Omit; + +export type NativeFieldsPreserved = Assert, NativeFields +>>; +export type BackendExportMatches = Assert>; +export type NamesMatchMap = Assert>; + +export const area = { + chartType: 'Area Chart', + title: 'Revenue', + subtitle: 'By quarter', + baseSize: { width: 400, height: 300 }, + canvasSize: { width: 800, height: 600 }, + encodings: { + x: 'quarter', + y: [{ field: 'revenue', type: 'quantitative', aggregate: 'sum' }, 'profit'], + color: { field: 'region', type: 'nominal', sortOrder: 'ascending' }, + }, + chartProperties: { stackMode: 'layered', interpolate: 'cardinal', opacity: 0.7 }, +} satisfies VegaLiteChartSpec; + +export const native: NativeSpec = area; +export const input: ChartAssemblyInput = { data: { values: [] }, chart_spec: area }; +export const noProperties = { chartType: 'Bar Chart', encodings: {} } satisfies VegaLiteChartSpec; +export const legacyOn = { + chartType: 'Bar Chart', encodings: {}, chartProperties: { showTextLabels: true }, +} satisfies VegaLiteChartSpec; +export const legacyOff = { + chartType: 'Heatmap', encodings: {}, + chartProperties: { showTextLabels: false, showValueLabels: true, colorScheme: 'viridis' }, +} satisfies VegaLiteChartSpec; +export const defaults = { + chartType: 'Bar Chart', encodings: {}, + chartProperties: { + cornerRadius: undefined, sort: undefined, showValueLabels: undefined, + showTextLabels: undefined, facetColumns: undefined, xAxisType: undefined, + }, +} satisfies VegaLiteChartSpec; +export const range = { + chartType: 'Range Area Chart', encodings: {}, + chartProperties: { interpolate: 'monotone', facetColumns: 3 }, +} satisfies VegaLiteChartSpec; +export const sort: VegaLiteChartPropertiesMap['Bar Chart'] = { sort: 'value-desc' }; +export const facets: VegaLiteChartPropertiesMap['Sparkline'] = { facetColumns: 2 }; +export const center: VegaLiteChartPropertiesMap['Map'] = { projectionCenter: [105, 35] }; +export const continuous: VegaLiteChartPropertiesMap['Histogram'] = { binCount: 0.5 }; +export const calendar: VegaLiteChartType = 'Calendar Heatmap'; + +export function narrow(spec: VegaLiteChartSpec): NativeSpec { + if (spec.chartType === 'Area Chart') { + const stack: 'layered' | 'normalize' | 'center' | undefined = spec.chartProperties?.stackMode; + void stack; + } + if (spec.chartType === 'Stacked Bar Chart') { + const stack: 'normalize' | 'center' | undefined = spec.chartProperties?.stackMode; + // @ts-expect-error Only area charts accept the layered stack mode. + const layered: 'layered' = spec.chartProperties?.stackMode; + void stack; + void layered; + } + return spec; +} + +// @ts-expect-error Chart names are the registered names, not arbitrary backend names. +export const wrongName: VegaLiteChartType = 'Area'; +// @ts-expect-error The spec is a closed, nongeneric discriminated union. +export type NotGeneric = VegaLiteChartSpec<'Bar Chart'>; +// @ts-expect-error Discriminant and properties must agree. +export const wrongPair: VegaLiteChartSpec = { chartType: 'Stacked Bar Chart', encodings: {}, chartProperties: { stackMode: 'layered' } }; +// @ts-expect-error Unknown property keys are rejected on authored literals. +export const wrongKey: VegaLiteChartPropertiesMap['Bar Chart'] = { cornerRaduis: 3 }; +// @ts-expect-error Stacked bars do not have the area's layered mode. +export const wrongStack: VegaLiteChartPropertiesMap['Stacked Bar Chart'] = { stackMode: 'layered' }; +// @ts-expect-error Range area has a narrower interpolation domain than area. +export const wrongCurve: VegaLiteChartPropertiesMap['Range Area Chart'] = { interpolate: 'cardinal' }; +// @ts-expect-error Binary controls accept booleans, not strings. +export const wrongBoolean: VegaLiteChartPropertiesMap['Bar Chart'] = { showValueLabels: 'true' }; +// @ts-expect-error Display labels are not enum values. +export const wrongLabel: VegaLiteChartPropertiesMap['Regression'] = { regressionMethod: 'Logarithmic' }; +// @ts-expect-error Encoding actions have finite domains too. +export const wrongSort: VegaLiteChartPropertiesMap['Bar Chart'] = { sort: 'descending' }; +// @ts-expect-error Only registered tuples, not arbitrary numeric pairs. +export const wrongCenter: VegaLiteChartPropertiesMap['Map'] = { projectionCenter: [1, 2] }; +// @ts-expect-error Tuple length is preserved. +export const wrongCenterLength: VegaLiteChartPropertiesMap['Map'] = { projectionCenter: [105, 35, 0] }; +// @ts-expect-error No legacy alias when the chart does not declare showValueLabels. +export const wrongAlias: VegaLiteChartPropertiesMap['Line Chart'] = { showTextLabels: true }; +// @ts-expect-error Calendar does not declare a column facet channel. +export const wrongFacet: VegaLiteChartPropertiesMap['Calendar Heatmap'] = { facetColumns: 2 }; +// @ts-expect-error Dynamic chart transitions are not a static property domain. +export const dynamicChart: VegaLiteChartPropertiesMap['Bar Chart'] = { chartType: 'Line Chart' }; +// @ts-expect-error Pivot IDs are resolved from current data and encodings. +export const dynamicPivot: VegaLiteChartPropertiesMap['Bar Chart'] = { pivot: 'transpose' }; +// @ts-expect-error Arrangement IDs are resolved from current data and encodings. +export const dynamicArrange: VegaLiteChartPropertiesMap['Bar Chart'] = { arrange: 'column' }; +// @ts-expect-error Native title remains a string. +export const wrongTitle: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: {}, title: 123 }; +// @ts-expect-error Native sizes remain numeric. +export const wrongSize: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: {}, baseSize: { width: '400', height: 300 } }; +// @ts-expect-error Encodings remain required. +export const missingEncodings: VegaLiteChartSpec = { chartType: 'Bar Chart' }; +// @ts-expect-error Native encoding values do not accept numbers. +export const wrongEncoding: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: { x: 123 } }; +// @ts-expect-error Native field names remain strings. +export const wrongField: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: { x: { field: 123 } } }; +// @ts-expect-error Native encoding type has a finite domain. +export const wrongEncodingType: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: { x: { type: 'number' } } }; +// @ts-expect-error Static series preserve the native aggregate domain. +export const wrongSeries: VegaLiteChartSpec = { chartType: 'Bar Chart', encodings: { y: [{ field: 'revenue', aggregate: 'median' }] } }; + +// Existing dynamic callers keep the broad native contract. +export const dynamic: NativeSpec = { + chartType: 'host-defined', + encodings: {}, + chartProperties: { pivot: 'runtime-id', arrange: 'runtime-id', chartType: 'runtime-id' }, +}; diff --git a/packages/flint-js/tests/vegalite-chart-types.test.ts b/packages/flint-js/tests/vegalite-chart-types.test.ts new file mode 100644 index 00000000..140673a4 --- /dev/null +++ b/packages/flint-js/tests/vegalite-chart-types.test.ts @@ -0,0 +1,179 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { readFileSync } from 'node:fs'; +import { describe, expect, it, vi } from 'vitest'; +import type { ChartPropertyDef, ChartTemplateDef, EncodingActionDef } from '../src/core/types'; +import { vlAllTemplateDefs } from '../src/vegalite/templates'; +import { generateVegaLiteChartTypes, propertyType } from '../scripts/vegalite-chart-types'; + +function template(properties: ChartPropertyDef[] = [], encodingActions: EncodingActionDef[] = []): ChartTemplateDef { + return { + chart: 'Test', template: {}, channels: [], markCognitiveChannel: 'position', + instantiate: () => {}, properties, encodingActions, + }; +} + +describe('Vega-Lite authoring property domains', () => { + it('keeps binary and continuous domains broad, with explicit default resets', () => { + expect(propertyType({ type: 'binary', defaultValue: true }, 'toggle')).toBe('boolean | undefined'); + expect(propertyType({ type: 'continuous', min: 5, max: 50, defaultValue: 0 }, 'bins')) + .toBe('number | undefined'); + }); + + it('uses values, not display labels, and includes literal defaults', () => { + expect(propertyType({ + type: 'discrete', + options: [{ value: 'linear', label: 'Linear curve' }, { value: 'linear', label: 'Duplicate' }], + defaultValue: 'auto', + }, 'curve')).toBe('"auto" | "linear" | undefined'); + }); + + it('preserves primitive literals, null, undefined, and escaped strings', () => { + const values = [undefined, null, false, true, -1, 2.5, 'a"\n\\b']; + const output = propertyType({ + type: 'discrete', options: values.map(value => ({ value, label: 'unused' })), + defaultValue: undefined, + }, 'primitives'); + expect(output).toBe([JSON.stringify('a"\n\\b'), '-1', '2.5', 'false', 'null', 'true', 'undefined'].sort().join(' | ')); + }); + + it('preserves arrays as finite tuples, including nested arrays and array defaults', () => { + expect(propertyType({ + type: 'discrete', + options: [ + { value: [105, 35], label: 'China' }, + { value: [], label: 'Empty' }, + { value: [[1], 'a', undefined], label: 'Nested' }, + ], + defaultValue: [0, 0], + }, 'center')).toBe('[0, 0] | [105, 35] | [[1], "a", undefined] | [] | undefined'); + }); + + it.each([NaN, Infinity, -Infinity, {}, new Date(0), /pattern/, () => 0, Symbol('value'), 1n])( + 'rejects unsupported enum values: %s', value => { + expect(() => propertyType({ type: 'discrete', options: [{ value, label: 'invalid' }] }, 'Chart.key')) + .toThrow('Chart.key: unsupported enum value'); + }, + ); + + it('rejects unsupported defaults and values nested in arrays', () => { + expect(() => propertyType({ + type: 'discrete', options: [{ value: 1, label: 'One' }], defaultValue: {}, + }, 'choice')).toThrow('choice default: unsupported enum value'); + expect(() => propertyType({ + type: 'discrete', options: [{ value: [0, Infinity], label: 'Invalid' }], + }, 'center')).toThrow('center[1]: unsupported enum value'); + expect(() => propertyType({ type: 'continuous', min: 0, max: 1, defaultValue: NaN }, 'size')) + .toThrow('invalid continuous default'); + // @ts-expect-error Deliberately malformed registry metadata must also fail at generation time. + expect(() => propertyType({ type: 'binary', defaultValue: 'true' }, 'toggle')) + .toThrow('invalid binary default'); + }); + + it('rejects empty or malformed discrete domains, even with a default', () => { + expect(() => propertyType({ type: 'discrete', options: [], defaultValue: 'auto' }, 'choice')) + .toThrow('discrete options must be nonempty'); + // @ts-expect-error Deliberately missing options. + expect(() => propertyType({ type: 'discrete' }, 'choice')).toThrow('discrete options must be nonempty'); + // @ts-expect-error A missing value differs from an explicitly registered undefined. + expect(() => propertyType({ type: 'discrete', options: [{ label: 'Missing' }] }, 'choice')) + .toThrow('enum option must declare a value'); + // @ts-expect-error New unsupported metadata must not become any, unknown, or string. + expect(() => propertyType({ type: 'text' }, 'choice')).toThrow('unsupported property metadata'); + }); + + it('rejects sparse and cyclic enum arrays', () => { + const cyclic: unknown[] = []; + cyclic.push(cyclic); + for (const [value, message] of [[new Array(2), 'sparse'], [cyclic, 'cyclic']] as const) { + expect(() => propertyType({ type: 'discrete', options: [{ value, label: 'Invalid' }] }, 'array')) + .toThrow(`${message} enum array`); + } + }); +}); + +describe('Vega-Lite authoring generation', () => { + const generated = readFileSync(new URL('../src/vegalite/chart-types.generated.ts', import.meta.url), 'utf8') + .replace(/\r\n/g, '\n'); + + it('matches the checked-in output from the live registry', () => { + expect(generateVegaLiteChartTypes()).toBe(generated); + }); + + it('is deterministic independent of chart, property, action, and option order', () => { + const reversed = [...vlAllTemplateDefs].reverse().map(definition => ({ + ...definition, + properties: [...(definition.properties ?? [])].reverse().map(property => + property.type === 'discrete' ? { ...property, options: [...property.options].reverse() } : property), + encodingActions: [...(definition.encodingActions ?? [])].reverse().map(action => ({ + ...action, + control: action.control.type === 'discrete' + ? { ...action.control, options: [...action.control.options].reverse() } + : action.control, + })), + })); + expect(generateVegaLiteChartTypes(reversed)).toBe(generated); + }); + + it('detects registry additions and property/action domain drift', () => { + expect(generateVegaLiteChartTypes([...vlAllTemplateDefs, template()])).not.toBe(generated); + const [first, ...rest] = vlAllTemplateDefs; + expect(generateVegaLiteChartTypes([{ + ...first, + properties: [...(first.properties ?? []), { key: 'newProperty', label: 'New', type: 'binary' }], + }, ...rest])).not.toBe(generated); + const changed = vlAllTemplateDefs.map(definition => ({ + ...definition, + encodingActions: definition.encodingActions?.map(action => ({ + ...action, + control: { type: 'binary' as const }, + })), + })); + expect(generateVegaLiteChartTypes(changed)).not.toBe(generated); + }); + + it('derives both properties and encoding controls without running data-dependent callbacks', () => { + const callback = vi.fn(() => { throw new Error('Must not evaluate runtime applicability'); }); + const result = generateVegaLiteChartTypes([template( + [{ key: 'toggle', label: 'Toggle', type: 'binary', check: callback }], + [{ key: 'action', label: 'Action', control: { type: 'discrete', options: [{ value: 'accepted', label: 'Label' }] }, + get: callback, set: callback, isApplicable: callback }], + )]); + expect(result).toContain('"toggle"?: boolean | undefined'); + expect(result).toContain('"action"?: "accepted" | undefined'); + expect(callback).not.toHaveBeenCalled(); + }); + + it('adds only the two source-backed supplements where structurally eligible', () => { + for (const definition of vlAllTemplateDefs) { + const result = generateVegaLiteChartTypes([definition]); + expect(result.includes('"facetColumns"?: number | undefined'), definition.chart) + .toBe(definition.channels.includes('column')); + expect(result.includes('"showTextLabels"?: boolean | undefined'), definition.chart) + .toBe(definition.properties?.some(property => property.key === 'showValueLabels') ?? false); + } + expect(generateVegaLiteChartTypes([{ ...template(), channels: ['row'] }])).not.toContain('"facetColumns"'); + }); + + it('excludes dynamic IDs without adding a loose index signature', () => { + const result = generateVegaLiteChartTypes([template( + [{ key: 'pivot', label: 'Pivot', type: 'binary' }], + ['chartType', 'arrange'].map(key => ({ + key, label: key, control: { type: 'binary' }, get: () => false, set: encodings => encodings, + })), + )]); + expect(result).toContain('"Test": never;'); + expect(result).not.toMatch(/"(pivot|chartType|arrange)"\?:|\[key: string\]/); + }); + + it('rejects empty registries and conflicting names instead of overwriting metadata', () => { + expect(() => generateVegaLiteChartTypes([])).toThrow('registry is empty'); + expect(() => generateVegaLiteChartTypes([template(), template()])).toThrow('duplicate chart name'); + const property: ChartPropertyDef = { key: 'same', label: 'Same', type: 'binary' }; + expect(() => generateVegaLiteChartTypes([template([property, property])])).toThrow('duplicate property'); + expect(() => generateVegaLiteChartTypes([template([property], [{ + key: 'same', label: 'Same', control: { type: 'binary' }, get: () => false, set: encodings => encodings, + }])])).toThrow('duplicate property'); + }); +}); diff --git a/packages/flint-js/tsconfig.package-test.json b/packages/flint-js/tsconfig.package-test.json new file mode 100644 index 00000000..abc7d537 --- /dev/null +++ b/packages/flint-js/tsconfig.package-test.json @@ -0,0 +1,13 @@ +{ + "extends": "./tsconfig.test.json", + "compilerOptions": { + "module": "NodeNext", + "moduleResolution": "NodeNext", + "paths": {}, + "skipLibCheck": false, + "exactOptionalPropertyTypes": true, + "types": ["node"] + }, + "include": ["tests/types/**/*.ts"], + "exclude": ["node_modules"] +} diff --git a/site/src/components/SiteShell.tsx b/site/src/components/SiteShell.tsx index fa93fb76..39a0e960 100644 --- a/site/src/components/SiteShell.tsx +++ b/site/src/components/SiteShell.tsx @@ -1,12 +1,13 @@ -import type { CSSProperties, KeyboardEvent, ReactNode } from 'react'; +import type { CSSProperties, KeyboardEvent, MouseEvent, ReactNode } from 'react'; import { useEffect, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; -import { useLocation } from 'react-router-dom'; +import { useLocation, useNavigate } from 'react-router-dom'; import { LocaleLink } from '../i18n/LocaleLink'; import { useLocale } from '../i18n/LocaleContext'; import { stripLocale } from '../i18n/paths'; import type { Locale } from '../i18n/locales'; -import { CONTENT_MAX_WIDTH, GITHUB_REPO, siteTheme } from '../shared/theme'; +import { CONTENT_MAX_WIDTH, DISCORD_INVITE, GITHUB_REPO, LANDING_SCROLL_TO_KEY, WECHAT_SECTION_ID, siteTheme } from '../shared/theme'; +import { scrollToHeading } from '../shared/scroll-to-heading'; const GITHUB_REPO_API = 'https://api.github.com/repos/microsoft/flint-chart'; const GITHUB_STARS_CACHE_KEY = 'flint-chart.github-stars'; @@ -84,6 +85,10 @@ export function SiteNavBar(_props: { flush?: boolean } = {}) {
+
+ + +
@@ -327,6 +332,69 @@ function LanguageSwitch() { ); } +function WeChatLink() { + const { t } = useTranslation(); + const { pathname } = useLocation(); + const navigate = useNavigate(); + const { lp } = useLocale(); + const [hovered, setHovered] = useState(false); + const logical = stripLocale(pathname); + + const onClick = (event: MouseEvent) => { + event.preventDefault(); + if (logical === '/') { + scrollToHeading(WECHAT_SECTION_ID); + return; + } + try { + sessionStorage.setItem(LANDING_SCROLL_TO_KEY, WECHAT_SECTION_ID); + } catch { + // Privacy modes may block storage. + } + navigate(lp('/')); + }; + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + ...socialIconLinkStyle, + color: hovered ? siteTheme.accent : siteTheme.text, + }} + > + + + ); +} + +function DiscordLink() { + const { t } = useTranslation(); + const [hovered, setHovered] = useState(false); + + return ( + setHovered(true)} + onMouseLeave={() => setHovered(false)} + style={{ + ...socialIconLinkStyle, + color: hovered ? siteTheme.accent : siteTheme.text, + }} + > + + + ); +} + function GitHubLink() { const { t, i18n } = useTranslation(); const [hovered, setHovered] = useState(false); @@ -468,6 +536,22 @@ export function GitHubIcon({ size = 15 }: { size?: number } = {}) { ); } +export function DiscordIcon({ size = 16 }: { size?: number } = {}) { + return ( + + ); +} + +export function WeChatIcon({ size = 16 }: { size?: number } = {}) { + return ( + + ); +} + export function LabIcon({ size = 17 }: { size?: number } = {}) { return (
@@ -290,7 +307,7 @@ export function Landing() { {/* ---- Closing CTA -------------------------------------------- */} -
+

{t('landing.closingTitle')}

@@ -331,6 +348,25 @@ export function Landing() { />

+ + {/* ---- WeChat community -------------------------------------- */} +
+

+ {t('landing.wechatTitle')} +

+

+ {t('landing.wechatBody')} +

+ {t('landing.wechatAlt')} +
diff --git a/site/src/shared/theme.ts b/site/src/shared/theme.ts index 78c31575..61087188 100644 --- a/site/src/shared/theme.ts +++ b/site/src/shared/theme.ts @@ -26,3 +26,12 @@ export const siteTheme = { export const CONTENT_MAX_WIDTH = 1180; export const GITHUB_REPO = 'https://github.com/microsoft/flint-chart'; + +/** Community Discord invite. */ +export const DISCORD_INVITE = 'https://discord.gg/hnm4Y8YX8'; + +/** Landing-page section id for the WeChat QR block. */ +export const WECHAT_SECTION_ID = 'wechat'; + +/** sessionStorage key used to scroll to a landing section after navigating home. */ +export const LANDING_SCROLL_TO_KEY = 'flint-landing-scroll-to';