diff --git a/package-lock.json b/package-lock.json index 5ed9d82..83286a2 100644 --- a/package-lock.json +++ b/package-lock.json @@ -9615,7 +9615,8 @@ "chart.js": "^4.0.0", "echarts": "^5.0.0 || ^6.0.0", "vega": "^5.0.0 || ^6.0.0", - "vega-lite": "^5.0.0 || ^6.0.0" + "vega-lite": "^5.0.0 || ^6.0.0", + "plotly.js": "^2.0.0 || ^3.0.0" }, "peerDependenciesMeta": { "chart.js": { @@ -9629,6 +9630,9 @@ }, "vega-lite": { "optional": true + }, + "plotly.js": { + "optional": true } } }, @@ -9854,7 +9858,8 @@ "remark-math": "^6.0.0", "vega": "^6.0.0", "vega-embed": "^7.1.0", - "vega-lite": "^6.4.1" + "vega-lite": "^6.4.1", + "plotly.js-dist-min": "^2.35.2" }, "devDependencies": { "@types/react": "^18.3.0", @@ -9864,6 +9869,12 @@ "typescript": "^5.6.0", "vite": "^7.3.5" } + }, + "node_modules/plotly.js-dist-min": { + "version": "2.35.3", + "resolved": "https://registry.npmjs.org/plotly.js-dist-min/-/plotly.js-dist-min-2.35.3.tgz", + "integrity": "sha512-sz2HLP8gkysLx/BanM2PtJTtZ1PLPwdHwMWNri2YxLBy3IOeuDsVQtlmWa4hoK3j/fi4naaD3uZJqH5ozM3zGg==", + "license": "MIT" } } } diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index 63c8ff8..43d3389 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -50,6 +50,11 @@ "import": "./dist/echarts/index.js", "require": "./dist/echarts/index.cjs" }, + "./plotly": { + "types": "./dist/plotly/index.d.ts", + "import": "./dist/plotly/index.js", + "require": "./dist/plotly/index.cjs" + }, "./chartjs": { "types": "./dist/chartjs/index.d.ts", "import": "./dist/chartjs/index.js", @@ -92,6 +97,7 @@ }, "peerDependencies": { "chart.js": "^4.0.0", + "plotly.js": "^2.0.0 || ^3.0.0", "echarts": "^5.0.0 || ^6.0.0", "vega": "^5.0.0 || ^6.0.0", "vega-lite": "^5.0.0 || ^6.0.0" @@ -108,6 +114,9 @@ }, "chart.js": { "optional": true + }, + "plotly.js": { + "optional": true } }, "devDependencies": { diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 90f845c..824f974 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -14,6 +14,7 @@ * vegalite/ — Vega-Lite backend: assembly, templates, spec instantiation * echarts/ — ECharts backend: assembly, templates, spec instantiation * chartjs/ — Chart.js backend: assembly, templates, spec instantiation + * plotly/ — Plotly backend: assembly, templates, spec instantiation * * Assembly functions: * assembleVegaLite(input) — Vega-Lite spec @@ -52,6 +53,7 @@ export * from './echarts'; // Chart.js backend: assembleChartjs, templates, spec instantiation export * from './chartjs'; +export * from './plotly'; // Excel backend: assembleExcel + Excel chart spec types export * from './excel'; diff --git a/packages/flint-js/src/plotly/assemble.ts b/packages/flint-js/src/plotly/assemble.ts new file mode 100644 index 0000000..628bb30 --- /dev/null +++ b/packages/flint-js/src/plotly/assemble.ts @@ -0,0 +1,361 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly chart assembly — Two-Stage Pipeline Coordinator. + * + * Reuses the **same core analysis pipeline** as the other backends: + * Phase 0: resolveChannelSemantics → ChannelSemantics + * Step 0a: declareLayoutMode → LayoutDeclaration + * Step 0b: convertTemporalData → converted data + * Step 0c: filterOverflow → filtered data, nominalCounts + * Phase 1: computeLayout → LayoutResult + * + * Then diverges for Phase 2 (Plotly-specific): + * template.instantiate → builds the Plotly figure structure + * plApplyLayoutToSpec → applies layout decisions to the figure + * + * Key structural difference from the other backends' output: + * PL: { data: [{ type, x, y, … }], layout: { xaxis, yaxis, … } } + * Figures are pure JSON — no callback functions anywhere — so compiled + * specs survive serialization across process boundaries. + * + * column/row facets render as a subplot grid (see facet.ts), mirroring the + * Chart.js backend's facet decisions (shared nice y-domain, leftmost-only + * y labels, column wrapping). + * + * This module has NO React, Redux, or UI framework dependencies. + */ + +import { + ChartTemplateDef, + ChartAssemblyInput, + AssembleOptions, + LayoutDeclaration, + InstantiateContext, +} from '../core/types'; +import type { ChartWarning } from '../core/types'; +import { applyEncodingOverrides } from '../core/encoding-overrides'; +import { applyAggregation } from '../core/aggregate'; +import { plGetTemplateDef } from './templates'; +import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; +import { computeZeroDecision } from '../core/semantic-types'; +import { filterOverflow } from '../core/filter-overflow'; +import { computeLayout, computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; +import { decideColorMaps } from '../core/color-decisions'; +import { plApplyLayoutToSpec, plApplyTooltips } from './instantiate-spec'; +import { plCombineFacetPanels, niceBounds, type PlotlyFacetPanel } from './facet'; +import { normalizeStaticSeries } from '../core/static-series'; +import { normalizeChartProperties } from '../core/normalize-properties'; + +// --------------------------------------------------------------------------- +// Public API +// --------------------------------------------------------------------------- +/** + * Assemble a Plotly figure object (`{ data, layout }`). + * + * ```ts + * const figure = assemblePlotly({ + * data: { values: myRows }, + * semantic_types: { weight: 'Quantity' }, + * chart_spec: { chartType: 'Bar Chart', encodings: { x: { field: 'category' }, y: { field: 'value' } } }, + * }); + * ``` + * + * @returns A Plotly figure with optional `_warnings` and `_width`/`_height` hints + */ +export function assemblePlotly(input: ChartAssemblyInput): any { + const chartType = input.chart_spec.chartType; + const semanticTypes = input.semantic_types ?? {}; + const sizeCeiling = input.chart_spec.canvasSize; + const baseSize = resolveBaseSize(input.chart_spec.baseSize, sizeCeiling); + const canvasSize = baseSize; + const options = input.options ?? {}; + const chartTemplate = plGetTemplateDef(chartType) as ChartTemplateDef; + if (!chartTemplate) { + throw new Error(`Unknown Plotly chart type: ${chartType}. Use plAllTemplateDefs to see available types.`); + } + + const warnings: ChartWarning[] = []; + + const normalizedProps = normalizeChartProperties( + chartTemplate.properties, input.chart_spec.chartProperties, + ); + const chartProperties = normalizedProps.chartProperties; + warnings.push(...normalizedProps.warnings); + + // ═══════════════════════════════════════════════════════════════════════ + // PRE-PHASE: Static Series Normalization + // ═══════════════════════════════════════════════════════════════════════ + const rawData = input.data.values ?? []; + const normalized = normalizeStaticSeries( + input.chart_spec.encodings, rawData, semanticTypes, + ); + let data = normalized.data; + const staticSeries = normalized.staticSeries; + + const encodings = applyEncodingOverrides(chartTemplate, normalized.encodings, chartProperties); + + // Optional aggregation transform — see vegalite/assemble for rationale. + data = applyAggregation(encodings, data); + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 0: Resolve Semantics (shared — completely target-agnostic) + // ═══════════════════════════════════════════════════════════════════════ + + const tplMark = chartTemplate.template?.mark; + const templateMarkType = typeof tplMark === 'string' ? tplMark : tplMark?.type; + + const convertedData = convertTemporalData(data, semanticTypes); + + const channelSemantics = resolveChannelSemantics( + encodings, data, semanticTypes, convertedData, + ); + + // Finalize zero-baseline (requires template mark knowledge) + const effectiveMarkType = templateMarkType || 'point'; + for (const [channel, cs] of Object.entries(channelSemantics)) { + if ((channel === 'x' || channel === 'y') && cs.type === 'quantitative') { + const numericValues = data + .map(r => r[cs.field]) + .filter((v: any) => v != null && typeof v === 'number' && !isNaN(v)); + cs.zero = computeZeroDecision( + cs.semanticAnnotation.semanticType, channel, effectiveMarkType, numericValues, + ); + } + } + + // ═══════════════════════════════════════════════════════════════════════ + // STEP 0a: declareLayoutMode (shared hook) + // ═══════════════════════════════════════════════════════════════════════ + + const declaration: LayoutDeclaration = chartTemplate.declareLayoutMode + ? chartTemplate.declareLayoutMode(channelSemantics, data, chartProperties) + : {}; + + const effectiveOptions: AssembleOptions = { + ...options, + ...(declaration.paramOverrides || {}), + }; + + Object.assign(effectiveOptions, deriveStretchCaps(baseSize, sizeCeiling, effectiveOptions)); + + const { + addTooltips: addTooltipsOpt = false, + } = effectiveOptions; + + // ═══════════════════════════════════════════════════════════════════════ + // STEP 0b: filterOverflow (shared) + // ═══════════════════════════════════════════════════════════════════════ + + const allMarkTypes = new Set(); + if (templateMarkType) allMarkTypes.add(templateMarkType); + + const budgets = computeChannelBudgets( + channelSemantics, declaration, convertedData, canvasSize, effectiveOptions, + ); + const facetGridResult = budgets.facetGrid; + + const overflowResult = filterOverflow( + channelSemantics, declaration, encodings, convertedData, + budgets, allMarkTypes, + ); + + const values = overflowResult.filteredData; + warnings.push(...overflowResult.warnings); + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 1: Compute Layout (shared — completely target-agnostic) + // ═══════════════════════════════════════════════════════════════════════ + + const layoutResult = computeLayout( + channelSemantics, + declaration, + values, + canvasSize, + effectiveOptions, + facetGridResult, + ); + + layoutResult.truncations = overflowResult.truncations; + + // ═══════════════════════════════════════════════════════════════════════ + // PHASE 2: Instantiate Plotly Figure (PL-specific) + // ═══════════════════════════════════════════════════════════════════════ + + const resolvedEncodings: Record = {}; + for (const [channel, encoding] of Object.entries(encodings)) { + const cs = channelSemantics[channel]; + if (cs) { + resolvedEncodings[channel] = { + field: cs.field, + type: cs.type, + aggregate: encoding.aggregate, + }; + } + } + + const instantiateContext: InstantiateContext = { + channelSemantics, + layout: layoutResult, + table: values, + fullTable: convertedData, + resolvedEncodings, + encodings, + chartProperties, + staticSeries, + canvasSize, + semanticTypes, + chartType, + assembleOptions: effectiveOptions, + colorDecisions: decideColorMaps({ + chartType, + encodings, + channelSemantics, + table: values, + background: 'light', + }), + }; + + const colField = channelSemantics.column?.field; + const rowField = channelSemantics.row?.field; + const hasFacet = !!(colField || rowField); + + let figure: any; + if (hasFacet) { + const colValues = colField ? [...new Set(values.map((r: any) => String(r[colField])))] : ['']; + const rowValues = rowField ? [...new Set(values.map((r: any) => String(r[rowField])))] : ['']; + + // Shared y-domain across panels (mirror the Chart.js backend): nice + // bounds so the shared top/bottom land on round tick values. + const yField = channelSemantics.y?.field; + let sharedYDomain: { min: number; max: number } | undefined; + if (yField) { + const nums = values + .map((r: any) => r[yField]) + .filter((v: any) => typeof v === 'number' && Number.isFinite(v)) as number[]; + if (nums.length > 0) { + const rawMin = Math.min(...nums); + const rawMax = Math.max(...nums); + const forceZero = !!channelSemantics.y?.zero?.zero; + const min = forceZero ? Math.min(0, rawMin) : rawMin; + const max = forceZero ? Math.max(0, rawMax) : rawMax; + sharedYDomain = niceBounds(min, max); + } + } + + // Column wrapping: a column-only facet with more categories than fit + // in one row wraps into a 2D grid (matching the other backends). The + // wrap width comes from the shared facet-grid budget. + const maxColsPerRow = (colField && !rowField) + ? (facetGridResult?.columns ?? colValues.length) + : colValues.length; + const wrapColumnOnly = !!colField && !rowField && maxColsPerRow < colValues.length; + + const gridRows: Array> = []; + if (wrapColumnOnly) { + for (let i = 0; i < colValues.length; i += maxColsPerRow) { + gridRows.push( + colValues.slice(i, i + maxColsPerRow).map((cv) => ({ colVal: cv, rowVal: '' })), + ); + } + } else { + for (let ri = 0; ri < rowValues.length; ri++) { + gridRows.push(colValues.map((cv) => ({ colVal: cv, rowVal: rowValues[ri] }))); + } + } + const gridCols = Math.max(1, ...gridRows.map(r => r.length)); + + // Panel plot size — same discrete/continuous rules as plApplyLayoutToSpec. + const xIsDiscrete = layoutResult.xNominalCount > 0 || layoutResult.xContinuousAsDiscrete > 0; + const yIsDiscrete = layoutResult.yNominalCount > 0 || layoutResult.yContinuousAsDiscrete > 0; + let panelWidth: number; + let panelHeight: number; + if (xIsDiscrete && layoutResult.xStepUnit !== 'group') { + const n = layoutResult.xNominalCount || layoutResult.xContinuousAsDiscrete || 0; + panelWidth = n > 0 ? layoutResult.xStep * n : (layoutResult.subplotWidth || canvasSize.width); + } else { + panelWidth = layoutResult.subplotWidth || canvasSize.width; + } + if (yIsDiscrete && layoutResult.yStepUnit !== 'group') { + const n = layoutResult.yNominalCount || layoutResult.yContinuousAsDiscrete || 0; + panelHeight = n > 0 ? layoutResult.yStep * n : (layoutResult.subplotHeight || canvasSize.height); + } else { + panelHeight = layoutResult.subplotHeight || canvasSize.height; + } + + const panels: PlotlyFacetPanel[] = []; + for (let ri = 0; ri < gridRows.length; ri++) { + const cells = gridRows[ri]; + for (let ci = 0; ci < cells.length; ci++) { + const { colVal, rowVal } = cells[ci]; + const panelData = values.filter((r: any) => { + if (colField && String(r[colField]) !== colVal) return false; + if (rowField && String(r[rowField]) !== rowVal) return false; + return true; + }); + + const panelFigure: any = structuredClone(chartTemplate.template); + const panelContext: InstantiateContext = { + ...instantiateContext, + table: panelData, + }; + chartTemplate.instantiate(panelFigure, panelContext); + if (chartTemplate.postProcess) chartTemplate.postProcess(panelFigure, panelContext); + + panels.push({ + rowIndex: ri, + colIndex: ci, + rowHeader: rowField ? rowVal : undefined, + colHeader: colField ? colVal : undefined, + figure: panelFigure, + }); + } + } + + figure = plCombineFacetPanels(panels, { + rows: gridRows.length, + cols: gridCols, + panelWidth, + panelHeight, + sharedYDomain, + hasColHeader: !!colField, + hasRowHeader: !!rowField, + colHeaderPerRow: wrapColumnOnly, + showLegend: !!channelSemantics.color?.field, + }); + + // Apply the shared x-label rotation / font decisions to every panel axis. + if (layoutResult.xLabel) { + for (const key of Object.keys(figure.layout)) { + if (!/^xaxis\d*$/.test(key)) continue; + const ax = figure.layout[key]; + if (layoutResult.xLabel.labelAngle) { + ax.tickangle = Math.abs(layoutResult.xLabel.labelAngle); + } + if (layoutResult.xLabel.fontSize) { + ax.tickfont = { ...(ax.tickfont || {}), size: layoutResult.xLabel.fontSize }; + } + } + } + if (addTooltipsOpt) plApplyTooltips(figure); + } else { + figure = structuredClone(chartTemplate.template); + chartTemplate.instantiate(figure, instantiateContext); + plApplyLayoutToSpec(figure, instantiateContext, warnings); + if (addTooltipsOpt) plApplyTooltips(figure); + if (chartTemplate.postProcess) chartTemplate.postProcess(figure, instantiateContext); + } + + // ═══════════════════════════════════════════════════════════════════════ + // RESULT + // ═══════════════════════════════════════════════════════════════════════ + + if (warnings.length > 0) { + figure._warnings = warnings; + } + + figure._dataLength = values.length; + + return figure; +} diff --git a/packages/flint-js/src/plotly/colormap.ts b/packages/flint-js/src/plotly/colormap.ts new file mode 100644 index 0000000..54afa6d --- /dev/null +++ b/packages/flint-js/src/plotly/colormap.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +// Plotly 专用调色板定义。 +// 承接 core/color-decisions.ts 中的抽象 colormap 信息(schemeType / schemeId / categoryCount), +// 但真正的颜色数组与选盘策略完全在 Plotly backend 本地实现,并尽量贴近 Plotly 默认配色。 + +import type { ColorDecision, ColorMapType } from '../core/color-decisions'; + +export type PlotlyPaletteId = 'plotly10' | 'light24' | 'viridis' | 'RdBu' | string; + +export interface PlotlyColorMapDef { + id: PlotlyPaletteId; + type: ColorMapType; + supportsDiscrete: boolean; + supportsContinuous: boolean; + background: 'light' | 'dark' | 'any'; + colorblindSafe?: boolean; + maxCategories?: number; + diverging?: boolean; + preferredMidpoint?: number; + colors: string[]; +} + +/** + * Plotly 常用的基础配色。plotly10 即 plotly.js 默认 `layout.colorway`; + * light24 对应 px.colors.qualitative.Light24 的前 20 色,用于高基数类别。 + */ +const PLOTLY_COLOR_MAPS: PlotlyColorMapDef[] = [ + { + id: 'plotly10', + type: 'categorical', + supportsDiscrete: true, + supportsContinuous: false, + background: 'any', + maxCategories: 10, + colorblindSafe: false, + colors: [ + '#636efa', // blue-violet + '#EF553B', // red-orange + '#00cc96', // green + '#ab63fa', // purple + '#FFA15A', // orange + '#19d3f3', // cyan + '#FF6692', // pink + '#B6E880', // light green + '#FF97FF', // magenta + '#FECB52', // yellow + ], + }, + { + id: 'light24', + type: 'categorical', + supportsDiscrete: true, + supportsContinuous: false, + background: 'light', + maxCategories: 24, + colorblindSafe: false, + colors: [ + '#FD3216', '#00FE35', '#6A76FC', '#FED4C4', '#FE00CE', + '#0DF9FF', '#F6F926', '#FF9616', '#479B55', '#EEA6FB', + '#DC587D', '#D626FF', '#6E899C', '#00B5F7', '#B68E00', + '#C9FBE5', '#FF0092', '#22FFA7', '#E3EE9E', '#86CE00', + '#BC7196', '#7E7DCD', '#FC6955', '#E48F72', + ], + }, + { + id: 'viridis', + type: 'sequential', + supportsDiscrete: true, + supportsContinuous: true, + background: 'any', + colorblindSafe: true, + colors: [ + '#440154', '#46327e', '#365c8d', '#277f8e', + '#1fa187', '#4ac16d', '#a0da39', '#fde725', + ], + }, + { + id: 'RdBu', + type: 'diverging', + supportsDiscrete: true, + supportsContinuous: true, + background: 'any', + diverging: true, + preferredMidpoint: 0, + colors: [ + '#b2182b', '#d6604d', '#f4a582', '#fddbc7', + '#f7f7f7', + '#d1e5f0', '#92c5de', '#4393c3', '#2166ac', + ], + }, +]; + +function getMapById(id: PlotlyPaletteId | undefined): PlotlyColorMapDef | undefined { + if (!id) return undefined; + const key = String(id).toLowerCase(); + return PLOTLY_COLOR_MAPS.find(m => m.id.toLowerCase() === key); +} + +export function getPaletteForScheme(id: PlotlyPaletteId): string[] | undefined { + const entry = getMapById(id); + return entry?.colors; +} + +/** + * Plotly 侧的「选盘」函数:等价于 chartjs/colormap.ts 的 pickChartJsPalette。 + * + * 策略: + * 1)若用户显式指定了 schemeId,则优先按该 id 取 palette。 + * 2)否则根据 schemeType + categoryCount 自动挑选合适的盘: + * - categorical:按类别数量在 plotly10 / light24 之间选; + * - sequential:优先 viridis; + * - diverging :优先 RdBu。 + * 3)若都无法命中,回退到 Plotly 默认 categorical palette(plotly10)。 + */ +export function pickPlotlyPalette(decision: ColorDecision | undefined): string[] { + if (!decision) { + const fallback = getPaletteForScheme('plotly10'); + return fallback && fallback.length ? fallback : []; + } + + const { schemeType, schemeId, categoryCount } = decision; + + if (schemeId) { + const fromId = getPaletteForScheme(schemeId); + if (fromId && fromId.length > 0) { + return fromId; + } + } + + const mapsOfType = PLOTLY_COLOR_MAPS.filter(m => m.type === schemeType); + + if (schemeType === 'categorical') { + const k = categoryCount ?? 0; + const candidates = mapsOfType.filter(m => m.supportsDiscrete); + if (candidates.length) { + const byCapacity = candidates + .filter(m => m.maxCategories == null || m.maxCategories >= k) + .sort((a, b) => (a.maxCategories ?? Infinity) - (b.maxCategories ?? Infinity)); + const picked = byCapacity[0] ?? candidates[0]; + if (picked.colors.length) { + return picked.colors; + } + } + } else if (schemeType === 'sequential') { + const seq = mapsOfType.find(m => m.supportsContinuous) ?? getMapById('viridis'); + if (seq && seq.colors.length) { + return seq.colors; + } + } else if (schemeType === 'diverging') { + const divergingFirst = mapsOfType.find(m => m.diverging) ?? getMapById('RdBu'); + if (divergingFirst && divergingFirst.colors.length) { + return divergingFirst.colors; + } + } + + const fallback = getPaletteForScheme('plotly10'); + return fallback && fallback.length ? fallback : []; +} diff --git a/packages/flint-js/src/plotly/facet.ts b/packages/flint-js/src/plotly/facet.ts new file mode 100644 index 0000000..fe2a6eb --- /dev/null +++ b/packages/flint-js/src/plotly/facet.ts @@ -0,0 +1,216 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly facet grid assembly. + * + * Combines per-panel figures (one template instantiation per column/row cell) + * into a single Plotly figure using per-panel axis pairs (`xaxis`/`xaxis2`/…) + * with explicit domains — the Plotly-native equivalent of the Chart.js + * backend's facet panel grid (chartjs/assemble.ts), mirroring its decisions: + * - shared, nice-rounded y-domain across all panels + * - only the leftmost column draws y tick labels and the y-axis title + * - column-only facets wrap into a 2D grid using the shared facet budget + * - column headers above panels, row headers rotated on the left + * - one legend entry per series (deduped across panels via legendgroup) + */ + +/** One instantiated facet cell. */ +export interface PlotlyFacetPanel { + rowIndex: number; + colIndex: number; + rowHeader?: string; + colHeader?: string; + /** The per-panel figure produced by template.instantiate ({ data, layout }). */ + figure: any; +} + +/** + * Round a [min, max] interval outward to "nice" round numbers so the shared + * facet axis lands its endpoints on clean tick values (same rule as the + * Chart.js backend / Vega-Lite `nice: true`). + */ +export function niceBounds(min: number, max: number, targetTicks = 5): { min: number; max: number } { + if (!(Number.isFinite(min) && Number.isFinite(max)) || max <= min) { + return { min, max }; + } + const niceNum = (range: number, round: boolean): number => { + const exp = Math.floor(Math.log10(range)); + const frac = range / 10 ** exp; + let niceFrac: number; + if (round) { + niceFrac = frac < 1.5 ? 1 : frac < 3 ? 2 : frac < 7 ? 5 : 10; + } else { + niceFrac = frac <= 1 ? 1 : frac <= 2 ? 2 : frac <= 5 ? 5 : 10; + } + return niceFrac * 10 ** exp; + }; + const step = niceNum(niceNum(max - min, false) / Math.max(1, targetTicks - 1), true); + return { + min: Math.floor(min / step) * step, + max: Math.ceil(max / step) * step, + }; +} + +const FACET_GAP_PX = 16; +const COL_HEADER_H = 22; +const ROW_HEADER_W = 28; +const MARGIN = { l: 64, r: 24, t: 16, b: 48 }; +const LEGEND_GUTTER = 96; + +/** + * Combine instantiated facet panels into one Plotly figure. + * + * Each panel's own `layout.xaxis` / `layout.yaxis` (category arrays, titles, + * range modes) is copied onto that panel's axis pair; traces are re-pointed at + * the pair. Domains are computed from the designed pixel grid so the figure + * scales exactly like the other backends' facet output. + */ +export function plCombineFacetPanels( + panels: PlotlyFacetPanel[], + opts: { + rows: number; + cols: number; + panelWidth: number; + panelHeight: number; + sharedYDomain?: { min: number; max: number }; + hasColHeader: boolean; + hasRowHeader: boolean; + /** Wrapped column-only facets repeat the header band above every row. */ + colHeaderPerRow: boolean; + showLegend: boolean; + }, +): any { + const { + rows, cols, panelWidth, panelHeight, sharedYDomain, + hasColHeader, hasRowHeader, colHeaderPerRow, showLegend, + } = opts; + + // Row headers sit on the RIGHT (Vega-Lite style): the left edge is already + // occupied by y tick labels + the y-axis title, so a left-side header would + // collide with them in a single combined figure. + const rowHeaderW = hasRowHeader ? ROW_HEADER_W : 0; + const headerBands = colHeaderPerRow ? rows : (hasColHeader ? 1 : 0); + + const plotW = cols * panelWidth + (cols - 1) * FACET_GAP_PX; + const plotH = rows * panelHeight + (rows - 1) * FACET_GAP_PX + + headerBands * COL_HEADER_H; + const totalW = MARGIN.l + plotW + rowHeaderW + MARGIN.r + (showLegend ? LEGEND_GUTTER : 0); + const totalH = MARGIN.t + plotH + MARGIN.b; + + // Paper-fraction geometry (Plotly domains live inside the margins). + const paperW = plotW + rowHeaderW; + const paperH = plotH; + const xFrac = (px: number) => px / paperW; + const yFrac = (px: number) => px / paperH; + + const colX0 = (ci: number) => ci * (panelWidth + FACET_GAP_PX); + // Row band top (px from paper top), accounting for header bands. + const rowY0 = (ri: number) => (colHeaderPerRow + ? ri * (COL_HEADER_H + panelHeight + FACET_GAP_PX) + COL_HEADER_H + : (hasColHeader ? COL_HEADER_H : 0) + ri * (panelHeight + FACET_GAP_PX)); + + const figure: any = { + data: [], + layout: { + margin: { ...MARGIN }, + showlegend: showLegend, + annotations: [], + }, + _facet: true, + _facetRows: rows, + _facetCols: cols, + _width: totalW, + _height: totalH, + }; + figure.layout.width = totalW; + figure.layout.height = totalH; + + const seenLegend = new Set(); + + for (const panel of panels) { + const { rowIndex: ri, colIndex: ci } = panel; + const n = ri * cols + ci + 1; + const xName = n === 1 ? 'xaxis' : `xaxis${n}`; + const yName = n === 1 ? 'yaxis' : `yaxis${n}`; + const xRef = n === 1 ? 'x' : `x${n}`; + const yRef = n === 1 ? 'y' : `y${n}`; + + const x0 = xFrac(colX0(ci)); + const x1 = xFrac(colX0(ci) + panelWidth); + // Plotly y-domain runs bottom-up; our rows run top-down. + const yTopPx = rowY0(ri); + const y1 = 1 - yFrac(yTopPx); + const y0 = 1 - yFrac(yTopPx + panelHeight); + + const panelLayout = panel.figure?.layout ?? {}; + const xAxis: any = { ...(panelLayout.xaxis ?? {}), domain: [x0, x1], anchor: yRef }; + const yAxis: any = { ...(panelLayout.yaxis ?? {}), domain: [y0, y1], anchor: xRef }; + + // Shared y-domain across panels (mirror CJS: fixed nice range, no + // per-panel autorange). Rangemode would fight an explicit range. + if (sharedYDomain) { + yAxis.range = [sharedYDomain.min, sharedYDomain.max]; + delete yAxis.rangemode; + } + // Only the leftmost column shows y tick labels + title. + if (ci > 0) { + yAxis.showticklabels = false; + delete yAxis.title; + } + // X-axis titles only on the bottom row (a per-panel title would + // collide with the next row's column headers in a single figure). + if (ri < rows - 1) { + delete xAxis.title; + } + + figure.layout[xName] = xAxis; + figure.layout[yName] = yAxis; + + for (const trace of panel.figure?.data ?? []) { + const placed: any = { ...trace, xaxis: xRef, yaxis: yRef }; + const legendKey = String(placed.name ?? ''); + if (legendKey) { + placed.legendgroup = legendKey; + placed.showlegend = !seenLegend.has(legendKey); + seenLegend.add(legendKey); + } else { + placed.showlegend = false; + } + figure.data.push(placed); + } + + // Column header annotation (per wrapped row, or once on the top row). + if (panel.colHeader != null && (colHeaderPerRow || ri === 0)) { + figure.layout.annotations.push({ + text: String(panel.colHeader), + showarrow: false, + xref: 'paper', yref: 'paper', + x: (x0 + x1) / 2, + y: 1 - yFrac(yTopPx - COL_HEADER_H / 2), + xanchor: 'center', yanchor: 'middle', + font: { size: 12 }, + }); + } + + // Row header annotation (rightmost side, Vega-Lite style), rotated. + if (panel.rowHeader != null && ci === cols - 1) { + figure.layout.annotations.push({ + text: String(panel.rowHeader), + showarrow: false, + textangle: 90, + xref: 'paper', yref: 'paper', + x: xFrac(plotW + rowHeaderW / 2), + y: (y0 + y1) / 2, + xanchor: 'center', yanchor: 'middle', + font: { size: 12 }, + }); + } + } + + if (!showLegend) { + figure.layout.showlegend = false; + } + + return figure; +} diff --git a/packages/flint-js/src/plotly/index.ts b/packages/flint-js/src/plotly/index.ts new file mode 100644 index 0000000..9dd7d02 --- /dev/null +++ b/packages/flint-js/src/plotly/index.ts @@ -0,0 +1,34 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/plotly + * + * Plotly backend for flint-chart. + * + * Compiles the core semantic layer into Plotly figure objects. + * Contains PL-specific assembly, spec instantiation, and chart templates. + * + * Architecture contrast with other backends: + * VL: encoding-channel-based — { encoding: { x: { field, type }, y: ... } } + * EC: series-based — { series: [{ type, data }], xAxis, yAxis } + * CJS: dataset-based — { type, data: { labels, datasets[] }, options } + * PL: trace-based — { data: [{ type, x, y }], layout } + * + * Same core pipeline (Phase 0 + Phase 1), different Phase 2 output. + * Figures are pure JSON (no callback functions). + */ + +// PL assembly function +export { assemblePlotly } from './assemble'; + +// PL spec instantiation (Phase 2) +export { plApplyLayoutToSpec, plApplyTooltips } from './instantiate-spec'; + +// PL template registry +export { + plTemplateDefs, + plAllTemplateDefs, + plGetTemplateDef, + plGetTemplateChannels, +} from './templates'; diff --git a/packages/flint-js/src/plotly/instantiate-spec.ts b/packages/flint-js/src/plotly/instantiate-spec.ts new file mode 100644 index 0000000..fe2f0ea --- /dev/null +++ b/packages/flint-js/src/plotly/instantiate-spec.ts @@ -0,0 +1,124 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * ============================================================================= + * PHASE 2: INSTANTIATE SPEC — Plotly backend + * ============================================================================= + * + * Translates semantic decisions (Phase 0) and layout dimensions (Phase 1) + * into Plotly-specific figure properties. + * + * Key differences from the other backends: + * - PL figures are `{ data: traces[], layout }` and stay pure JSON — axis + * tick formatting uses declarative `tickformat`/axis types, never + * callback functions + * - PL sizing via `layout.width` / `layout.height` + * - PL label rotation via `layout.xaxis.tickangle` + * + * PL dependency: **Yes — this is where Plotly-specific syntax lives** + * ============================================================================= + */ + +import type { + InstantiateContext, + ChartWarning, +} from '../core/types'; + +/** + * Phase 2: Apply layout and semantic decisions to the Plotly figure. + * + * Handles common Plotly plumbing across all templates: + * - Figure sizing (_width, _height + layout.width/height) + * - Axis label rotation and font sizing + * - Overflow truncation warnings + */ +export function plApplyLayoutToSpec( + figure: any, + context: InstantiateContext, + warnings: ChartWarning[], +): void { + const { layout, canvasSize } = context; + + if (!figure.layout) figure.layout = {}; + + // ── Figure dimensions ──────────────────────────────────────────────── + if (!figure._width) { + const PADDING = 80; // approximate space for axes, labels + + const xIsDiscrete = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0; + const yIsDiscrete = layout.yNominalCount > 0 || layout.yContinuousAsDiscrete > 0; + + let plotWidth: number; + let plotHeight: number; + + if (xIsDiscrete && layout.xStepUnit !== 'group') { + const xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0; + plotWidth = xItemCount > 0 ? layout.xStep * xItemCount : (layout.subplotWidth || canvasSize.width); + } else { + plotWidth = layout.subplotWidth || canvasSize.width; + } + + if (yIsDiscrete && layout.yStepUnit !== 'group') { + const yItemCount = layout.yNominalCount || layout.yContinuousAsDiscrete || 0; + plotHeight = yItemCount > 0 ? layout.yStep * yItemCount : (layout.subplotHeight || canvasSize.height); + } else { + plotHeight = layout.subplotHeight || canvasSize.height; + } + + const legendGutter = figure.layout.showlegend ? 96 : 0; + figure._width = plotWidth + PADDING + legendGutter; + figure._height = plotHeight + PADDING; + } + + figure.layout.width = figure._width; + figure.layout.height = figure._height; + figure.layout.margin = figure.layout.margin ?? { t: 24 }; + + // ── X-axis label rotation and font sizing ──────────────────────────── + if (layout.xLabel) { + if (!figure.layout.xaxis) figure.layout.xaxis = {}; + if (layout.xLabel.labelAngle && layout.xLabel.labelAngle !== 0) { + figure.layout.xaxis.tickangle = Math.abs(layout.xLabel.labelAngle); + } + if (layout.xLabel.fontSize) { + figure.layout.xaxis.tickfont = { + ...(figure.layout.xaxis.tickfont || {}), + size: layout.xLabel.fontSize, + }; + } + } + + // ── Y-axis label font sizing ───────────────────────────────────────── + if (layout.yLabel?.fontSize) { + if (!figure.layout.yaxis) figure.layout.yaxis = {}; + figure.layout.yaxis.tickfont = { + ...(figure.layout.yaxis.tickfont || {}), + size: layout.yLabel.fontSize, + }; + } + + // ── Overflow truncation warnings ───────────────────────────────────── + if (layout.truncations && layout.truncations.length > 0) { + for (const trunc of layout.truncations) { + warnings.push({ + severity: 'warning', + code: 'overflow', + message: trunc.message, + channel: trunc.channel, + field: trunc.field, + }); + } + } +} + +/** + * Apply tooltips to a Plotly figure. Plotly hover is on by default; this + * pins an explicit unified hover mode so tooltips read across series. + */ +export function plApplyTooltips(figure: any): void { + if (!figure.layout) figure.layout = {}; + if (figure.layout.hovermode == null) { + figure.layout.hovermode = 'closest'; + } +} diff --git a/packages/flint-js/src/plotly/templates/area.ts b/packages/flint-js/src/plotly/templates/area.ts new file mode 100644 index 0000000..2474ad9 --- /dev/null +++ b/packages/flint-js/src/plotly/templates/area.ts @@ -0,0 +1,142 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly Area Chart template (single + multi-series). + * + * Mirrors the Chart.js Area template's decisions. Plotly renders areas as + * scatter traces with `fill`; multi-series stacking uses `stackgroup` + * (the Plotly-native equivalent of Chart.js `fill: 'stack'`). + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + extractCategories, + groupBy, + buildCategoryAlignedData, + coerceIsoDateForPlotly, + getPlotlyPalette, + getSeriesColor, +} from './utils'; + +const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; + +/** 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()); + if (!m) return hex; + const v = parseInt(m[1], 16); + const a = Math.max(0, Math.min(1, alpha)); + return `rgba(${(v >> 16) & 255}, ${(v >> 8) & 255}, ${v & 255}, ${a})`; +} + +export const plAreaChartDef: ChartTemplateDef = { + chart: 'Area Chart', + template: { mark: 'area', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + markCognitiveChannel: 'area', + declareLayoutMode: () => ({ + paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, + }), + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + + if (!xCS?.field || !yCS?.field) return; + const xField = xCS.field; + const yField = yCS.field; + + const xIsDiscrete = isDiscrete(xCS.type); + const xIsTemporal = xCS.type === 'temporal'; + const mapX = (raw: unknown) => (xIsTemporal ? coerceIsoDateForPlotly(raw) : raw); + + const categories = xIsDiscrete + ? extractCategories(table, xField, xCS.ordinalSortOrder) + : undefined; + + const opacity = Number(chartProperties?.opacity ?? 0.4); + const stackMode = chartProperties?.stackMode; + const stacked = stackMode !== 'layered'; + const smooth = chartProperties?.interpolate === 'monotone'; + + const palette = getPlotlyPalette(ctx, 'color'); + const traces: any[] = []; + const makeTrace = (name: string, rows: any[], colorIndex: number) => { + const xVals = xIsDiscrete ? categories! : rows.map(r => mapX(r[xField])); + const yVals = xIsDiscrete + ? buildCategoryAlignedData(rows, xField, yField, categories!) + : rows.map(r => (r[yField] == null ? null : r[yField])); + const color = getSeriesColor(palette, colorIndex); + const trace: any = { + type: 'scatter', + mode: 'lines', + name, + x: xVals, + y: yVals, + line: { color, shape: smooth ? 'spline' : 'linear' }, + fillcolor: fillColor(color, opacity), + }; + if (colorField && stacked) { + trace.stackgroup = 'one'; + } else { + trace.fill = 'tozeroy'; + } + return trace; + }; + + if (colorField) { + let i = 0; + for (const [name, rows] of groupBy(table, colorField)) { + traces.push(makeTrace(name, rows, i)); + i++; + } + } else { + traces.push(makeTrace(yField, table, 0)); + } + + const xAxisSpec: any = { title: { text: xField } }; + if (xIsDiscrete) { + xAxisSpec.type = 'category'; + xAxisSpec.categoryorder = 'array'; + xAxisSpec.categoryarray = categories; + } else if (xIsTemporal) { + xAxisSpec.type = 'date'; + } + + const yAxisSpec: any = { title: { text: yField } }; + if (yCS.zero) { + yAxisSpec.rangemode = yCS.zero.zero !== false ? 'tozero' : 'normal'; + } + + const figure: any = { + data: traces, + layout: { + xaxis: xAxisSpec, + yaxis: yAxisSpec, + showlegend: !!colorField, + }, + }; + + Object.assign(spec, figure); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'interpolate', label: 'Curve', type: 'discrete', options: [ + { value: undefined, label: 'Default (linear)' }, + { value: 'linear', label: 'Linear' }, + { value: 'monotone', label: 'Monotone (smooth)' }, + ], + } as ChartPropertyDef, + { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 0.4 } as ChartPropertyDef, + { + key: 'stackMode', label: 'Stack', type: 'discrete', options: [ + { value: undefined, label: 'Stacked (default)' }, + { value: 'layered', label: 'Layered (overlap)' }, + ], + } as ChartPropertyDef, + ], +}; diff --git a/packages/flint-js/src/plotly/templates/bar.ts b/packages/flint-js/src/plotly/templates/bar.ts new file mode 100644 index 0000000..8950c86 --- /dev/null +++ b/packages/flint-js/src/plotly/templates/bar.ts @@ -0,0 +1,81 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly Bar Chart template. + * + * Mirrors the Chart.js Bar template's decisions (category detection, + * ordinal sort order, zero baseline, horizontal transposition), expressed + * as a Plotly `bar` trace: + * CJS: { type: 'bar', data: { labels, datasets[] }, options: { indexAxis } } + * PL: { data: [{ type: 'bar', x, y, orientation }], layout: { xaxis, yaxis } } + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { extractCategories, buildCategoryAlignedData, detectAxes, getPlotlyPalette, getSeriesColor } from './utils'; +import { detectBandedAxisFromSemantics } from '../../core/axis-detection'; + +export const plBarChartDef: ChartTemplateDef = { + chart: 'Bar Chart', + template: { mark: 'bar', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + markCognitiveChannel: 'length', + declareLayoutMode: (cs, table) => { + const result = detectBandedAxisFromSemantics(cs, table, { preferAxis: 'x' }); + return { + axisFlags: result ? { [result.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: result?.resolvedTypes, + }; + }, + instantiate: (spec, ctx) => { + const { channelSemantics, table } = ctx; + const { categoryAxis, valueAxis } = detectAxes(channelSemantics); + + const catField = channelSemantics[categoryAxis]?.field; + const valField = channelSemantics[valueAxis]?.field; + if (!catField || !valField) return; + + const catCS = channelSemantics[categoryAxis]; + const categories = extractCategories(table, catField, catCS?.ordinalSortOrder); + const values = buildCategoryAlignedData(table, catField, valField, categories); + + const isHorizontal = categoryAxis === 'y'; + const palette = getPlotlyPalette(ctx); + + const catAxisSpec = { + type: 'category' as const, + categoryorder: 'array' as const, + categoryarray: categories, + title: { text: catField }, + }; + const valCS = channelSemantics[valueAxis]; + // Bars encode length — include zero unless the semantic decision says otherwise. + const includeZero = valCS?.zero ? valCS.zero.zero !== false : true; + const valAxisSpec = { + title: { text: valField }, + rangemode: (includeZero ? 'tozero' : 'normal') as 'tozero' | 'normal', + }; + + const figure: any = { + data: [{ + type: 'bar', + name: valField, + ...(isHorizontal + ? { x: values, y: categories, orientation: 'h' } + : { x: categories, y: values }), + marker: { color: getSeriesColor(palette, 0) }, + }], + layout: { + ...(isHorizontal + ? { xaxis: valAxisSpec, yaxis: catAxisSpec } + : { xaxis: catAxisSpec, yaxis: valAxisSpec }), + showlegend: false, + }, + }; + + Object.assign(spec, figure); + delete spec.mark; + delete spec.encoding; + }, + properties: [] as ChartPropertyDef[], +}; diff --git a/packages/flint-js/src/plotly/templates/index.ts b/packages/flint-js/src/plotly/templates/index.ts new file mode 100644 index 0000000..af6c2e5 --- /dev/null +++ b/packages/flint-js/src/plotly/templates/index.ts @@ -0,0 +1,46 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly template registry. + * + * Mirrors the structure of chartjs/templates/index.ts, echarts/templates/index.ts + * and vegalite/templates/index.ts but with Plotly template definitions. + * + * First-merge scope (see docs/adding-a-backend.md §6): the four acceptance + * templates. Further templates are follow-ups. + */ + +import { ChartTemplateDef } from '../../core/types'; +import { plBarChartDef } from './bar'; +import { plLineChartDef } from './line'; +import { plAreaChartDef } from './area'; +import { plScatterPlotDef } from './scatter'; + +/** + * Plotly chart template definitions, grouped by category. + */ +export const plTemplateDefs: { [key: string]: ChartTemplateDef[] } = { + 'Scatter & Point': [plScatterPlotDef], + 'Bar': [plBarChartDef], + 'Line & Area': [plLineChartDef, plAreaChartDef], +}; + +/** + * Flat list of all Plotly chart template definitions. + */ +export const plAllTemplateDefs: ChartTemplateDef[] = Object.values(plTemplateDefs).flat(); + +/** + * Look up a Plotly chart template definition by chart type name. + */ +export function plGetTemplateDef(chartType: string): ChartTemplateDef | undefined { + return plAllTemplateDefs.find(t => t.chart === chartType); +} + +/** + * Get the available channels for a Plotly chart type. + */ +export function plGetTemplateChannels(chartType: string): string[] { + return plGetTemplateDef(chartType)?.channels || []; +} diff --git a/packages/flint-js/src/plotly/templates/line.ts b/packages/flint-js/src/plotly/templates/line.ts new file mode 100644 index 0000000..4109a7b --- /dev/null +++ b/packages/flint-js/src/plotly/templates/line.ts @@ -0,0 +1,144 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly Line Chart template (single + multi-series). + * + * Mirrors the Chart.js Line template's decisions. Plotly differences: + * - temporal x uses Plotly's native `date` axis (ISO strings), no tick + * callback needed — figures stay pure JSON + * - one trace per series; the legend comes from trace `name`s + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { + extractCategories, + groupBy, + buildCategoryAlignedData, + coerceIsoDateForPlotly, + getPlotlyPalette, + getSeriesColor, +} from './utils'; + +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'; + } +} + +export const plLineChartDef: ChartTemplateDef = { + chart: 'Line Chart', + template: { mark: 'line', encoding: {} }, + channels: ['x', 'y', 'color', 'opacity', 'column', 'row'], + markCognitiveChannel: 'position', + declareLayoutMode: () => ({ + paramOverrides: { continuousMarkCrossSection: { x: 100, y: 20, seriesCountAxis: 'auto' }, facetAspectRatioResistance: 0.5 }, + }), + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + const colorField = channelSemantics.color?.field; + + if (!xCS?.field || !yCS?.field) return; + const xField = xCS.field; + const yField = yCS.field; + + const xIsDiscrete = isDiscrete(xCS.type); + const xIsTemporal = xCS.type === 'temporal'; + + const mapX = (raw: unknown) => (xIsTemporal ? coerceIsoDateForPlotly(raw) : raw); + + const categories = xIsDiscrete + ? extractCategories(table, xField, xCS.ordinalSortOrder) + : undefined; + + const shape = lineShape(chartProperties?.interpolate); + const showPoints = chartProperties?.showPoints === true; + const mode = showPoints ? 'lines+markers' : 'lines'; + + const palette = getPlotlyPalette(ctx, 'color'); + const traces: any[] = []; + const makeTrace = (name: string, rows: any[], colorIndex: number) => { + const xVals = xIsDiscrete + ? categories! + : rows.map(r => mapX(r[xField])); + const yVals = xIsDiscrete + ? buildCategoryAlignedData(rows, xField, yField, categories!) + : rows.map(r => (r[yField] == null ? null : r[yField])); + return { + type: 'scatter', + mode, + name, + x: xVals, + y: yVals, + line: { color: getSeriesColor(palette, colorIndex), shape }, + }; + }; + + if (colorField) { + let i = 0; + for (const [name, rows] of groupBy(table, colorField)) { + traces.push(makeTrace(name, rows, i)); + i++; + } + } else { + traces.push(makeTrace(yField, table, 0)); + } + + const xAxisSpec: any = { title: { text: xField } }; + if (xIsDiscrete) { + xAxisSpec.type = 'category'; + xAxisSpec.categoryorder = 'array'; + xAxisSpec.categoryarray = categories; + } else if (xIsTemporal) { + xAxisSpec.type = 'date'; + } + + const yAxisSpec: any = { title: { text: yField } }; + if (yCS.zero) { + yAxisSpec.rangemode = yCS.zero.zero !== false ? 'tozero' : 'normal'; + } + + const figure: any = { + data: traces, + layout: { + xaxis: xAxisSpec, + yaxis: yAxisSpec, + showlegend: !!colorField, + }, + }; + + Object.assign(spec, figure); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { + key: 'interpolate', label: 'Curve', type: 'discrete', options: [ + { 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' }, + ], + } as ChartPropertyDef, + { key: 'showPoints', label: 'Show points', type: 'binary', defaultValue: false } as ChartPropertyDef, + ], +}; diff --git a/packages/flint-js/src/plotly/templates/scatter.ts b/packages/flint-js/src/plotly/templates/scatter.ts new file mode 100644 index 0000000..7bf0fba --- /dev/null +++ b/packages/flint-js/src/plotly/templates/scatter.ts @@ -0,0 +1,100 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly Scatter Plot template. + * + * Mirrors the Chart.js Scatter template's decisions (color grouping, zero + * baseline per axis, canvas-aware point radius) as Plotly `scatter` traces + * in `markers` mode. + */ + +import { ChartTemplateDef, ChartPropertyDef } from '../../core/types'; +import { groupBy, getPlotlyPalette, getSeriesColor } from './utils'; + +/** Compute a reasonable marker diameter based on canvas area and point count. */ +function computeMarkerSize(width: number, height: number, pointCount: number): number { + const canvasArea = width * height; + const areaPerPoint = canvasArea / Math.max(1, pointCount); + const idealRadius = Math.sqrt(areaPerPoint * 0.05) / 2; + const radius = Math.max(2, Math.min(6, Math.round(idealRadius))); + return radius * 2; +} + +export const plScatterPlotDef: ChartTemplateDef = { + chart: 'Scatter Plot', + template: { mark: 'circle', encoding: {} }, + channels: ['x', 'y', 'color', 'size', 'opacity', 'column', 'row'], + markCognitiveChannel: 'position', + instantiate: (spec, ctx) => { + const { channelSemantics, table, chartProperties } = ctx; + const xField = channelSemantics.x?.field; + const yField = channelSemantics.y?.field; + const colorField = channelSemantics.color?.field; + + if (!xField || !yField) return; + + const opacity = Number(chartProperties?.opacity ?? 1); + + const palette = getPlotlyPalette(ctx, 'color'); + const traces: any[] = []; + const makeTrace = (name: string | undefined, rows: any[], colorIndex: number) => ({ + type: 'scatter', + mode: 'markers', + ...(name != null ? { name } : {}), + x: rows.map(r => r[xField]), + y: rows.map(r => r[yField]), + marker: { + color: getSeriesColor(palette, colorIndex), + opacity, + line: { color: '#ffffff', width: 0.5 }, + }, + }); + + if (colorField) { + let i = 0; + for (const [name, rows] of groupBy(table, colorField)) { + traces.push(makeTrace(name, rows, i)); + i++; + } + } else { + traces.push(makeTrace(undefined, table, 0)); + } + + const xAxisSpec: any = { title: { text: xField } }; + const yAxisSpec: any = { title: { text: yField } }; + if (channelSemantics.x?.zero) { + xAxisSpec.rangemode = channelSemantics.x.zero.zero !== false ? 'tozero' : 'normal'; + } + if (channelSemantics.y?.zero) { + yAxisSpec.rangemode = channelSemantics.y.zero.zero !== false ? 'tozero' : 'normal'; + } + + const figure: any = { + data: traces, + layout: { + xaxis: xAxisSpec, + yaxis: yAxisSpec, + showlegend: !!colorField, + }, + }; + + Object.assign(spec, figure); + delete spec.mark; + delete spec.encoding; + }, + properties: [ + { key: 'opacity', label: 'Opacity', type: 'continuous', min: 0.1, max: 1, step: 0.05, defaultValue: 1 } as ChartPropertyDef, + ], + postProcess: (figure, ctx) => { + if (!Array.isArray(figure.data)) return; + const w = figure._width || ctx.canvasSize.width; + const h = figure._height || ctx.canvasSize.height; + const size = computeMarkerSize(w, h, ctx.table.length); + for (const trace of figure.data) { + if (trace?.marker && trace.marker.size == null) { + trace.marker.size = size; + } + } + }, +}; diff --git a/packages/flint-js/src/plotly/templates/utils.ts b/packages/flint-js/src/plotly/templates/utils.ts new file mode 100644 index 0000000..65bb7ba --- /dev/null +++ b/packages/flint-js/src/plotly/templates/utils.ts @@ -0,0 +1,160 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Shared helper functions for Plotly template hooks. + * Pure logic — no UI dependencies. + */ + +import type { ChannelSemantics, InstantiateContext } from '../../core/types'; +import { pickPlotlyPalette } from '../colormap'; + +const isDiscrete = (type: string | undefined) => type === 'nominal' || type === 'ordinal'; + +/** + * Extract unique category values from data for a given field, preserving order. + * If `ordinalSortOrder` is provided, returns values sorted in that canonical order. + */ +export function extractCategories(data: any[], field: string, ordinalSortOrder?: string[]): string[] { + const seen = new Set(); + const result: string[] = []; + for (const row of data) { + const val = row[field]; + if (val != null) { + const key = String(val); + if (!seen.has(key)) { + seen.add(key); + result.push(key); + } + } + } + + if (ordinalSortOrder && ordinalSortOrder.length > 0) { + const orderMap = new Map(ordinalSortOrder.map((v, i) => [v, i])); + result.sort((a, b) => { + const ia = orderMap.get(a); + const ib = orderMap.get(b); + if (ia !== undefined && ib !== undefined) return ia - ib; + if (ia !== undefined) return -1; + if (ib !== undefined) return 1; + return 0; + }); + } + + return result; +} + +/** + * Group data by a categorical field. + * Returns a map: seriesName → rows[]. + */ +export function groupBy(data: any[], field: string): Map { + const groups = new Map(); + for (const row of data) { + const key = String(row[field] ?? ''); + if (!groups.has(key)) groups.set(key, []); + groups.get(key)!.push(row); + } + return groups; +} + +/** + * Build category-aligned value array for a subset of rows. + * Returns values indexed by category position (null for missing). + */ +export function buildCategoryAlignedData( + rows: any[], + catField: string, + valField: string, + categories: string[], +): (number | null)[] { + const map = new Map(); + for (const row of rows) { + const key = String(row[catField] ?? ''); + const val = row[valField]; + if (val != null && !isNaN(val)) { + map.set(key, (map.get(key) ?? 0) + Number(val)); + } + } + return categories.map(cat => map.get(cat) ?? null); +} + +/** + * Detect which axis is the category (banded) axis and which is the value axis. + */ +export function detectAxes( + channelSemantics: Record, +): { categoryAxis: 'x' | 'y'; valueAxis: 'x' | 'y' } { + const xCS = channelSemantics.x; + const yCS = channelSemantics.y; + + if (xCS && isDiscrete(xCS.type)) { + return { categoryAxis: 'x', valueAxis: 'y' }; + } + if (yCS && isDiscrete(yCS.type)) { + return { categoryAxis: 'y', valueAxis: 'x' }; + } + return { categoryAxis: 'x', valueAxis: 'y' }; +} + +/** + * Coerce a temporal value to an ISO-8601 string for Plotly's native `date` + * axis. Numbers below 1e12 are treated as Unix seconds; strings and Dates are + * parsed directly. Returns null when unparseable. + */ +export function coerceIsoDateForPlotly(raw: unknown): string | null { + if (raw == null) return null; + let ms: number; + if (typeof raw === 'number' && Number.isFinite(raw)) { + ms = raw < 1e12 ? Math.round(raw * 1000) : raw; + } else if (raw instanceof Date) { + ms = raw.getTime(); + } else { + ms = new Date(String(raw)).getTime(); + } + return Number.isFinite(ms) ? new Date(ms).toISOString() : null; +} + +/** + * Plotly's default qualitative palette (plotly.js `layout.colorway` defaults). + * Fallback when no color decision is available; the decision-aware path lives + * in ../colormap.ts (mirroring chartjs/colormap.ts). + */ +export const PLOTLY_COLORS = [ + '#636efa', // blue-violet + '#EF553B', // red-orange + '#00cc96', // green + '#ab63fa', // purple + '#FFA15A', // orange + '#19d3f3', // cyan + '#FF6692', // pink + '#B6E880', // light green + '#FF97FF', // magenta + '#FECB52', // yellow +]; + +/** + * 从 color-decisions 解析调色板;若没有决策则回退到 Plotly 默认 plotly10。 + * (Mirror of chartjs/templates/utils.ts getChartJsPalette.) + */ +export function getPlotlyPalette(ctx: InstantiateContext, preferred: 'color' | 'group' = 'color'): string[] { + const decisions = ctx.colorDecisions; + const decision = + preferred === 'color' + ? decisions?.color ?? decisions?.group + : decisions?.group ?? decisions?.color; + + const palette = pickPlotlyPalette(decision); + if (palette.length > 0) { + return palette; + } + return PLOTLY_COLORS; +} + +/** Series color by index from a resolved palette. */ +export function getSeriesColor(palette: string[], index: number): string { + if (!palette.length) { + return PLOTLY_COLORS[index % PLOTLY_COLORS.length]; + } + return palette[index % palette.length]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index abd5fea..c02d46d 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -39,6 +39,7 @@ export { genGasPressureTests } from './gas-pressure-tests'; export { genLineAreaStretchTests } from './line-area-stretch-tests'; export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; +export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; export { genDiscreteAxisTests } from './discrete-axis-tests'; export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests'; export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; @@ -112,6 +113,7 @@ import { genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDeca import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; +import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; import { genGalleryRegionalSurveyScatterTests, genGalleryRegionalSurveyLineTests, @@ -248,6 +250,8 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Chart.js: Doughnut *': genChartJsDoughnutTests, 'Chart.js: Combo *': genChartJsComboTests, 'Chart.js: Stress Tests': genChartJsStressTests, + 'Plotly: Core Templates': genPlotlyCoreTests, + 'Plotly: Facets': genPlotlyFacetTests, 'Gallery: Scatter': genGalleryRegionalSurveyScatterTests, 'Gallery: Line': genGalleryRegionalSurveyLineTests, 'Gallery: Bar': genGalleryRegionalSurveyBarTests, diff --git a/packages/flint-js/src/test-data/plotly-tests.ts b/packages/flint-js/src/test-data/plotly-tests.ts new file mode 100644 index 0000000..c58baa0 --- /dev/null +++ b/packages/flint-js/src/test-data/plotly-tests.ts @@ -0,0 +1,244 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Plotly backend tests. + * + * Exercises the Plotly backend's first-merge surface: the four acceptance + * templates (Bar, Line, Area, Scatter) with color on/off, and the facet + * grid path (column, row, column×row) that renders as Plotly subplot pairs. + */ + +import { Type } from './df-types'; +import { TestCase, makeField, makeEncodingItem } from './types'; +import { seededRandom } from './generators'; + +// --------------------------------------------------------------------------- +// Test data generators +// --------------------------------------------------------------------------- + +function genRegionSales(seed: number) { + const rand = seededRandom(seed); + const regions = ['East', 'South', 'North', 'West', 'Central']; + return regions.map(r => ({ + Region: r, + Revenue: Math.round(100 + rand() * 900), + })); +} + +function genMonthlySeries(seed: number, series: string[]) { + const rand = seededRandom(seed); + const months = ['Jan', 'Feb', 'Mar', 'Apr', 'May', 'Jun', 'Jul', 'Aug']; + const data: any[] = []; + for (const m of months) { + for (const s of series) { + data.push({ + Month: m, + Value: Math.round(200 + rand() * 800), + Series: s, + }); + } + } + return data; +} + +function genScatterGroups(n: number, seed: number) { + const rand = seededRandom(seed); + const groups = ['Alpha', 'Beta', 'Gamma']; + return Array.from({ length: n }, (_, i) => ({ + X: Math.round(rand() * 100 * 10) / 10, + Y: Math.round(rand() * 100 * 10) / 10, + Group: groups[i % groups.length], + })); +} + +function genQuarterRegion(seed: number) { + const rand = seededRandom(seed); + const quarters = ['Q1', 'Q2', 'Q3', 'Q4']; + const regions = ['North', 'South']; + const data: any[] = []; + for (const q of quarters) { + for (const r of regions) { + data.push({ + Quarter: q, + Revenue: Math.round(200 + rand() * 800), + Region: r, + }); + } + } + return data; +} + +// --------------------------------------------------------------------------- +// Test case builders +// --------------------------------------------------------------------------- + +/** The four acceptance templates, single-series and color-grouped. */ +export function genPlotlyCoreTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genRegionSales(42); + tests.push({ + title: 'PL: Bar — Basic N×Q', + description: 'Single bar trace; category order preserved, zero baseline.', + tags: ['plotly', 'bar'], + chartType: 'Bar Chart', + data, + fields: [makeField('Region'), makeField('Revenue')], + metadata: { + Region: { type: Type.String, semanticType: 'Region', levels: [] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Region'), y: makeEncodingItem('Revenue') }, + }); + } + + { + const data = genRegionSales(43); + tests.push({ + title: 'PL: Bar — Horizontal', + description: 'Category on y → orientation "h" bar trace.', + tags: ['plotly', 'bar', 'horizontal'], + chartType: 'Bar Chart', + data, + fields: [makeField('Region'), makeField('Revenue')], + metadata: { + Region: { type: Type.String, semanticType: 'Region', levels: [] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Revenue'), y: makeEncodingItem('Region') }, + }); + } + + { + const data = genMonthlySeries(77, ['ProductA', 'ProductB', 'ProductC']); + tests.push({ + title: 'PL: Line — Color Groups', + description: 'One trace per series; legend from trace names.', + tags: ['plotly', 'line', 'color', 'multi-series'], + chartType: 'Line Chart', + data, + fields: [makeField('Month'), makeField('Value'), makeField('Series')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: [] }, + Value: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Series: { type: Type.String, semanticType: 'Category', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Value'), color: makeEncodingItem('Series') }, + }); + } + + { + const data = genMonthlySeries(88, ['Web', 'Mobile']); + tests.push({ + title: 'PL: Area — Stacked', + description: 'Color groups stack via stackgroup (Plotly-native stacking).', + tags: ['plotly', 'area', 'color', 'stacked'], + chartType: 'Area Chart', + data, + fields: [makeField('Month'), makeField('Value'), makeField('Series')], + metadata: { + Month: { type: Type.String, semanticType: 'Month', levels: [] }, + Value: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Series: { type: Type.String, semanticType: 'Category', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Value'), color: makeEncodingItem('Series') }, + }); + } + + { + const data = genScatterGroups(90, 99); + tests.push({ + title: 'PL: Scatter — Color Groups', + description: 'Markers mode, one trace per group, qualitative palette.', + tags: ['plotly', 'scatter', 'color', 'multi-series'], + chartType: 'Scatter Plot', + data, + fields: [makeField('X'), makeField('Y'), makeField('Group')], + metadata: { + X: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Y: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Group: { type: Type.String, semanticType: 'Category', levels: ['Alpha', 'Beta', 'Gamma'] }, + }, + encodingMap: { x: makeEncodingItem('X'), y: makeEncodingItem('Y'), color: makeEncodingItem('Group') }, + }); + } + + return tests; +} + +/** Facet grid path: column, row, and column×row subplot grids. */ +export function genPlotlyFacetTests(): TestCase[] { + const tests: TestCase[] = []; + + { + const data = genQuarterRegion(11); + tests.push({ + title: 'PL: Facet — Column', + description: 'Bar per Region column; shared nice y-domain, leftmost-only y labels.', + tags: ['plotly', 'facet', 'column', 'bar'], + chartType: 'Bar Chart', + data, + fields: [makeField('Quarter'), makeField('Revenue'), makeField('Region')], + metadata: { + Quarter: { type: Type.String, semanticType: 'Quarter', levels: [] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Region: { type: Type.String, semanticType: 'Region', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Quarter'), y: makeEncodingItem('Revenue'), column: makeEncodingItem('Region') }, + }); + } + + { + const data = genQuarterRegion(22); + tests.push({ + title: 'PL: Facet — Row', + description: 'Line per Region row; rotated row headers, x title on bottom row only.', + tags: ['plotly', 'facet', 'row', 'line'], + chartType: 'Line Chart', + data, + fields: [makeField('Quarter'), makeField('Revenue'), makeField('Region')], + metadata: { + Quarter: { type: Type.String, semanticType: 'Quarter', levels: [] }, + Revenue: { type: Type.Number, semanticType: 'Amount', levels: [] }, + Region: { type: Type.String, semanticType: 'Region', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('Quarter'), y: makeEncodingItem('Revenue'), row: makeEncodingItem('Region') }, + }); + } + + { + const rand = seededRandom(33); + const data: any[] = []; + for (const a of ['A', 'B']) { + for (const b of ['X', 'Y']) { + for (let i = 0; i < 12; i++) { + data.push({ + V: Math.round(rand() * 100), + W: Math.round(rand() * 100), + Col: a, + Row: b, + }); + } + } + } + tests.push({ + title: 'PL: Facet — Column × Row Grid', + description: 'Scatter 2×2 grid; one subplot axis pair per cell.', + tags: ['plotly', 'facet', 'grid', 'scatter'], + chartType: 'Scatter Plot', + data, + fields: [makeField('V'), makeField('W'), makeField('Col'), makeField('Row')], + metadata: { + V: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + W: { type: Type.Number, semanticType: 'Quantity', levels: [] }, + Col: { type: Type.String, semanticType: 'Category', levels: [] }, + Row: { type: Type.String, semanticType: 'Category', levels: [] }, + }, + encodingMap: { x: makeEncodingItem('V'), y: makeEncodingItem('W'), column: makeEncodingItem('Col'), row: makeEncodingItem('Row') }, + }); + } + + return tests; +} diff --git a/packages/flint-js/tests/plotly-backend.test.ts b/packages/flint-js/tests/plotly-backend.test.ts new file mode 100644 index 0000000..324a51f --- /dev/null +++ b/packages/flint-js/tests/plotly-backend.test.ts @@ -0,0 +1,206 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, it, expect } from 'vitest'; +import { assemblePlotly, plGetTemplateDef, plAllTemplateDefs } from '../src'; +import { genPlotlyCoreTests, genPlotlyFacetTests } from '../src/test-data'; + +const SALES = [ + { region: 'East', revenue: 168 }, + { region: 'South', revenue: 167 }, + { region: 'North', revenue: 145 }, +]; + +const GROUPED = [ + { region: 'East', revenue: 168, year: '2024' }, + { region: 'South', revenue: 167, year: '2024' }, + { region: 'East', revenue: 120, year: '2025' }, + { region: 'South', revenue: 131, year: '2025' }, +]; + +const CARS = [ + { weight: 1.6, mpg: 32, origin: 'JP' }, + { weight: 2.1, mpg: 27, origin: 'US' }, + { weight: 1.9, mpg: 29, origin: 'EU' }, +]; + +const MONTHLY = [ + { month: '2025-01', value: 12 }, + { month: '2025-02', value: 18 }, + { month: '2025-03', value: 15 }, +]; + +function input(chartType: string, encodings: Record, values: any[], semantic_types: Record, chartProperties?: Record) { + return { + data: { values }, + semantic_types, + chart_spec: { chartType, encodings, baseSize: { width: 400, height: 300 }, ...(chartProperties ? { chartProperties } : {}) }, + } as any; +} + +/** Recursively assert a value contains no functions (pure JSON). */ +function assertNoFunctions(node: any, path = '$'): void { + if (typeof node === 'function') { + throw new Error(`function found at ${path}`); + } + if (node && typeof node === 'object') { + for (const [k, v] of Object.entries(node)) { + assertNoFunctions(v, `${path}.${k}`); + } + } +} + +describe('Plotly backend', () => { + it('registers exactly the four acceptance templates', () => { + expect(plAllTemplateDefs.map(t => t.chart).sort()).toEqual( + ['Area Chart', 'Bar Chart', 'Line Chart', 'Scatter Plot'], + ); + expect(plGetTemplateDef('Bar Chart')).toBeDefined(); + }); + + it('throws on an unregistered chart type', () => { + expect(() => + assemblePlotly(input('Heatmap', { x: { field: 'region' }, y: { field: 'revenue' } }, SALES, { region: 'Region', revenue: 'Amount' })), + ).toThrow(/Unknown Plotly chart type/); + }); + + it('bar: builds a bar trace with category order and zero baseline', () => { + const fig = assemblePlotly(input('Bar Chart', { x: { field: 'region' }, y: { field: 'revenue' } }, SALES, { region: 'Region', revenue: 'Amount' })); + expect(fig.data).toHaveLength(1); + expect(fig.data[0].type).toBe('bar'); + expect(fig.data[0].x).toEqual(['East', 'South', 'North']); + expect(fig.data[0].y).toEqual([168, 167, 145]); + expect(fig.layout.xaxis.type).toBe('category'); + expect(fig.layout.yaxis.rangemode).toBe('tozero'); + expect(fig.layout.width).toBeGreaterThan(0); + expect(fig.layout.height).toBeGreaterThan(0); + }); + + it('bar: transposes to horizontal when the category is on y', () => { + const fig = assemblePlotly(input('Bar Chart', { x: { field: 'revenue' }, y: { field: 'region' } }, SALES, { region: 'Region', revenue: 'Amount' })); + expect(fig.data[0].orientation).toBe('h'); + expect(fig.data[0].y).toEqual(['East', 'South', 'North']); + expect(fig.layout.xaxis.rangemode).toBe('tozero'); + }); + + it('line: one trace per color group with legend on', () => { + const fig = assemblePlotly(input('Line Chart', { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })); + expect(fig.data).toHaveLength(2); + expect(fig.data.map((t: any) => t.name)).toEqual(['2024', '2025']); + expect(fig.data[0].mode).toBe('lines'); + expect(fig.layout.showlegend).toBe(true); + }); + + it('line: temporal x uses a native date axis with ISO values', () => { + const fig = assemblePlotly(input('Line Chart', { x: { field: 'month' }, y: { field: 'value' } }, MONTHLY, { month: 'YearMonth', value: 'Amount' })); + expect(fig.layout.xaxis.type).toBe('date'); + expect(typeof fig.data[0].x[0]).toBe('string'); + expect(fig.data[0].x[0]).toMatch(/^\d{4}-\d{2}-\d{2}T/); + }); + + it('area: stacked color groups use stackgroup; single series fills to zero', () => { + const grouped = assemblePlotly(input('Area Chart', { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })); + expect(grouped.data.every((t: any) => t.stackgroup === 'one')).toBe(true); + + const single = assemblePlotly(input('Area Chart', { x: { field: 'region' }, y: { field: 'revenue' } }, SALES, { region: 'Region', revenue: 'Amount' })); + expect(single.data[0].fill).toBe('tozeroy'); + }); + + it('scatter: markers mode, per-group traces, no forced zero for open measures', () => { + const fig = assemblePlotly(input('Scatter Plot', { x: { field: 'weight' }, y: { field: 'mpg' }, color: { field: 'origin' } }, CARS, { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' })); + expect(fig.data).toHaveLength(3); + expect(fig.data[0].mode).toBe('markers'); + expect(fig.data[0].marker.size).toBeGreaterThan(0); + }); + + it('column facet: one axis pair per panel, shared nice y-range, headers', () => { + const fig = assemblePlotly(input('Bar Chart', { x: { field: 'region' }, y: { field: 'revenue' }, column: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })); + expect(fig._facet).toBe(true); + expect(fig._facetCols).toBe(2); + expect(fig.layout.xaxis2).toBeDefined(); + expect(fig.layout.yaxis2).toBeDefined(); + expect(fig.layout.yaxis.range).toEqual(fig.layout.yaxis2.range); + expect(fig.layout.yaxis.range[0]).toBe(0); // Amount forces zero into the shared domain + expect(fig.layout.yaxis2.showticklabels).toBe(false); // leftmost-only y labels + const headerTexts = fig.layout.annotations.map((a: any) => a.text); + expect(headerTexts).toContain('2024'); + expect(headerTexts).toContain('2025'); + expect(fig.data.every((t: any) => t.xaxis && t.yaxis)).toBe(true); + }); + + it('row facet: stacked panels with rotated row headers', () => { + const fig = assemblePlotly(input('Line Chart', { x: { field: 'region' }, y: { field: 'revenue' }, row: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })); + expect(fig._facet).toBe(true); + expect(fig._facetRows).toBe(2); + expect(fig._facetCols).toBe(1); + const rowHeaders = fig.layout.annotations.filter((a: any) => a.textangle === 90); + expect(rowHeaders.map((a: any) => a.text)).toEqual(['2024', '2025']); + // x title only on the bottom row + expect(fig.layout.xaxis.title).toBeUndefined(); + expect(fig.layout.xaxis2.title).toBeDefined(); + }); + + it('column+row facet: full grid with one axis pair per cell', () => { + const CELLS = [ + { v: 1, w: 10, a: 'A', b: 'X' }, { v: 2, w: 20, a: 'B', b: 'X' }, + { v: 3, w: 30, a: 'A', b: 'Y' }, { v: 4, w: 40, a: 'B', b: 'Y' }, + ]; + const fig = assemblePlotly(input('Scatter Plot', { x: { field: 'v' }, y: { field: 'w' }, column: { field: 'a' }, row: { field: 'b' } }, CELLS, { v: 'Quantity', w: 'Quantity', a: 'Category', b: 'Category' })); + expect(fig._facetRows).toBe(2); + expect(fig._facetCols).toBe(2); + expect(fig.layout.xaxis4).toBeDefined(); + expect(fig.layout.yaxis4).toBeDefined(); + }); + + it('facet legend is deduped across panels via legendgroup', () => { + const fig = assemblePlotly(input('Line Chart', { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' }, column: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })); + const shown = fig.data.filter((t: any) => t.showlegend); + const names = new Set(fig.data.map((t: any) => t.name)); + expect(shown.length).toBe(names.size); + expect(fig.data.every((t: any) => t.legendgroup === t.name)).toBe(true); + }); + + it('diverging vs categorical semantics pick different scheme families', () => { + const TEMP = [ + { city: 'Oslo', temp: -6 }, { city: 'Rome', temp: 14 }, { city: 'Cairo', temp: 24 }, + ]; + const diverging = assemblePlotly(input('Bar Chart', { x: { field: 'city' }, y: { field: 'temp' }, color: { field: 'temp' } }, TEMP, { city: 'City', temp: 'Temperature' })); + const categorical = assemblePlotly(input('Scatter Plot', { x: { field: 'weight' }, y: { field: 'mpg' }, color: { field: 'origin' } }, CARS, { weight: 'Quantity', mpg: 'Quantity', origin: 'Country' })); + // Categorical grouping draws from the plotly10 qualitative palette… + expect(categorical.data[0].marker.color).toBe('#636efa'); + // …while a diverging measure on color routes to the RdBu ramp. + expect(diverging.data[0].marker.color).toBe('#b2182b'); + }); + + it('every dedicated generator case assembles cleanly', () => { + for (const tc of [...genPlotlyCoreTests(), ...genPlotlyFacetTests()]) { + const encodings = Object.fromEntries( + Object.entries(tc.encodingMap).map(([ch, e]: [string, any]) => [ch, { field: e.fieldID }]), + ); + const semantic_types = Object.fromEntries( + Object.entries(tc.metadata).map(([f, m]: [string, any]) => [f, m.semanticType]), + ); + const fig = assemblePlotly({ + data: { values: tc.data }, + semantic_types, + chart_spec: { chartType: tc.chartType, encodings }, + } as any); + expect(Array.isArray(fig.data), tc.title).toBe(true); + expect(fig.data.length, tc.title).toBeGreaterThan(0); + } + }); + + it('figures are pure JSON for all four templates', () => { + const figures = [ + assemblePlotly(input('Bar Chart', { x: { field: 'region' }, y: { field: 'revenue' } }, SALES, { region: 'Region', revenue: 'Amount' })), + assemblePlotly(input('Line Chart', { x: { field: 'month' }, y: { field: 'value' } }, MONTHLY, { month: 'YearMonth', value: 'Amount' })), + assemblePlotly(input('Area Chart', { x: { field: 'region' }, y: { field: 'revenue' }, color: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })), + assemblePlotly(input('Scatter Plot', { x: { field: 'weight' }, y: { field: 'mpg' } }, CARS, { weight: 'Quantity', mpg: 'Quantity' })), + assemblePlotly(input('Bar Chart', { x: { field: 'region' }, y: { field: 'revenue' }, column: { field: 'year' } }, GROUPED, { region: 'Region', revenue: 'Amount', year: 'Year' })), + ]; + for (const fig of figures) { + assertNoFunctions(fig); + expect(JSON.parse(JSON.stringify(fig))).toEqual(fig); + } + }); +}); diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index 9cf8957..4e7977b 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -6,6 +6,7 @@ import { assembleVegaLite, assembleECharts, assembleChartjs, + assemblePlotly, assembleExcel, } from '../src'; diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 67d5f29..519b97f 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -7,6 +7,7 @@ export default defineConfig({ 'vegalite/index': 'src/vegalite/index.ts', 'echarts/index': 'src/echarts/index.ts', 'chartjs/index': 'src/chartjs/index.ts', + 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', 'test-data/index': 'src/test-data/index.ts', 'gallery/index': 'src/gallery/index.ts', @@ -18,5 +19,5 @@ export default defineConfig({ splitting: false, treeshake: true, target: 'es2020', - external: ['vega', 'vega-lite', 'echarts', 'chart.js'], + external: ['vega', 'vega-lite', 'echarts', 'chart.js', 'plotly.js'], }); diff --git a/site/package.json b/site/package.json index 72d39ab..698d022 100644 --- a/site/package.json +++ b/site/package.json @@ -16,6 +16,7 @@ "@uiw/react-codemirror": "^4.23.0", "chart.js": "^4.5.1", "echarts": "^6.0.0", + "plotly.js-dist-min": "^2.35.2", "flint-chart": "*", "i18next": "^26.3.6", "katex": "^0.17.0", diff --git a/site/src/components/DocChart.tsx b/site/src/components/DocChart.tsx index eea0ad8..b91d727 100644 --- a/site/src/components/DocChart.tsx +++ b/site/src/components/DocChart.tsx @@ -3,6 +3,7 @@ import { TEST_GENERATORS } from 'flint-chart/test-data'; import { VegaLiteView } from './VegaLiteView'; import { EChartsView } from './EChartsView'; import { ChartjsView } from './ChartjsView'; +import { PlotlyView } from './PlotlyView'; import { ScaleToFit } from './ScaleToFit'; import { testCaseToAssemblyInput, type CanvasSize } from '../shared/test-case-utils'; import { BACKENDS, type PreviewBackend } from '../shared/supported-backends'; @@ -147,6 +148,7 @@ export function DocChart({ source, backend = 'vegalite' }: { source: string; bac {result.kind === 'vegalite' && } {result.kind === 'echarts' && } {result.kind === 'chartjs' && } + {result.kind === 'plotly' && } ); diff --git a/site/src/components/PlotlyView.tsx b/site/src/components/PlotlyView.tsx new file mode 100644 index 0000000..04261ad --- /dev/null +++ b/site/src/components/PlotlyView.tsx @@ -0,0 +1,57 @@ +import { useEffect, useRef } from 'react'; +import Plotly from 'plotly.js-dist-min'; + +const asFinite = (v: unknown): number | undefined => + typeof v === 'number' && Number.isFinite(v) ? v : undefined; + +/** + * Plotly renderer. + * + * The flint Plotly assembler computes a designed figure size (`_width`/`_height`, + * also written into `layout.width`/`layout.height`). Render into a wrapper sized + * to those dimensions so the plot keeps its designed proportions, the same way + * the other backend views render at their natural designed size. + */ +export function PlotlyView({ + figure, + height = 320, + constrain = true, +}: { + figure: any; + height?: number; + /** When false, render at the designed pixel size without clamping to the + * container width (used by the photo-wall, which scales charts to fit). */ + constrain?: boolean; +}) { + const ref = useRef(null); + + const designedWidth = asFinite(figure?._width); + const designedHeight = asFinite(figure?._height); + const renderHeight = designedHeight ?? height; + + useEffect(() => { + const el = ref.current; + if (!el) return; + Plotly.newPlot(el, figure?.data ?? [], figure?.layout ?? {}, { + displayModeBar: false, + responsive: false, + }); + return () => { + Plotly.purge(el); + }; + }, [figure]); + + return ( +
+
+
+ ); +} diff --git a/site/src/components/TripleChart.tsx b/site/src/components/TripleChart.tsx index 582202d..8cd17a5 100644 --- a/site/src/components/TripleChart.tsx +++ b/site/src/components/TripleChart.tsx @@ -3,6 +3,7 @@ import type { TestCase } from 'flint-chart/test-data'; import { VegaLiteView } from './VegaLiteView'; import { EChartsView } from './EChartsView'; import { ChartjsView } from './ChartjsView'; +import { PlotlyView } from './PlotlyView'; import { testCaseToAssemblyInput } from '../shared/test-case-utils'; import { BACKENDS, @@ -130,6 +131,7 @@ export function TripleChart({ {backend === 'vegalite' && } {backend === 'echarts' && } {backend === 'chartjs' && } + {backend === 'plotly' && } ) : (
diff --git a/site/src/components/WallChart.tsx b/site/src/components/WallChart.tsx
index 66fd036..178f6d5 100644
--- a/site/src/components/WallChart.tsx
+++ b/site/src/components/WallChart.tsx
@@ -3,6 +3,7 @@ import type { TestCase } from 'flint-chart/test-data';
 import { VegaLiteView } from './VegaLiteView';
 import { EChartsView } from './EChartsView';
 import { ChartjsView } from './ChartjsView';
+import { PlotlyView } from './PlotlyView';
 import { testCaseToAssemblyInput, thumbnailCanvasSize, type CanvasSize } from '../shared/test-case-utils';
 import { BACKENDS, type PreviewBackend } from '../shared/supported-backends';
 import { siteTheme } from '../shared/theme';
@@ -68,5 +69,6 @@ export function WallChart({
 
   if (backend === 'vegalite') return ;
   if (backend === 'echarts') return ;
+  if (backend === 'plotly') return ;
   return ;
 }
diff --git a/site/src/routes/ChartWall.tsx b/site/src/routes/ChartWall.tsx
index 1b9aae3..2454f32 100644
--- a/site/src/routes/ChartWall.tsx
+++ b/site/src/routes/ChartWall.tsx
@@ -561,6 +561,7 @@ function BackendIntro({
     vegalite: 'spec',
     echarts: 'option',
     chartjs: 'config',
+    plotly: 'figure',
   };
   const snippet = `import { ${category.fn} } from 'flint-chart';
 
diff --git a/site/src/routes/Editor.tsx b/site/src/routes/Editor.tsx
index a3317a9..f9306a2 100644
--- a/site/src/routes/Editor.tsx
+++ b/site/src/routes/Editor.tsx
@@ -318,6 +318,7 @@ const OUTPUT_LANG: Record = {
   vegalite: 'Vega-Lite JSON',
   echarts: 'ECharts option',
   chartjs: 'Chart.js config',
+  plotly: 'Plotly figure',
 };
 
 /**
diff --git a/site/src/shared/supported-backends.ts b/site/src/shared/supported-backends.ts
index f4c4ea1..961e220 100644
--- a/site/src/shared/supported-backends.ts
+++ b/site/src/shared/supported-backends.ts
@@ -2,13 +2,15 @@ import {
   assembleVegaLite,
   assembleECharts,
   assembleChartjs,
+  assemblePlotly,
   cjsGetTemplateDef,
   ecGetTemplateDef,
   vlGetTemplateDef,
+  plGetTemplateDef,
   type ChartAssemblyInput,
 } from 'flint-chart';
 
-export type PreviewBackend = 'vegalite' | 'echarts' | 'chartjs';
+export type PreviewBackend = 'vegalite' | 'echarts' | 'chartjs' | 'plotly';
 
 /** A backend's template def (or `undefined` when it has no template for a type). */
 type TemplateDef = ReturnType;
@@ -51,6 +53,12 @@ export const BACKENDS: Record = {
     assemble: assembleChartjs,
     getTemplateDef: cjsGetTemplateDef,
   },
+  plotly: {
+    id: 'plotly',
+    label: 'Plotly',
+    assemble: assemblePlotly,
+    getTemplateDef: plGetTemplateDef,
+  },
 };
 
 export const ALL_BACKENDS = Object.keys(BACKENDS) as PreviewBackend[];
diff --git a/site/src/types/plotly.d.ts b/site/src/types/plotly.d.ts
new file mode 100644
index 0000000..5116476
--- /dev/null
+++ b/site/src/types/plotly.d.ts
@@ -0,0 +1,7 @@
+declare module 'plotly.js-dist-min' {
+  const Plotly: {
+    newPlot: (el: HTMLElement, data: unknown[], layout?: unknown, config?: unknown) => Promise;
+    purge: (el: HTMLElement) => void;
+  };
+  export default Plotly;
+}