From 847b5d816bd39ba7918223b7b5cdeb079ac33fbd Mon Sep 17 00:00:00 2001 From: Anon-link <1149559789@qq.com> Date: Sat, 25 Jul 2026 23:25:22 +0800 Subject: [PATCH 1/3] fix chart transform problem Gallery controls are modeled against Vega-Lite, but some backends never actually wired them up. --- packages/flint-js/src/chartjs/assemble.ts | 39 +++++-- packages/flint-js/src/chartjs/index.ts | 2 +- .../flint-js/src/chartjs/templates/area.ts | 12 ++ .../flint-js/src/chartjs/templates/bar.ts | 44 +++++-- .../flint-js/src/chartjs/templates/line.ts | 3 + .../flint-js/src/chartjs/templates/utils.ts | 22 ++++ packages/flint-js/src/echarts/assemble.ts | 39 +++++-- packages/flint-js/src/echarts/index.ts | 2 +- .../flint-js/src/echarts/templates/area.ts | 3 + .../flint-js/src/echarts/templates/bar.ts | 6 +- .../flint-js/src/echarts/templates/line.ts | 3 + .../src/echarts/templates/lollipop.ts | 4 +- .../flint-js/src/echarts/templates/utils.ts | 53 ++++++++- packages/flint-js/src/plotly/assemble.ts | 38 +++++- packages/flint-js/src/plotly/index.ts | 2 +- .../flint-js/src/plotly/templates/area.ts | 34 +++++- packages/flint-js/src/plotly/templates/bar.ts | 18 ++- .../flint-js/src/plotly/templates/heatmap.ts | 2 + .../flint-js/src/plotly/templates/jitter.ts | 2 + .../flint-js/src/plotly/templates/line.ts | 8 ++ .../flint-js/src/plotly/templates/scatter.ts | 6 + packages/flint-js/tests/pivot.test.ts | 75 ++++++++++++ .../flint-js/tests/sort-curve-widgets.test.ts | 109 ++++++++++++++++++ site/src/components/ChartCodeModal.tsx | 16 +-- site/src/shared/chart-options.ts | 62 ++++++++-- 25 files changed, 544 insertions(+), 60 deletions(-) create mode 100644 packages/flint-js/tests/sort-curve-widgets.test.ts diff --git a/packages/flint-js/src/chartjs/assemble.ts b/packages/flint-js/src/chartjs/assemble.ts index a4eb8053..53563145 100644 --- a/packages/flint-js/src/chartjs/assemble.ts +++ b/packages/flint-js/src/chartjs/assemble.ts @@ -34,7 +34,7 @@ import { import type { ChartWarning } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; -import { applyPivot, PivotSurface } from '../core/pivot'; +import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } from '../core/pivot'; import { cjsGetTemplateDef } from './templates'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; import { computeZeroDecision } from '../core/semantic-types'; @@ -109,17 +109,22 @@ export function assembleChartjs(input: ChartAssemblyInput): any { : { ...enc, type: prelimSemantics[ch]?.type }; } - const pivoted = applyPivot(chartTemplate, typedRawEncodings, data, chartProperties, cjsGetTemplateDef); - if (pivoted.chartType && pivoted.chartType !== chartType) { - const swapped = cjsGetTemplateDef(pivoted.chartType) as ChartTemplateDef | undefined; + // Transform (derived Category-B operator): same two-control model as VL — + // chartProperties.chartType (θ) + chartProperties.arrange (τ/σ/γ). Legacy + // composed `pivot` ids are migrated inside applyTransform. See + // design-docs/chart-transform-two-axes.md. + const authoredTemplate = chartTemplate; + const transformed = applyTransform(chartTemplate, typedRawEncodings, data, chartProperties, cjsGetTemplateDef); + if (transformed.chartType && transformed.chartType !== chartType) { + const swapped = cjsGetTemplateDef(transformed.chartType) as ChartTemplateDef | undefined; if (swapped) chartTemplate = swapped; } // Compose Category-B encoding-action overrides (stored by the host in - // chartProperties, keyed by action key) onto the post-pivot encodings before - // any pipeline phase runs. Flint owns the transform; the host only stores - // the override value. See applyEncodingOverrides / EncodingActionDef. - const encodings = applyEncodingOverrides(chartTemplate, pivoted.encodings, chartProperties); + // chartProperties, keyed by action key) onto the post-transform encodings + // before any pipeline phase runs. Flint owns the transform; the host only + // stores the override value. See applyEncodingOverrides / EncodingActionDef. + const encodings = applyEncodingOverrides(chartTemplate, transformed.encodings, chartProperties); // Optional aggregation transform — see vegalite/assemble for rationale. data = applyAggregation(encodings, data); @@ -429,19 +434,31 @@ export function assembleChartjs(input: ChartAssemblyInput): any { cjsConfig._dataLength = values.length; - if (pivoted.surface) { - cjsConfig._pivot = pivoted.surface; + if (transformed.surface) { + cjsConfig._transform = transformed.surface; + } + // Legacy single-control surface for getChartjsPivot — enumerated from the + // authored template so ids/labels match the pre-split contract. + const legacyPivot = applyPivot(authoredTemplate, typedRawEncodings, data, chartProperties, cjsGetTemplateDef); + if (legacyPivot.surface) { + cjsConfig._pivot = legacyPivot.surface; } return cjsConfig; } -/** Inspect the Chart.js view transformation surface for an input. */ +/** Inspect the Chart.js legacy (composed) view transformation surface for an input. */ export function getChartjsPivot(input: ChartAssemblyInput): PivotSurface | undefined { const spec = assembleChartjs(input); return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; } +/** Inspect the Chart.js two-control transform surface for an input. */ +export function getChartjsTransform(input: ChartAssemblyInput): TransformSurface | undefined { + const spec = assembleChartjs(input); + return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; +} + /** * Round a [min, max] interval outward to "nice" round numbers so a shared facet * axis lands its endpoints on clean tick values (mirrors Vega-Lite's default diff --git a/packages/flint-js/src/chartjs/index.ts b/packages/flint-js/src/chartjs/index.ts index 1b3944dd..6e1f0968 100644 --- a/packages/flint-js/src/chartjs/index.ts +++ b/packages/flint-js/src/chartjs/index.ts @@ -18,7 +18,7 @@ */ // CJS assembly function -export { assembleChartjs, getChartjsPivot } from './assemble'; +export { assembleChartjs, getChartjsPivot, getChartjsTransform } from './assemble'; // CJS spec instantiation (Phase 2) export { cjsApplyLayoutToSpec, cjsApplyTooltips } from './instantiate-spec'; diff --git a/packages/flint-js/src/chartjs/templates/area.ts b/packages/flint-js/src/chartjs/templates/area.ts index 7bc6fcbe..e5f126d9 100644 --- a/packages/flint-js/src/chartjs/templates/area.ts +++ b/packages/flint-js/src/chartjs/templates/area.ts @@ -58,6 +58,10 @@ export const cjsAreaChartDef: ChartTemplateDef = { const tension = (interpolate === 'monotone' || interpolate === 'basis' || interpolate === 'cardinal' || interpolate === 'catmull-rom') ? 0.4 : 0; + const stepped = interpolate === 'step' ? 'middle' as const + : interpolate === 'step-before' ? 'before' as const + : interpolate === 'step-after' ? 'after' as const + : false; const palette = getChartJsPalette(ctx, 'color'); @@ -133,6 +137,7 @@ export const cjsAreaChartDef: ChartTemplateDef = { borderColor, backgroundColor: bgColor, tension, + stepped, fill: stacked ? 'stack' : 'origin', pointRadius: 2, }); @@ -154,6 +159,7 @@ export const cjsAreaChartDef: ChartTemplateDef = { borderColor: getSeriesBorderColor(palette, 0), backgroundColor: getSeriesBackgroundColor(palette, 0, opacity), tension, + stepped, fill: 'origin', pointRadius: 2, }); @@ -170,6 +176,12 @@ export const cjsAreaChartDef: ChartTemplateDef = { { value: undefined, label: 'Default (linear)' }, { value: 'linear', label: 'Linear' }, { value: 'monotone', label: 'Monotone (smooth)' }, + { value: 'step', label: 'Step' }, + { value: 'step-before', label: 'Step Before' }, + { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, { diff --git a/packages/flint-js/src/chartjs/templates/bar.ts b/packages/flint-js/src/chartjs/templates/bar.ts index 456fe2cd..030772a7 100644 --- a/packages/flint-js/src/chartjs/templates/bar.ts +++ b/packages/flint-js/src/chartjs/templates/bar.ts @@ -9,21 +9,22 @@ * CJS: explicit datasets[] with stacked option on scales */ -import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; import { - extractCategories, - groupBy, - detectAxes, - buildCategoryAlignedData, - getChartJsPalette, - getSeriesBorderColor, - getSeriesBackgroundColor, + resolveCategoryOrder, + groupBy, + detectAxes, + buildCategoryAlignedData, + getChartJsPalette, + getSeriesBorderColor, + getSeriesBackgroundColor, } from './utils'; import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, } from '../../core/axis-detection'; import { planBandDodge, resolveDodge } from '../../core/band-dodge'; import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; // --------------------------------------------------------------------------- // Helpers @@ -54,7 +55,13 @@ export const cjsBarChartDef: ChartTemplateDef = { if (!catField || !valField) return; const catCS = channelSemantics[categoryAxis]; - const categories = extractCategories(table, catField, catCS?.ordinalSortOrder); + const catEnc = ctx.encodings?.[categoryAxis]; + const sortByField = catEnc?.sortBy ? channelSemantics[catEnc.sortBy]?.field : undefined; + const categories = resolveCategoryOrder(table, catField, { + ordinalSortOrder: catCS?.ordinalSortOrder, + sortBy: sortByField, + sortOrder: catEnc?.sortOrder, + }); const values = buildCategoryAlignedData(table, catField, valField, categories); const isHorizontal = categoryAxis === 'y'; @@ -111,6 +118,7 @@ export const cjsBarChartDef: ChartTemplateDef = { properties: [ { key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 15, step: 1, defaultValue: 0 }, ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], @@ -144,7 +152,13 @@ export const cjsStackedBarChartDef: ChartTemplateDef = { if (!catField || !valField) return; const catCS = channelSemantics[categoryAxis]; - const categories = extractCategories(table, catField, catCS?.ordinalSortOrder); + const catEnc = ctx.encodings?.[categoryAxis]; + const sortByField = catEnc?.sortBy ? channelSemantics[catEnc.sortBy]?.field : undefined; + const categories = resolveCategoryOrder(table, catField, { + ordinalSortOrder: catCS?.ordinalSortOrder, + sortBy: sortByField, + sortOrder: catEnc?.sortOrder, + }); const isHorizontal = categoryAxis === 'y'; const palette = getChartJsPalette(ctx, 'color'); @@ -214,6 +228,7 @@ export const cjsStackedBarChartDef: ChartTemplateDef = { delete spec.mark; delete spec.encoding; }, + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], @@ -255,7 +270,13 @@ export const cjsGroupedBarChartDef: ChartTemplateDef = { if (!catField || !valField) return; const catCS = channelSemantics[categoryAxis]; - const categories = extractCategories(table, catField, catCS?.ordinalSortOrder); + const catEnc = ctx.encodings?.[categoryAxis]; + const sortByField = catEnc?.sortBy ? channelSemantics[catEnc.sortBy]?.field : undefined; + const categories = resolveCategoryOrder(table, catField, { + ordinalSortOrder: catCS?.ordinalSortOrder, + sortBy: sortByField, + sortOrder: catEnc?.sortOrder, + }); const isHorizontal = categoryAxis === 'y'; const palette = getChartJsPalette(ctx, 'group'); @@ -414,6 +435,7 @@ export const cjsGroupedBarChartDef: ChartTemplateDef = { }, }, ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], diff --git a/packages/flint-js/src/chartjs/templates/line.ts b/packages/flint-js/src/chartjs/templates/line.ts index e0a1114f..427cb3cc 100644 --- a/packages/flint-js/src/chartjs/templates/line.ts +++ b/packages/flint-js/src/chartjs/templates/line.ts @@ -197,6 +197,9 @@ export const cjsLineChartDef: ChartTemplateDef = { { value: 'step', label: 'Step' }, { value: 'step-before', label: 'Step Before' }, { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, ], diff --git a/packages/flint-js/src/chartjs/templates/utils.ts b/packages/flint-js/src/chartjs/templates/utils.ts index 1b9abb12..99748663 100644 --- a/packages/flint-js/src/chartjs/templates/utils.ts +++ b/packages/flint-js/src/chartjs/templates/utils.ts @@ -59,6 +59,28 @@ export function extractCategories(data: any[], field: string, ordinalSortOrder?: return result; } +/** + * Resolve the display order of a categorical axis, honoring either a canonical + * `ordinalSortOrder` or a sort-by-measure request (`sortBy` + `sortOrder` from + * the shared Sort encoding action). + */ +export function resolveCategoryOrder( + data: any[], + catField: string, + opts?: { ordinalSortOrder?: string[]; sortBy?: string; sortOrder?: 'ascending' | 'descending' }, +): string[] { + const base = extractCategories(data, catField, opts?.ordinalSortOrder); + if (!opts?.sortBy) return base; + const agg = new Map(); + for (const row of data) { + const cat = String(row[catField] ?? ''); + const v = Number(row[opts.sortBy]); + if (Number.isFinite(v)) agg.set(cat, (agg.get(cat) ?? 0) + v); + } + const dir = opts.sortOrder === 'ascending' ? 1 : -1; + return [...base].sort((a, b) => dir * ((agg.get(a) ?? 0) - (agg.get(b) ?? 0))); +} + /** * Group data by a categorical field. * Returns a map: seriesName → rows[]. diff --git a/packages/flint-js/src/echarts/assemble.ts b/packages/flint-js/src/echarts/assemble.ts index 47788b84..638c081c 100644 --- a/packages/flint-js/src/echarts/assemble.ts +++ b/packages/flint-js/src/echarts/assemble.ts @@ -61,7 +61,7 @@ import { import type { ChartWarning } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; -import { applyPivot, PivotSurface } from '../core/pivot'; +import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } from '../core/pivot'; import { ecGetTemplateDef } from './templates'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; import { toTypeString, type SemanticAnnotation } from '../core/field-semantics'; @@ -141,17 +141,22 @@ export function assembleECharts(input: ChartAssemblyInput): any { : { ...enc, type: prelimSemantics[ch]?.type }; } - const pivoted = applyPivot(chartTemplate, typedRawEncodings, data, chartProperties, ecGetTemplateDef); - if (pivoted.chartType && pivoted.chartType !== chartType) { - const swapped = ecGetTemplateDef(pivoted.chartType) as ChartTemplateDef | undefined; + // Transform (derived Category-B operator): same two-control model as VL — + // chartProperties.chartType (θ) + chartProperties.arrange (τ/σ/γ). Legacy + // composed `pivot` ids are migrated inside applyTransform. See + // design-docs/chart-transform-two-axes.md. + const authoredTemplate = chartTemplate; + const transformed = applyTransform(chartTemplate, typedRawEncodings, data, chartProperties, ecGetTemplateDef); + if (transformed.chartType && transformed.chartType !== chartType) { + const swapped = ecGetTemplateDef(transformed.chartType) as ChartTemplateDef | undefined; if (swapped) chartTemplate = swapped; } // Compose Category-B encoding-action overrides (stored by the host in - // chartProperties, keyed by action key) onto the post-pivot encodings before - // any pipeline phase runs. Flint owns the transform; the host only stores - // the override value. See applyEncodingOverrides / EncodingActionDef. - const encodings = applyEncodingOverrides(chartTemplate, pivoted.encodings, chartProperties); + // chartProperties, keyed by action key) onto the post-transform encodings + // before any pipeline phase runs. Flint owns the transform; the host only + // stores the override value. See applyEncodingOverrides / EncodingActionDef. + const encodings = applyEncodingOverrides(chartTemplate, transformed.encodings, chartProperties); // Optional aggregation transform — see vegalite/assemble for rationale. data = applyAggregation(encodings, data); @@ -516,8 +521,14 @@ export function assembleECharts(input: ChartAssemblyInput): any { // ECharts data is embedded directly in series[].data) ecOption._dataLength = values.length; - if (pivoted.surface) { - ecOption._pivot = pivoted.surface; + if (transformed.surface) { + ecOption._transform = transformed.surface; + } + // Legacy single-control surface for getEChartsPivot — enumerated from the + // authored template so ids/labels match the pre-split contract. + const legacyPivot = applyPivot(authoredTemplate, typedRawEncodings, data, chartProperties, ecGetTemplateDef); + if (legacyPivot.surface) { + ecOption._pivot = legacyPivot.surface; } // Clean internal-only props @@ -526,12 +537,18 @@ export function assembleECharts(input: ChartAssemblyInput): any { return ecOption; } -/** Inspect the ECharts view transformation surface for an input. */ +/** Inspect the ECharts legacy (composed) view transformation surface for an input. */ export function getEChartsPivot(input: ChartAssemblyInput): PivotSurface | undefined { const spec = assembleECharts(input); return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; } +/** Inspect the ECharts two-control transform surface for an input. */ +export function getEChartsTransform(input: ChartAssemblyInput): TransformSurface | undefined { + const spec = assembleECharts(input); + return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; +} + // =========================================================================== // buildECEncodings — Translate abstract semantics → EC-resolved encodings // =========================================================================== diff --git a/packages/flint-js/src/echarts/index.ts b/packages/flint-js/src/echarts/index.ts index 501abae9..9787871a 100644 --- a/packages/flint-js/src/echarts/index.ts +++ b/packages/flint-js/src/echarts/index.ts @@ -17,7 +17,7 @@ */ // EC assembly function -export { assembleECharts, getEChartsPivot } from './assemble'; +export { assembleECharts, getEChartsPivot, getEChartsTransform } from './assemble'; // EC spec instantiation (Phase 2) export { ecApplyLayoutToSpec, ecApplyTooltips } from './instantiate-spec'; diff --git a/packages/flint-js/src/echarts/templates/area.ts b/packages/flint-js/src/echarts/templates/area.ts index 30bcde04..7412823d 100644 --- a/packages/flint-js/src/echarts/templates/area.ts +++ b/packages/flint-js/src/echarts/templates/area.ts @@ -300,6 +300,9 @@ export const ecAreaChartDef: ChartTemplateDef = { { value: 'step', label: 'Step' }, { value: 'step-before', label: 'Step Before' }, { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, { diff --git a/packages/flint-js/src/echarts/templates/bar.ts b/packages/flint-js/src/echarts/templates/bar.ts index e66dfc13..190782a1 100644 --- a/packages/flint-js/src/echarts/templates/bar.ts +++ b/packages/flint-js/src/echarts/templates/bar.ts @@ -12,7 +12,7 @@ * and barGap/barCategoryGap for grouped layout */ -import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; import { extractCategories, groupBy, detectAxes, getCategoryOrder, } from './utils'; @@ -23,6 +23,7 @@ import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete, } from '../../core/axis-detection'; import { makeCartesianPivot } from '../../core/pivot'; +import { makeSortAction } from '../../core/encoding-actions'; // --------------------------------------------------------------------------- // Helpers @@ -445,6 +446,7 @@ export const ecBarChartDef: ChartTemplateDef = { properties: [ { key: 'cornerRadius', label: 'Corners', type: 'continuous', min: 0, max: 15, step: 1, defaultValue: 0 }, ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], @@ -667,6 +669,7 @@ export const ecStackedBarChartDef: ChartTemplateDef = { check: (ctx) => ({ applicable: !!ctx.encodings.color?.field }), }, ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], @@ -975,6 +978,7 @@ export const ecGroupedBarChartDef: ChartTemplateDef = { }, } as ChartPropertyDef, ], + encodingActions: [makeSortAction()] as EncodingActionDef[], pivot: makeCartesianPivot({ transpose: [['x', 'y']], permute: [['x', 'y', 'color']], diff --git a/packages/flint-js/src/echarts/templates/line.ts b/packages/flint-js/src/echarts/templates/line.ts index 27e96b88..25658fe7 100644 --- a/packages/flint-js/src/echarts/templates/line.ts +++ b/packages/flint-js/src/echarts/templates/line.ts @@ -287,6 +287,9 @@ export const ecLineChartDef: ChartTemplateDef = { { value: 'step', label: 'Step' }, { value: 'step-before', label: 'Step Before' }, { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, { key: 'showPoints', label: 'Points', type: 'binary', defaultValue: false } as ChartPropertyDef, diff --git a/packages/flint-js/src/echarts/templates/lollipop.ts b/packages/flint-js/src/echarts/templates/lollipop.ts index bf6d29cc..4a86f81c 100644 --- a/packages/flint-js/src/echarts/templates/lollipop.ts +++ b/packages/flint-js/src/echarts/templates/lollipop.ts @@ -6,10 +6,11 @@ * Vega-Lite: rule strokeWidth 1.5、圆 size 80;茎黑色,圆点用图例色。 */ -import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { ChartTemplateDef, ChartPropertyDef, EncodingActionDef } from '../../core/types'; import { extractCategories, groupBy, DEFAULT_COLORS, getCategoryOrder } from './utils'; import { detectAxes } from './utils'; import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; +import { makeSortAction } from '../../core/encoding-actions'; /** Vega-Lite 风格:茎(rule)黑色、细线,圆点与 color 图例一致 */ const STEM_COLOR = '#000000'; @@ -178,4 +179,5 @@ export const ecLollipopChartDef: ChartTemplateDef = { properties: [ { key: 'dotSize', label: 'Dot Size', type: 'continuous', min: 20, max: 300, step: 10, defaultValue: 80 }, ] as ChartPropertyDef[], + encodingActions: [makeSortAction()] as EncodingActionDef[], }; diff --git a/packages/flint-js/src/echarts/templates/utils.ts b/packages/flint-js/src/echarts/templates/utils.ts index 3847c7f4..4162634a 100644 --- a/packages/flint-js/src/echarts/templates/utils.ts +++ b/packages/flint-js/src/echarts/templates/utils.ts @@ -11,10 +11,61 @@ import type { ChannelSemantics, InstantiateContext } from '../../core/types'; /** * Get canonical category order for a channel (from buildECEncodings or channelSemantics). * Use when calling extractCategories so sort order is consistent across templates. + * + * Honors the shared Sort encoding action (`encodings[channel].sortBy` = + * measure channel + `sortOrder`) by aggregating the measure per category — + * same contract as Plotly's `resolveCategoryOrder`. */ export function getCategoryOrder(ctx: InstantiateContext, channel: string): string[] | undefined { - return (ctx.resolvedEncodings as any)?.[channel]?.ordinalSortOrder + const resolved = (ctx.resolvedEncodings as any)?.[channel]; + if (Array.isArray(resolved?.sortValues) && resolved.sortValues.length > 0) { + return resolved.sortValues.map(String); + } + + const ordinal = resolved?.ordinalSortOrder ?? ctx.channelSemantics?.[channel]?.ordinalSortOrder; + + const enc = ctx.encodings?.[channel]; + const sortByChannel = enc?.sortBy; + if ( + typeof sortByChannel === 'string' + && (sortByChannel === 'x' || sortByChannel === 'y' || sortByChannel === 'color' + || sortByChannel === 'size' || sortByChannel === 'group') + ) { + const catField = ctx.channelSemantics?.[channel]?.field ?? enc?.field; + const sortByField = ctx.channelSemantics?.[sortByChannel]?.field + ?? ctx.encodings?.[sortByChannel]?.field; + if (catField && sortByField && ctx.table) { + return resolveCategoryOrder(ctx.table, catField, { + ordinalSortOrder: ordinal, + sortBy: sortByField, + sortOrder: enc?.sortOrder, + }); + } + } + + return ordinal; +} + +/** + * Resolve the display order of a categorical axis, honoring either a canonical + * `ordinalSortOrder` or a sort-by-measure request (`sortBy` + `sortOrder`). + */ +export function resolveCategoryOrder( + data: any[], + catField: string, + opts?: { ordinalSortOrder?: string[]; sortBy?: string; sortOrder?: 'ascending' | 'descending' }, +): string[] { + const base = extractCategories(data, catField, opts?.ordinalSortOrder); + if (!opts?.sortBy) return base; + const agg = new Map(); + for (const row of data) { + const cat = String(row[catField] ?? ''); + const v = Number(row[opts.sortBy]); + if (Number.isFinite(v)) agg.set(cat, (agg.get(cat) ?? 0) + v); + } + const dir = opts.sortOrder === 'ascending' ? 1 : -1; + return [...base].sort((a, b) => dir * ((agg.get(a) ?? 0) - (agg.get(b) ?? 0))); } // Re-export circumference-pressure functions from core (shared with VL backend) diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts index 870037c5..0f18d537 100644 --- a/packages/flint-js/src/plotly/assemble.ts +++ b/packages/flint-js/src/plotly/assemble.ts @@ -37,6 +37,7 @@ import { import type { ChartWarning, ChartEncoding } from '../core/types'; import { applyEncodingOverrides } from '../core/encoding-overrides'; import { applyAggregation } from '../core/aggregate'; +import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } from '../core/pivot'; import { plGetTemplateDef } from './templates'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; import { computeZeroDecision } from '../core/semantic-types'; @@ -71,7 +72,7 @@ export function assemblePlotly(input: ChartAssemblyInput): any { const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); const canvasSize = baseSize; const options = input.options ?? {}; - const chartTemplate = plGetTemplateDef(chartType) as ChartTemplateDef; + let chartTemplate = plGetTemplateDef(chartType) as ChartTemplateDef; if (!chartTemplate) { throw new Error(`Unknown Plotly chart type: ${chartType}. Use plAllTemplateDefs to see available types.`); } @@ -107,7 +108,18 @@ export function assemblePlotly(input: ChartAssemblyInput): any { for (const [ch, enc] of Object.entries(normalized.encodings)) { typedRawEncodings[ch] = enc.type ? enc : { ...enc, type: prelimSemantics[ch]?.type }; } - const encodings = applyEncodingOverrides(chartTemplate, typedRawEncodings, chartProperties); + + // Transform (derived Category-B operator): same two-control model as VL — + // chartProperties.chartType (θ) + chartProperties.arrange (τ/σ/γ). Legacy + // composed `pivot` ids are migrated inside applyTransform. See + // design-docs/chart-transform-two-axes.md. + const authoredTemplate = chartTemplate; + const transformed = applyTransform(chartTemplate, typedRawEncodings, data, chartProperties, plGetTemplateDef); + if (transformed.chartType && transformed.chartType !== chartType) { + const swapped = plGetTemplateDef(transformed.chartType) as ChartTemplateDef | undefined; + if (swapped) chartTemplate = swapped; + } + const encodings = applyEncodingOverrides(chartTemplate, transformed.encodings, chartProperties); // Optional aggregation transform — see vegalite/assemble for rationale. data = applyAggregation(encodings, data); @@ -394,5 +406,27 @@ export function assemblePlotly(input: ChartAssemblyInput): any { figure._dataLength = values.length; + if (transformed.surface) { + figure._transform = transformed.surface; + } + // Legacy single-control surface for getPlotlyPivot — enumerated from the + // authored template so ids/labels match the pre-split contract. + const legacyPivot = applyPivot(authoredTemplate, typedRawEncodings, data, chartProperties, plGetTemplateDef); + if (legacyPivot.surface) { + figure._pivot = legacyPivot.surface; + } + return figure; } + +/** Inspect the Plotly legacy (composed) view transformation surface for an input. */ +export function getPlotlyPivot(input: ChartAssemblyInput): PivotSurface | undefined { + const spec = assemblePlotly(input); + return spec && spec._pivot ? (spec._pivot as PivotSurface) : undefined; +} + +/** Inspect the Plotly two-control transform surface for an input. */ +export function getPlotlyTransform(input: ChartAssemblyInput): TransformSurface | undefined { + const spec = assemblePlotly(input); + return spec && spec._transform ? (spec._transform as TransformSurface) : undefined; +} diff --git a/packages/flint-js/src/plotly/index.ts b/packages/flint-js/src/plotly/index.ts index 9dd7d029..ae4acbd2 100644 --- a/packages/flint-js/src/plotly/index.ts +++ b/packages/flint-js/src/plotly/index.ts @@ -20,7 +20,7 @@ */ // PL assembly function -export { assemblePlotly } from './assemble'; +export { assemblePlotly, getPlotlyPivot, getPlotlyTransform } from './assemble'; // PL spec instantiation (Phase 2) export { plApplyLayoutToSpec, plApplyTooltips } from './instantiate-spec'; diff --git a/packages/flint-js/src/plotly/templates/area.ts b/packages/flint-js/src/plotly/templates/area.ts index 5772528a..a80e68ed 100644 --- a/packages/flint-js/src/plotly/templates/area.ts +++ b/packages/flint-js/src/plotly/templates/area.ts @@ -18,9 +18,29 @@ import { getPlotlyPalette, getSeriesColor, } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; +/** Map the shared `interpolate` property onto Plotly's `line.shape`. */ +function lineShape(interpolate: unknown): 'linear' | 'spline' | 'hv' | 'vh' | 'hvh' { + switch (interpolate) { + case 'monotone': + case 'basis': + case 'cardinal': + case 'catmull-rom': + return 'spline'; + case 'step': + return 'hvh'; + case 'step-before': + return 'vh'; + case 'step-after': + return 'hv'; + default: + return 'linear'; + } +} + /** Hex → rgba with alpha, for translucent area fills. */ function fillColor(hex: string, alpha: number): string { const m = /^#?([0-9a-f]{6})$/i.exec(hex.trim()); @@ -59,7 +79,7 @@ export const plAreaChartDef: ChartTemplateDef = { const opacity = Number(chartProperties?.opacity ?? 0.4); const stackMode = chartProperties?.stackMode; const stacked = stackMode !== 'layered'; - const smooth = chartProperties?.interpolate === 'monotone'; + const shape = lineShape(chartProperties?.interpolate); const palette = getPlotlyPalette(ctx, 'color'); const traces: any[] = []; @@ -75,7 +95,7 @@ export const plAreaChartDef: ChartTemplateDef = { name, x: xVals, y: yVals, - line: { color, shape: smooth ? 'spline' : 'linear' }, + line: { color, shape }, fillcolor: fillColor(color, opacity), }; if (colorField && stacked) { @@ -135,6 +155,12 @@ export const plAreaChartDef: ChartTemplateDef = { { value: undefined, label: 'Default (linear)' }, { value: 'linear', label: 'Linear' }, { value: 'monotone', label: 'Monotone (smooth)' }, + { value: 'step', label: 'Step' }, + { value: 'step-before', label: 'Step Before' }, + { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.4 } as ChartPropertyDef, @@ -148,4 +174,8 @@ export const plAreaChartDef: ChartTemplateDef = { ], } as ChartPropertyDef, ], + pivot: makeCartesianPivot({ + permute: [['y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), }; diff --git a/packages/flint-js/src/plotly/templates/bar.ts b/packages/flint-js/src/plotly/templates/bar.ts index 5f026123..433ff34d 100644 --- a/packages/flint-js/src/plotly/templates/bar.ts +++ b/packages/flint-js/src/plotly/templates/bar.ts @@ -16,6 +16,7 @@ import { extractCategories, resolveCategoryOrder, buildCategoryAlignedData, dete import { detectBandedAxisFromSemantics, detectBandedAxisForceDiscrete } from '../../core/axis-detection'; import { planBandDodge } from '../../core/band-dodge'; import { makeSortAction } from '../../core/encoding-actions'; +import { makeCartesianPivot } from '../../core/pivot'; /** Corner-radius property shared by the Plotly bar templates (px, matches VL). */ const BAR_CORNER_RADIUS: ChartPropertyDef = { @@ -100,6 +101,11 @@ export const plBarChartDef: ChartTemplateDef = { }, properties: [BAR_CORNER_RADIUS], encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'column', 'row'], + }), }; // ─── Stacked Bar Chart ────────────────────────────────────────────────────── @@ -193,6 +199,11 @@ export const plStackedBarChartDef: ChartTemplateDef = { ] } as ChartPropertyDef, ], encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), }; // ─── Grouped Bar Chart ────────────────────────────────────────────────────── @@ -283,10 +294,13 @@ export const plGroupedBarChartDef: ChartTemplateDef = { delete spec.encoding; }, encodingActions: [makeSortAction()] as EncodingActionDef[], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), }; -// ─── Pyramid Chart ────────────────────────────────────────────────────────── - /** * Plotly Pyramid Chart (population pyramid) — two mirrored horizontal bar * traces sharing one category axis, one side negated so the bars extend left diff --git a/packages/flint-js/src/plotly/templates/heatmap.ts b/packages/flint-js/src/plotly/templates/heatmap.ts index 641519ac..186637fb 100644 --- a/packages/flint-js/src/plotly/templates/heatmap.ts +++ b/packages/flint-js/src/plotly/templates/heatmap.ts @@ -11,6 +11,7 @@ import { ChartTemplateDef, EncodingActionDef } from '../../core/types'; import { extractCategories } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; const SCHEME_COLORSCALES: Record = { viridis: 'Viridis', inferno: 'Hot', magma: 'Magma', plasma: 'Plasma', turbo: 'Turbo', @@ -98,4 +99,5 @@ export const plHeatmapDef: ChartTemplateDef = { set: (enc, value) => ({ ...enc, color: { ...(enc.color as any), scheme: value } }), }, ] as EncodingActionDef[], + pivot: makeCartesianPivot({ transpose: [['x', 'y']] }), }; diff --git a/packages/flint-js/src/plotly/templates/jitter.ts b/packages/flint-js/src/plotly/templates/jitter.ts index c915f326..48c759f1 100644 --- a/packages/flint-js/src/plotly/templates/jitter.ts +++ b/packages/flint-js/src/plotly/templates/jitter.ts @@ -12,6 +12,7 @@ import { ChartTemplateDef } from '../../core/types'; import { isDiscreteType, extractCategories, groupBy, getPlotlyPalette, getSeriesColor, seededJitter } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; export const plStripPlotDef: ChartTemplateDef = { chart: 'Strip Plot', @@ -120,4 +121,5 @@ export const plStripPlotDef: ChartTemplateDef = { delete spec.mark; delete spec.encoding; }, + pivot: makeCartesianPivot({}), }; diff --git a/packages/flint-js/src/plotly/templates/line.ts b/packages/flint-js/src/plotly/templates/line.ts index 4109a7b1..e7fe6d3c 100644 --- a/packages/flint-js/src/plotly/templates/line.ts +++ b/packages/flint-js/src/plotly/templates/line.ts @@ -19,6 +19,7 @@ import { getPlotlyPalette, getSeriesColor, } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; @@ -137,8 +138,15 @@ export const plLineChartDef: ChartTemplateDef = { { value: 'step', label: 'Step' }, { value: 'step-before', label: 'Step Before' }, { value: 'step-after', label: 'Step After' }, + { value: 'basis', label: 'Basis (smooth)' }, + { value: 'cardinal', label: 'Cardinal' }, + { value: 'catmull-rom', label: 'Catmull-Rom' }, ], } as ChartPropertyDef, { key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false } as ChartPropertyDef, ], + pivot: makeCartesianPivot({ + permute: [['y', 'color']], + shift: ['color', 'group', 'column', 'row'], + }), }; diff --git a/packages/flint-js/src/plotly/templates/scatter.ts b/packages/flint-js/src/plotly/templates/scatter.ts index d135e7b4..1e9fecbc 100644 --- a/packages/flint-js/src/plotly/templates/scatter.ts +++ b/packages/flint-js/src/plotly/templates/scatter.ts @@ -11,6 +11,7 @@ import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; import { groupBy, getPlotlyPalette, getSeriesColor } from './utils'; +import { makeCartesianPivot } from '../../core/pivot'; /** Compute a reasonable marker diameter based on canvas area and point count. */ function computeMarkerSize(width: number, height: number, pointCount: number): number { @@ -120,6 +121,11 @@ export const plScatterPlotDef: ChartTemplateDef = { properties: [ { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 1 } as ChartPropertyDef, ], + pivot: makeCartesianPivot({ + transpose: [['x', 'y']], + permute: [['x', 'y', 'color', 'size']], + shift: ['color', 'group', 'column', 'row'], + }), postProcess: (figure, ctx) => { if (!Array.isArray(figure.data)) return; const w = figure._width || ctx.canvasSize.width; diff --git a/packages/flint-js/tests/pivot.test.ts b/packages/flint-js/tests/pivot.test.ts index b6880ffc..94ed6122 100644 --- a/packages/flint-js/tests/pivot.test.ts +++ b/packages/flint-js/tests/pivot.test.ts @@ -6,10 +6,14 @@ import { assembleVegaLite, assembleECharts, assembleChartjs, + assemblePlotly, getChartPivot, getChartTransform, getEChartsPivot, + getEChartsTransform, getChartjsPivot, + getChartjsTransform, + getPlotlyTransform, } from '../src'; import { computePivot, @@ -1056,6 +1060,77 @@ describe('applyTransform — two independent overrides', () => { }); }); +describe('backend transform parity — chartType/arrange overrides', () => { + const scatterProps = (props?: Record) => ({ + data: { + values: [ + { a: 1, b: 2, c: 'X' }, { a: 3, b: 1, c: 'Y' }, + { a: 2, b: 4, c: 'X' }, { a: 5, b: 3, c: 'Y' }, + ], + }, + semantic_types: { a: 'Quantity', b: 'Quantity', c: 'Category' }, + chart_spec: { + chartType: 'Scatter Plot', + encodings: { x: 'a', y: 'b', color: 'c' }, + baseSize: { width: 400, height: 300 }, + ...(props ? { chartProperties: props } : {}), + }, + }); + + const barProps = (props?: Record) => ({ + data: { values: BAR_DATA }, + semantic_types: BAR_SEMANTIC, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'region', y: 'sales', color: 'segment' }, + baseSize: { width: 400, height: 300 }, + ...(props ? { chartProperties: props } : {}), + }, + }); + + it('ECharts honors chartProperties.chartType (gallery two-control model)', () => { + const surface = getEChartsTransform(scatterProps())!; + expect(surface.chartType!.ids).toContain('type:Strip Plot'); + + const option = assembleECharts(scatterProps({ chartType: 'type:Strip Plot' })) as any; + expect(option._transform.chartType.index).toBe( + option._transform.chartType.ids.indexOf('type:Strip Plot'), + ); + expect(Array.isArray(option.xAxis)).toBe(true); + expect(option.xAxis[0].name).toBe('c'); + }); + + it('Chart.js honors chartProperties.chartType (gallery two-control model)', () => { + const surface = getChartjsTransform(scatterProps())!; + expect(surface.chartType!.ids).toContain('type:Strip Plot'); + + const config = assembleChartjs(scatterProps({ chartType: 'type:Strip Plot' })) as any; + expect(config._transform.chartType.index).toBe( + config._transform.chartType.ids.indexOf('type:Strip Plot'), + ); + expect(config.options.scales.x.title.text).toBe('c'); + }); + + it('Plotly honors chartProperties.chartType and arrange (gallery two-control model)', () => { + const typeSurface = getPlotlyTransform(scatterProps())!; + expect(typeSurface.chartType!.ids).toContain('type:Strip Plot'); + + const strip = assemblePlotly(scatterProps({ chartType: 'type:Strip Plot' })) as any; + expect(strip._transform.chartType.index).toBe( + strip._transform.chartType.ids.indexOf('type:Strip Plot'), + ); + + const arrangeSurface = getPlotlyTransform(barProps())!; + expect(arrangeSurface.arrange!.ids).toContain('flip:x-y'); + + const flipped = assemblePlotly(barProps({ arrange: 'flip:x-y' })) as any; + expect(flipped._transform.arrange.index).toBe( + flipped._transform.arrange.ids.indexOf('flip:x-y'), + ); + expect(flipped.data?.[0]?.orientation).toBe('h'); + }); +}); + describe('getChartTransform — end-to-end through assembleVegaLite', () => { const scatterInput = (props?: Record) => ({ data: { values: SCATTER_DATA }, diff --git a/packages/flint-js/tests/sort-curve-widgets.test.ts b/packages/flint-js/tests/sort-curve-widgets.test.ts new file mode 100644 index 00000000..4cc902c7 --- /dev/null +++ b/packages/flint-js/tests/sort-curve-widgets.test.ts @@ -0,0 +1,109 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Regression: ECharts / Chart.js Sort encoding action (gallery Sort control). + * Parity with packages/flint-js/tests/plotly-widgets.test.ts. + */ + +import { describe, it, expect } from 'vitest'; +import { assembleECharts, assembleChartjs } from '../src'; + +const CATS = [{ Cat: 'A', Val: 30 }, { Cat: 'B', Val: 90 }, { Cat: 'C', Val: 50 }]; +const CAT_TYPES = { Cat: 'Category', Val: 'Quantity' } as const; + +function ecBandOrder(spec: any): string[] { + const cat = [spec.xAxis, spec.yAxis].find((ax) => ax?.type === 'category'); + return cat?.data ?? []; +} + +function cjsBandOrder(spec: any): string[] { + return spec.data?.labels ?? []; +} + +describe('ECharts Sort encoding action', () => { + for (const chartType of ['Bar Chart', 'Stacked Bar Chart', 'Grouped Bar Chart', 'Lollipop Chart']) { + it(`reorders ${chartType} categories by value`, () => { + const mk = (sort?: string) => assembleECharts({ + data: { values: CATS }, + semantic_types: CAT_TYPES, + chart_spec: { + chartType, encodings: { x: 'Cat', y: 'Val' }, + ...(sort ? { chartProperties: { sort } } : {}), + }, + } as any); + expect(ecBandOrder(mk())).toEqual(['A', 'B', 'C']); + expect(ecBandOrder(mk('value-desc'))).toEqual(['B', 'C', 'A']); + expect(ecBandOrder(mk('value-asc'))).toEqual(['A', 'C', 'B']); + }); + } +}); + +describe('Chart.js Sort encoding action', () => { + for (const chartType of ['Bar Chart', 'Stacked Bar Chart', 'Grouped Bar Chart']) { + it(`reorders ${chartType} categories by value`, () => { + const mk = (sort?: string) => assembleChartjs({ + data: { values: CATS }, + semantic_types: CAT_TYPES, + chart_spec: { + chartType, encodings: { x: 'Cat', y: 'Val' }, + ...(sort ? { chartProperties: { sort } } : {}), + }, + } as any); + expect(cjsBandOrder(mk())).toEqual(['A', 'B', 'C']); + expect(cjsBandOrder(mk('value-desc'))).toEqual(['B', 'C', 'A']); + expect(cjsBandOrder(mk('value-asc'))).toEqual(['A', 'C', 'B']); + }); + } +}); + +describe('Line Curve interpolate (non-VL)', () => { + it('ECharts applies monotone smooth', () => { + const data = [ + { t: 1, v: 2 }, { t: 2, v: 5 }, { t: 3, v: 3 }, + ]; + const option = assembleECharts({ + data: { values: data }, + semantic_types: { t: 'Quantity', v: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 't', y: 'v' }, + chartProperties: { interpolate: 'monotone' }, + }, + } as any); + expect(option.series?.some((s: any) => s.smooth === true)).toBe(true); + }); + + it('Chart.js applies monotone tension', () => { + const data = [ + { t: 1, v: 2 }, { t: 2, v: 5 }, { t: 3, v: 3 }, + ]; + const config = assembleChartjs({ + data: { values: data }, + semantic_types: { t: 'Quantity', v: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 't', y: 'v' }, + chartProperties: { interpolate: 'monotone' }, + }, + } as any); + expect(config.data.datasets[0].tension).toBe(0.4); + }); + + it('ECharts keeps basis (not dropped by normalize)', () => { + const data = [ + { t: 1, v: 2 }, { t: 2, v: 5 }, { t: 3, v: 3 }, + ]; + const option = assembleECharts({ + data: { values: data }, + semantic_types: { t: 'Quantity', v: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 't', y: 'v' }, + chartProperties: { interpolate: 'basis' }, + }, + } as any); + expect(option.series?.some((s: any) => s.smooth === true)).toBe(true); + expect(option._warnings?.some((w: any) => w.code === 'invalid-option-value')).toBeFalsy(); + }); +}); diff --git a/site/src/components/ChartCodeModal.tsx b/site/src/components/ChartCodeModal.tsx index f110caa2..af1ad599 100644 --- a/site/src/components/ChartCodeModal.tsx +++ b/site/src/components/ChartCodeModal.tsx @@ -83,13 +83,15 @@ export function ChartCodeModal({ ); const outputText = useMemo(() => { - if (!testCase) return ''; + if (!displayInput) return ''; try { - const input = testCaseToAssemblyInput(testCase); - const spec = BACKENDS[chart.backend].assemble(input); - // Drop Flint's private `_`-prefixed annotations (`_pivot`, `_warnings`, - // `_width`, `_height`): they are internal metadata for the host/runtime, - // not part of the actual backend spec a user would render or copy. + // Compile the same overridden input the chart/Input tab show, so Output + // tracks gallery option-bar edits (chart type, arrange, properties, …). + const spec = BACKENDS[chart.backend].assemble(displayInput); + // Drop Flint's private `_`-prefixed annotations (`_pivot`, `_transform`, + // `_warnings`, `_width`, `_height`): they are internal metadata for the + // host/runtime, not part of the actual backend spec a user would render + // or copy. const clean = spec && typeof spec === 'object' ? { ...(spec as Record) } : spec; if (clean && typeof clean === 'object') { for (const key of Object.keys(clean)) { @@ -102,7 +104,7 @@ export function ChartCodeModal({ (err as Error)?.message ?? err, )}`; } - }, [testCase, chart.backend]); + }, [displayInput, chart.backend]); const codeText = tab === 'input' ? inputText : outputText; diff --git a/site/src/shared/chart-options.ts b/site/src/shared/chart-options.ts index c92e7d31..59994014 100644 --- a/site/src/shared/chart-options.ts +++ b/site/src/shared/chart-options.ts @@ -18,12 +18,13 @@ import { getChartPivot, getChartTransform, resolveEncodingType, - vlGetTemplateDef, + TRANSFORM_CHART_TYPE_KEY, } from 'flint-chart'; import type { ChartAssemblyInput, ChartEncoding, ChartOption, + ChartPropertyDef, EncodingActionDef, PivotSurface, RawEncodingValue, @@ -73,6 +74,19 @@ function semanticTypeOf(input: ChartAssemblyInput, field: string): string { return typeof st === 'string' ? st : ((st as { type?: string }).type ?? ''); } +/** + * Effective chart type for controls: honor a gallery chart-type override + * (`chartProperties.chartType = 'type:Strip Plot'`) so properties/actions match + * what assemble will actually render after applyTransform. + */ +function effectiveChartType(input: ChartAssemblyInput): string { + const raw = input.chart_spec.chartProperties?.[TRANSFORM_CHART_TYPE_KEY]; + if (typeof raw === 'string' && raw.startsWith('type:') && raw.length > 5) { + return raw.slice('type:'.length); + } + return input.chart_spec.chartType; +} + /** * Normalize the user's raw encodings into `ChartEncoding` objects with a * resolved Vega type, so encoding actions (which read `enc.type`) work without @@ -145,12 +159,38 @@ function backendSupportedPropertyKeys( return new Set(props.map((p) => p.key)); } +/** + * Narrow a discrete ChartOption's value list to what the selected backend + * template actually accepts. Without this, VL-only Curve tokens (e.g. `basis`) + * appear in the gallery for ECharts/Chart.js/Plotly, then + * `normalizeChartProperties` silently drops them and the chart never changes. + */ +function alignDiscreteOptionsToBackend( + option: ChartOption, + backendProps: ChartPropertyDef[] | undefined, +): ChartOption { + if (option.type !== 'discrete' || !backendProps) return option; + const def = backendProps.find((p) => p.key === option.key); + if (!def || def.type !== 'discrete' || !def.options?.length) return option; + return { + ...option, + options: def.options.map((o) => ({ + value: o.value, + label: o.label ?? (o.value == null ? 'Default' : String(o.value)), + })), + }; +} + /** Build the option model (properties + encoding actions) for the input. */ export function buildPanelModel( input: ChartAssemblyInput, backend: PreviewBackend = 'vegalite', ): PanelModel { - const def = vlGetTemplateDef(input.chart_spec.chartType); + const chartType = effectiveChartType(input); + const backendDef = BACKENDS[backend].getTemplateDef(chartType); + // Fall back to VL actions only when the selected backend has no template for + // this type (shouldn't happen for gallery tiles, but keeps the bar usable). + const actionSource = backendDef ?? BACKENDS.vegalite.getTemplateDef(chartType); let properties: ChartOption[] = []; try { @@ -164,10 +204,16 @@ export function buildPanelModel( // bar never shows a knob that does nothing. `facetColumns` is exempt: it is a // LAYOUT-level control (facet wrap) honored by every backend's assembler, not // a per-template mark property, so it never appears in a template's own list. - const supported = backendSupportedPropertyKeys(input.chart_spec.chartType, backend); + const supported = backendSupportedPropertyKeys(chartType, backend); if (supported) { properties = properties.filter((o) => o.key === 'facetColumns' || supported.has(o.key)); } + // Also replace discrete option lists with the backend's accepted values so + // Curve / stackMode / etc. can't offer tokens normalizeChartProperties drops. + const backendProps = backendDef?.properties; + if (backend !== 'vegalite' && backendProps) { + properties = properties.map((o) => alignDiscreteOptionsToBackend(o, backendProps)); + } const encodings = normalizeEncodings(input); const ctx = { @@ -175,7 +221,7 @@ export function buildPanelModel( data: input.data?.values ?? [], chartProperties: input.chart_spec.chartProperties, }; - const actions: ResolvedAction[] = (def?.encodingActions ?? []) + const actions: ResolvedAction[] = (actionSource?.encodingActions ?? []) .filter((a) => (a.isApplicable ? a.isApplicable(ctx) : true)) .map((a) => ({ key: a.key, @@ -192,16 +238,16 @@ export function buildPanelModel( } // Factored two-control transform surfaces (chart type = θ, arrange = τ/σ/γ). - let chartType: PivotSurface | undefined; + let chartTypeSurface: PivotSurface | undefined; let arrange: PivotSurface | undefined; try { const transform = getChartTransform(input); - chartType = transform?.chartType; + chartTypeSurface = transform?.chartType; arrange = transform?.arrange; } catch { - chartType = undefined; + chartTypeSurface = undefined; arrange = undefined; } - return { properties, actions, pivot, chartType, arrange }; + return { properties, actions, pivot, chartType: chartTypeSurface, arrange }; } From 0a9fef9a42ca20ba255c7ba13ecce2f400af1774 Mon Sep 17 00:00:00 2001 From: Chenglong Wang Date: Mon, 27 Jul 2026 11:41:38 -0700 Subject: [PATCH 2/3] icons --- .gitignore | 3 ++ README.md | 2 +- assets/flint-logo-pixel.svg | 36 +++++++++++++ assets/flint-logo.svg | 9 ++++ docs/reference-chartjs.md | 4 +- docs/reference-echarts.md | 4 +- docs/reference-plotly.md | 4 +- docs/zh-CN/reference-plotly.md | 4 +- site/index.html | 2 +- site/public/favicon.svg | 9 ++++ site/src/assets/flint-logo.svg | 9 ++++ site/src/routes/Landing.tsx | 34 ++++++++++-- .../excel/evaluations/render-client.mjs | 52 +++++++++++++++++-- test-harness/excel/package.json | 9 ---- 14 files changed, 152 insertions(+), 29 deletions(-) create mode 100644 assets/flint-logo-pixel.svg create mode 100644 assets/flint-logo.svg create mode 100644 site/public/favicon.svg create mode 100644 site/src/assets/flint-logo.svg diff --git a/.gitignore b/.gitignore index 4510397c..a5c88ef0 100644 --- a/.gitignore +++ b/.gitignore @@ -14,6 +14,9 @@ build/ coverage/ .nyc_output/ +# Experiment outputs (source, inputs, and manifests stay tracked) +experiments/* + # Logs npm-debug.log* yarn-debug.log* diff --git a/README.md b/README.md index d0903ddf..32a93f33 100644 --- a/README.md +++ b/README.md @@ -1,4 +1,4 @@ -# Flint: A Visualization Language for the AI Era +

Flint: A Visualization Language for the AI Era

[![npm: flint-chart](https://img.shields.io/npm/v/flint-chart.svg?label=npm%3A%20flint-chart)](https://www.npmjs.com/package/flint-chart) [![npm: flint-chart-mcp](https://img.shields.io/npm/v/flint-chart-mcp.svg?label=npm%3A%20flint-chart-mcp)](https://www.npmjs.com/package/flint-chart-mcp) diff --git a/assets/flint-logo-pixel.svg b/assets/flint-logo-pixel.svg new file mode 100644 index 00000000..2003d551 --- /dev/null +++ b/assets/flint-logo-pixel.svg @@ -0,0 +1,36 @@ + + Flint pixel mark + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/assets/flint-logo.svg b/assets/flint-logo.svg new file mode 100644 index 00000000..f155dfb0 --- /dev/null +++ b/assets/flint-logo.svg @@ -0,0 +1,9 @@ + + Flint + + + + + + + \ No newline at end of file diff --git a/docs/reference-chartjs.md b/docs/reference-chartjs.md index f06c1265..4fece66b 100644 --- a/docs/reference-chartjs.md +++ b/docs/reference-chartjs.md @@ -125,7 +125,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | ### ![](chart-icon-bump.svg) Bump Chart @@ -145,7 +145,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | conditional | Mark opacity. | | `stackMode` | choice | Stacked (default) _(default)_, `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. | diff --git a/docs/reference-echarts.md b/docs/reference-echarts.md index dc657223..4ee28465 100644 --- a/docs/reference-echarts.md +++ b/docs/reference-echarts.md @@ -134,7 +134,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | ### ![](chart-icon-bump.svg) Bump Chart @@ -155,7 +155,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.7` | conditional | Mark opacity. | | `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `center` (Center), `layered` (Layered (overlap)) | — | always | Stacking strategy for overlapping series. | diff --git a/docs/reference-plotly.md b/docs/reference-plotly.md index 4bdf2c8a..02fc7478 100644 --- a/docs/reference-plotly.md +++ b/docs/reference-plotly.md @@ -181,7 +181,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `showPoints` | toggle | on / off | `false` | always | Overlay point markers on the line. | ### ![](chart-icon-area.svg) Area Chart @@ -190,7 +190,7 @@ _No template-specific parameters._ | Parameter | Control | Domain | Default | Availability | Description | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | Line or area interpolation method. | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | Line or area interpolation method. | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | always | Mark opacity. | | `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | Stacking strategy for overlapping series. | diff --git a/docs/zh-CN/reference-plotly.md b/docs/zh-CN/reference-plotly.md index c5313b39..ecf7032c 100644 --- a/docs/zh-CN/reference-plotly.md +++ b/docs/zh-CN/reference-plotly.md @@ -177,7 +177,7 @@ _无模板专用参数。_ | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After) | — | always | 线或区域的插值方式。 | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 线或区域的插值方式。 | | `showPoints` | toggle | on / off | `false` | always | 在线上叠加点标记。 | ### ![](chart-icon-area.svg) Area Chart @@ -186,7 +186,7 @@ _无模板专用参数。_ | 参数 | 控件 | 取值范围 | 默认值 | 可用性 | 说明 | |---|---|---|---|---|---| -| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)) | — | always | 线或区域的插值方式。 | +| `interpolate` | choice | Default (linear) _(default)_, `linear` (Linear), `monotone` (Monotone (smooth)), `step` (Step), `step-before` (Step Before), `step-after` (Step After), `basis` (Basis (smooth)), `cardinal` (Cardinal), `catmull-rom` (Catmull-Rom) | — | always | 线或区域的插值方式。 | | `opacity` | number | 0.1 – 1 (step 0.05) | `0.4` | always | 标记不透明度。 | | `stackMode` | choice | Stacked (default) _(default)_, `normalize` (Normalize (100%)), `layered` (Layered (overlap)) | — | conditional | 重叠系列的堆叠策略。 | diff --git a/site/index.html b/site/index.html index a5b85a15..754e3426 100644 --- a/site/index.html +++ b/site/index.html @@ -4,7 +4,7 @@ Flint: A Visualization Language for the AI Era - +