diff --git a/CHANGELOG.md b/CHANGELOG.md index a87c26c3..4919a6f2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -11,6 +11,27 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ## [Unreleased] +### Fixed + +- ECharts categorical legends (and their title graphics) are pinned with + `legend.right` instead of a design-canvas `left` pixel. Hosts that size the + container independently of `_width` and call `chart.resize()` keep the + reserved gutter instead of overlapping the plot or clipping the legend + ([#98](https://github.com/microsoft/flint-chart/issues/98)). +- Visible units now require an explicit `unit` in the field's semantic + annotation. Conventional compact units may accompany values, while lexical + units such as `years` are stated once as part of the field title. Bar Tables + also no longer repeat their value column as annotations on the bars. +- A raw sum-stacked chart whose total lands exactly on a clean axis tick now + keeps that edge flush instead of adding an empty interval above it, including + machine-scale residue from calculated shares. Totals meaningfully beyond the + clean endpoint still advance to the next tick; the rule is derived from the + plotted stack and does not special-case percentages or 100. +- Series-end labels now use a bounded screen-space packing pass when endpoints + form one readable column. Small adjustments keep labels attached by proximity; + crowded or horizontally staggered sets fall back together to the next legend + placement instead of leaving a partial or overlapping direct-label system. + ## [0.5.1] - 2026-08-13 ### Added diff --git a/agent-skills/flint-chart-author/SKILL.md b/agent-skills/flint-chart-author/SKILL.md index af1a890f..f7326fb1 100644 --- a/agent-skills/flint-chart-author/SKILL.md +++ b/agent-skills/flint-chart-author/SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. diff --git a/docs/community-backends.md b/docs/community-backends.md new file mode 100644 index 00000000..ffc539af --- /dev/null +++ b/docs/community-backends.md @@ -0,0 +1,63 @@ +# Community backends + +Community backends extend Flint to additional renderers and delivery surfaces. +They use the same `ChartAssemblyInput`, but may have different chart coverage, +release cadence, and gallery, editor, MCP, or ThemeSpec integration from Flint's +core backends. + +## Image-Charts + +> Originally contributed by +> [François-Guillaume Ribreau](https://github.com/FGRibreau). + +The Image-Charts backend compiles a Flint input into an unsigned URL for the +third-party [Image-Charts](https://www.image-charts.com/) service. It is useful +when the output must work as an ordinary image URL, including email, generated +documents, chat messages, and other no-JavaScript environments. + +```ts +import { + assembleImageCharts, + isImageChartsSupported, +} from 'flint-chart/image-charts'; + +if (isImageChartsSupported(input.chart_spec.chartType)) { + const artifact = assembleImageCharts(input); + // { type: 'image-charts', url: 'https://image-charts.com/chart?...' } +} +``` + +Assembly is pure: it creates the URL without making a network request. Loading +the returned URL sends the encoded chart data to Image-Charts, so do not use it +with confidential data unless sending that data to the service is acceptable +under your privacy and deployment requirements. + +### Supported charts + +- Bar Chart, Grouped Bar Chart, and Stacked Bar Chart +- Line Chart, Sparkline, and Area Chart +- Scatter Plot +- Pie Chart and Donut Chart +- Radar Chart + +Unsupported chart types and faceted inputs throw an error rather than silently +falling back to another representation. + +### Current scope + +- Output is an unsigned `https://image-charts.com/chart?...` GET URL. Account + identifiers, HMAC signatures, and secrets are outside this pure compiler. +- Width and height are clamped to 999 pixels, and total area is clamped to + 998,001 pixels, matching the service's documented chart-size limits. +- Data, labels, legends, colors, and titles are carried in the query string. + Large or label-heavy charts can produce long URLs; Flint does not currently + convert them to Image-Charts POST requests or enforce a maximum URL length. +- Banded bar charts use Flint's overflow filtering before URL serialization. +- The backend uses a fixed categorical palette. ThemeSpec and most + `chartProperties` are not applied. +- Flint does not currently render this artifact in its gallery, editor, or MCP + server. Availability, caching, retention, quotas, and subscription behavior + are controlled by Image-Charts. + +See the [Image-Charts API documentation](https://documentation.image-charts.com/) +for the hosted service's current request grammar and limits. diff --git a/docs/design-semantics.md b/docs/design-semantics.md index 0836a96a..b607a26e 100644 --- a/docs/design-semantics.md +++ b/docs/design-semantics.md @@ -656,7 +656,10 @@ Only override native formatting when semantic context adds value: prefix/suffix, | **Sentiment / Correlation** | `+` + data-driven | — | — | — | Signed decimal | | **Latitude / Longitude** | — (empty) | — | — | — | VL native | -Unit/currency priority is `annotation.unit` > column-name heuristics > data-value scanning > type defaults. +Visible unit text requires `annotation.unit`; semantic types, column names, and +data values do not authorize display by themselves. Conventional compact units +such as `$`, `%`, `°C`, `kg`, or `min` may accompany values. Lexical units such +as `years` are stated once with the field title (`field (years)`). **Parsing** is the compiler's job, guided by semantic type rather than stored on context: diff --git a/docs/figs/issue-98-slope-534-before.png b/docs/figs/issue-98-slope-534-before.png new file mode 100644 index 00000000..5a63b302 Binary files /dev/null and b/docs/figs/issue-98-slope-534-before.png differ diff --git a/docs/figs/issue-98-slope-534.png b/docs/figs/issue-98-slope-534.png new file mode 100644 index 00000000..77518fd9 Binary files /dev/null and b/docs/figs/issue-98-slope-534.png differ diff --git a/docs/figs/issue-98-slope-800-before.png b/docs/figs/issue-98-slope-800-before.png new file mode 100644 index 00000000..4b565268 Binary files /dev/null and b/docs/figs/issue-98-slope-800-before.png differ diff --git a/docs/figs/issue-98-slope-800.png b/docs/figs/issue-98-slope-800.png new file mode 100644 index 00000000..2adda8d3 Binary files /dev/null and b/docs/figs/issue-98-slope-800.png differ diff --git a/docs/reference-vegalite.md b/docs/reference-vegalite.md index a65a9836..1bbfdd3d 100644 --- a/docs/reference-vegalite.md +++ b/docs/reference-vegalite.md @@ -415,7 +415,9 @@ The **Availability** column shows whether a parameter is `always` available or ` **Encoding channels:** `x`, `color` -_No template-specific parameters._ +| Parameter | Control | Domain | Default | Availability | Description | +|---|---|---|---|---|---| +| `cornerRadius` | number | 0 – 8 (step 1) | `2` | always | Corner radius for supported marks. | ### ![](chart-icon-bar-table.svg) Bar Table diff --git a/packages/flint-js/package.json b/packages/flint-js/package.json index e74f6b47..bf8c69ff 100644 --- a/packages/flint-js/package.json +++ b/packages/flint-js/package.json @@ -65,6 +65,11 @@ "import": "./dist/excel/index.js", "require": "./dist/excel/index.cjs" }, + "./image-charts": { + "types": "./dist/image-charts/index.d.ts", + "import": "./dist/image-charts/index.js", + "require": "./dist/image-charts/index.cjs" + }, "./test-data": { "types": "./dist/test-data/index.d.ts", "import": "./dist/test-data/index.js", diff --git a/packages/flint-js/src/core/field-semantics.ts b/packages/flint-js/src/core/field-semantics.ts index 91e8b76c..5120d50a 100644 --- a/packages/flint-js/src/core/field-semantics.ts +++ b/packages/flint-js/src/core/field-semantics.ts @@ -261,6 +261,44 @@ const UNIT_SUFFIX_MAP: Record = { '%': '%', }; +export interface DisplayUnit { + /** Normalized display text, e.g. `USD` becomes `$` and `hours` becomes `hr`. */ + text: string; + /** Compact conventional tags may accompany values; lexical units belong once beside the field name. */ + placement: 'value' | 'field'; + /** Currency symbols precede values; other compact units follow them. */ + position: 'prefix' | 'suffix'; +} + +/** + * Resolve display intent only from a unit explicitly declared in the semantic + * annotation. A semantic type or suggestive field name is not permission to + * print a unit. + */ +export function resolveDisplayUnit(annotation?: SemanticAnnotation): DisplayUnit | undefined { + const declared = annotation?.unit?.trim(); + if (!declared) return undefined; + + const currency = CURRENCY_MAP[declared.toUpperCase()] ?? CURRENCY_MAP[declared]; + if (currency) return { text: currency, placement: 'value', position: 'prefix' }; + + const compact = UNIT_SUFFIX_MAP[declared] ?? UNIT_SUFFIX_MAP[declared.toLowerCase()]; + if (compact) return { text: compact.trim(), placement: 'value', position: 'suffix' }; + + // Field-level units are labels, not prose. Reject control characters, + // parenthetical fragments, and long descriptions; those belong in a + // subtitle supplied by the authoring agent. + if (declared.length > 24 || /[\r\n()]/.test(declared)) return undefined; + return { text: declared, placement: 'field', position: 'suffix' }; +} + +/** Append a field-level unit once, preserving labels that already name it. */ +export function titleWithDisplayUnit(title: string, unit?: DisplayUnit): string { + if (unit?.placement !== 'field') return title; + if (title.toLocaleLowerCase().includes(`(${unit.text.toLocaleLowerCase()})`)) return title; + return `${title} (${unit.text})`; +} + /** * Detect whether percentage data uses 0–1 (fractional) or 0–100 (whole-number) * representation. diff --git a/packages/flint-js/src/core/theme/ground.ts b/packages/flint-js/src/core/theme/ground.ts index 6cdbd7ae..ad4e7315 100644 --- a/packages/flint-js/src/core/theme/ground.ts +++ b/packages/flint-js/src/core/theme/ground.ts @@ -42,7 +42,7 @@ import { resolvePresenceInk, sampleRamp, } from './presence.js'; -import { CURRENCY_MAP } from '../field-semantics.js'; +import { resolveDisplayUnit } from '../field-semantics.js'; import { getRegistryEntry } from '../type-registry.js'; import { inferValueLabelFormat, longestLabelChars } from './value-label-format.js'; import { deepMerge } from './merge.js'; @@ -581,26 +581,9 @@ function percentOfWhole(ctx: GroundingContext, channel: string): string | undefi return n >= 3 && Math.abs(sum - 100) < 0.5 ? '%' : undefined; } -/** - * The unit a measure is counted in, when the chart already knows it. - * - * Either the annotation says so outright, or the field names it the way a - * person does — `CO₂ (ppm)`, `Unemployment (%)`. Anything longer than a short - * tag is a phrase, not a unit, and belongs in the subtitle. - */ -const UNIT_IN_FIELD_NAME = /\(([^()]{1,6})\)\s*$/; - -function unitText(ctx: GroundingContext, channel: string): string | undefined { +function displayUnit(ctx: GroundingContext, channel: string) { const sem = ctx.channelSemantics?.[channel]; - const declared = sem?.semanticAnnotation?.unit; - const field = sem?.field ?? (ctx.positional as any)?.[channel]?.field; - const named = typeof field === 'string' ? field.match(UNIT_IN_FIELD_NAME) : null; - const raw = (typeof declared === 'string' && declared.length > 0 && declared.length <= 6) - ? declared - : named?.[1]; - if (!raw) return undefined; - // A currency is written with its sign, not its ISO code: `$8`, not `8 USD`. - return CURRENCY_MAP[raw.toUpperCase()] ?? raw; + return resolveDisplayUnit(sem?.semanticAnnotation); } /** @@ -923,12 +906,14 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe // reads in shares, whatever the field was measured in. const unitPolicy = theme.annotation?.unit ?? 'never'; const inFieldUnits = ctx.stacked !== 'normalize' && !ctx.partToWhole; - const unit = role === 'measure' && inFieldUnits ? unitText(ctx, channel) : undefined; - const unitTag = unitPolicy !== 'never' ? unit : undefined; + const unit = role === 'measure' && inFieldUnits ? displayUnit(ctx, channel) : undefined; + const unitTag = unitPolicy !== 'never' && unit?.placement === 'value' ? unit.text : undefined; // Where the house keeps its axis titles, the title is the natural place // for the unit — `Weight (lb)` — and the ticks stay bare numbers. - const titleUnit = showTitle && theme.annotation?.unitsInAxisTitle === true ? unit : undefined; + const titleUnit = showTitle && unit && ( + unit.placement === 'field' || theme.annotation?.unitsInAxisTitle === true + ) ? unit.text : undefined; // The gap between a label and the plot is the same gap whether or not a // tick is drawn in it. Where there is one, the tick spans the first part @@ -1460,22 +1445,20 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe const shareUnit = signals.isPartToWhole && !axisStatesUnit ? percentOfWhole(ctx, valueUnitChannel ?? '') : undefined; + const valueDisplayUnit = displayUnit(ctx, valueUnitChannel ?? ''); const valueUnit = houseStatesUnit - ? (unitText(ctx, valueUnitChannel ?? '') ?? shareUnit) + ? (valueDisplayUnit?.placement === 'value' ? valueDisplayUnit.text : shareUnit) : shareUnit; // A label placed at the mark sits *inside* it, which only works while the // mark is longer than the label. Below that length the label has to move - // out, and above the point where the mark reaches the end of the scale an - // outside label has nowhere left to go. Grounding is the stage that can - // say where those two lines are. + // out. Outside placement is chart-wide: the backend reserves room instead + // of flipping only the longest mark inward. let insideMinValue: number | undefined; - let outsideMaxValue: number | undefined; if (dlShow && measureChannel) { const span = measureChannel === 'x' ? ctx.layout.subplotWidth : ctx.layout.subplotHeight; if (valueMaxAbs > 0 && span > 0) { insideMinValue = (valueLabelWidthPx / span) * valueMaxAbs; - outsideMaxValue = valueMaxAbs - insideMinValue; } } @@ -1754,7 +1737,6 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe format: numberFormat, ...(valueUnit ? { unit: valueUnit } : {}), insideMinValue, - outsideMaxValue, ...(segmentMinShare !== undefined ? { segmentMinShare } : {}), }, // A house that dots the end of a line is saying where the story stops. @@ -1773,6 +1755,7 @@ export function groundTheme(themeIn: ThemeSpec, ctx: GroundingContext): DesignDe padding, density, plotWidth: ctx.layout.subplotWidth, + plotHeight: ctx.layout.subplotHeight, xStep: ctx.layout.xStep, canvasWidth: ctx.canvasSize?.width, }, diff --git a/packages/flint-js/src/core/theme/types.ts b/packages/flint-js/src/core/theme/types.ts index dafa0f78..a3b80912 100644 --- a/packages/flint-js/src/core/theme/types.ts +++ b/packages/flint-js/src/core/theme/types.ts @@ -802,11 +802,6 @@ export interface ResolvedDataLabels { * question about space, not about style. */ insideMinValue?: number; - /** - * Above this magnitude the mark reaches the end of the scale, so an - * outside label would fall off the plot. The mirror of `insideMinValue`. - */ - outsideMaxValue?: number; /** * The smallest share of the measure axis a stacked segment may occupy and * still be labelled — a line of text over the plot's extent along that @@ -928,11 +923,12 @@ export interface DesignDecisions { spacing?: number; preferredColumns?: number; }; - /** `plotWidth`/`xStep` are what the layout settled, so an axis can ask whether its names still fit. */ + /** Plot dimensions and step are what layout settled, so realization can test whether annotations fit. */ layout: { padding: number; density: 'compact' | 'normal' | 'airy'; plotWidth?: number; + plotHeight?: number; xStep?: number; /** The graphic the caller asked for. Wider than `plotWidth` by the axis gutter. */ canvasWidth?: number; diff --git a/packages/flint-js/src/core/types.ts b/packages/flint-js/src/core/types.ts index b2fc03b7..00b065b7 100644 --- a/packages/flint-js/src/core/types.ts +++ b/packages/flint-js/src/core/types.ts @@ -972,6 +972,12 @@ export interface ChartTemplateDef { */ ownsValueLabels?: boolean; + /** + * The template already presents values in a dedicated table column, so a + * generic label layer would repeat the same number on the data mark. + */ + suppressValueLabels?: boolean; + /** * Opt out of a backend's *generic* column/row facet-splitting pass, even * though the template declares `x`/`y` (so the axis-less `hasAxes` gate diff --git a/packages/flint-js/src/docs/design-semantics.md b/packages/flint-js/src/docs/design-semantics.md index c2800eab..d6b80637 100644 --- a/packages/flint-js/src/docs/design-semantics.md +++ b/packages/flint-js/src/docs/design-semantics.md @@ -1162,7 +1162,10 @@ For generic decimal types (Number, Score, Rating, Ratio, Latitude, Longitude), t **Unit and currency from annotation metadata:** When the LLM provides `unit` in the annotation (e.g., `"unit": "EUR"` for Price, `"unit": "kg"` for Weight), the format spec uses that directly. See §3 for the full annotation schema. -**Fallback priority for units:** annotation.unit > column-name heuristics ("Weight (kg)") > data-value scanning ("$1,234") > type-specific defaults ("$" for Price). +**Visible-unit policy:** only `annotation.unit` authorizes unit text. Semantic +types, column names, and data scanning may inform parsing or other semantic +decisions, but do not cause a unit to be printed. Conventional compact units +may accompany values; lexical units are stated once with the field title. ### 5.1.1 Parsing @@ -2070,9 +2073,9 @@ After this phase, all semantic-type-driven decisions flow through the flat `Chan 1. **Unit/domain annotation reliability.** How reliably will the LLM provide `domain` and `unit`? Mitigation strategies: - (a) Require domain/unit for a small set of types (Rating, Score, Temperature, Price) — reject annotations without them - - (b) Treat domain/unit as best-effort hints — fall back gracefully to data-inferred or type-intrinsic defaults (current proposal) + - (b) Treat domain/unit as best-effort hints, but require an explicit unit annotation before displaying unit text (current policy) - (c) Prompt the user to confirm/correct LLM-provided annotations in certain cases - - Fallback priority: annotation.unit > column-name heuristics ("Weight (kg)") > data scan ("$1,234") > type defaults + - Visible unit text has no fallback: it requires `annotation.unit` - Note: `intrinsicDomain` replaces the old `domain` property for clarity 2. **Scale type auto-detection.** Should we auto-switch to log scale when data spans >2 orders of magnitude? This is powerful but can surprise users. Options: diff --git a/packages/flint-js/src/echarts/facet.ts b/packages/flint-js/src/echarts/facet.ts index ffad6a9c..35ce0bde 100644 --- a/packages/flint-js/src/echarts/facet.ts +++ b/packages/flint-js/src/echarts/facet.ts @@ -525,13 +525,14 @@ function repositionFacetedLegendBesideGrids(combined: any): void { const BUFFER = 16; const rightMost = Math.max(...grids.map((g: any) => (g.left ?? 0) + (g.width ?? 0))); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), @@ -565,13 +566,14 @@ function repositionFacetedPolarLegend(combined: any): void { const r = Number(p?.radius) || 0; return cx + r; })); + const { left: _ignoredLeft, ...legendRest } = combined.legend; + void _ignoredLeft; combined.legend = { - ...combined.legend, - left: rightMost + GAP, + ...legendRest, + right: BUFFER, top: combined.legend.top ?? 20, orient: combined.legend.orient || 'vertical', align: 'left', - right: undefined, textStyle: { fontSize: highCardinality ? 8 : 11, ...(combined.legend.textStyle || {}), diff --git a/packages/flint-js/src/echarts/instantiate-spec.ts b/packages/flint-js/src/echarts/instantiate-spec.ts index b4474d4c..0299005f 100644 --- a/packages/flint-js/src/echarts/instantiate-spec.ts +++ b/packages/flint-js/src/echarts/instantiate-spec.ts @@ -495,41 +495,24 @@ export function ecApplyLayoutToSpec( option.graphic = Array.isArray(existing) ? [...existing, titleGraphic] : (existing ? [existing, titleGraphic] : [titleGraphic]); } } else { - // Single legend: use left positioning so title and legend circles share the same left edge + // Single legend: pin to the canvas right edge (not a design-width + // `left` px). Hosts that call chart.resize() keep the gutter; + // `right = designW - left` is wrong — ECharts `right` is the inset + // to the legend's *right* edge, which would grow into the plot. + // See https://github.com/microsoft/flint-chart/issues/98 const maxLabelLen = Math.max(...legendLabels.map((l: string) => l.length), 3); const highCardinality = legendLabels.length >= 16; const legendSymbolWidth = highCardinality ? 12 : 14; const legendItemGap = 5; const estimatedTextWidth = Math.min(120, maxLabelLen * 7 + 30); option._legendWidth = legendSymbolWidth + legendItemGap + estimatedTextWidth; - const LEGEND_GAP = 12; const CANVAS_BUFFER = 16; - const rightMarginPx = option._legendWidth + LEGEND_GAP + CANVAS_BUFFER; - const hasYTitle = !!option.yAxis?.name; - const gridLeft = (hasYTitle ? 70 : 50) + CANVAS_BUFFER; - // Use same effective plot width as canvas block (grouped bar/boxplot widen the plot) so legend does not overlap chart - let plotW = layout?.subplotWidth ?? canvasSize?.width ?? 400; - const xIsDiscreteForLegend = layout.xNominalCount > 0 || layout.xContinuousAsDiscrete > 0; - if (xIsDiscreteForLegend) { - let xItemCount = layout.xNominalCount || layout.xContinuousAsDiscrete || 0; - if (layout.xStepUnit === 'group' && option.series && Array.isArray(option.series) && layout.xNominalCount > 0) { - const barSeriesCount = option.series.filter((s: any) => s.type === 'bar').length || option.series.length; - if (barSeriesCount > 0) { - xItemCount = Math.max(1, Math.round(layout.xNominalCount / barSeriesCount)); - } - } - plotW = xItemCount > 0 ? layout.xStep * xItemCount : plotW; - } - const boxplotMinWForLegend = estimateGroupedBoxplotMinPlotWidth(option, layout); - if (boxplotMinWForLegend > 0) { - plotW = Math.max(plotW, boxplotMinWForLegend); - } - const effectiveChartWidth = plotW + gridLeft + rightMarginPx; - const legendLeftPx = Math.max(0, effectiveChartWidth - rightMarginPx); + const { left: _ignoredLeft, ...legendRest } = option.legend; + void _ignoredLeft; option.legend = { - ...option.legend, + ...legendRest, top: legendTitle != null ? 20 : 0, - left: legendLeftPx, + right: CANVAS_BUFFER, orient: option.legend.orient || 'vertical', align: 'left', // icon on left, text on right textStyle: { @@ -542,7 +525,7 @@ export function ecApplyLayoutToSpec( if (legendTitle != null) { const titleGraphic = { type: 'text' as const, - left: legendLeftPx, + right: CANVAS_BUFFER, top: 4, z: 100, style: { @@ -551,6 +534,7 @@ export function ecApplyLayoutToSpec( fontWeight: 'bold', fill: '#333', textAlign: 'left', + width: option._legendWidth, }, }; const existing = option.graphic; diff --git a/packages/flint-js/src/echarts/templates/streamgraph.ts b/packages/flint-js/src/echarts/templates/streamgraph.ts index ef8135c7..a414558b 100644 --- a/packages/flint-js/src/echarts/templates/streamgraph.ts +++ b/packages/flint-js/src/echarts/templates/streamgraph.ts @@ -200,22 +200,20 @@ export const ecStreamgraphDef: ChartTemplateDef = { option.singleAxis.left = option.singleAxis.left || 50; option.singleAxis.right = Math.max(option.singleAxis.right || 0, rightMargin); - // Position legend in the right margin so it doesn't overlap the stream + // Pin legend to the right gutter (not design-canvas `left`) so resize() + // does not drop it into the stream. See microsoft/flint-chart#98. if (hasLegend && option.legend) { - const legendLeft = option._width - rightMargin + BUFFER; - option.legend.left = legendLeft; - delete option.legend.right; // Use left to align with graphic titles + delete option.legend.left; + option.legend.right = BUFFER; option.legend.top = 20; option.legend.orient = option.legend.orient || 'vertical'; option.legend.align = 'left'; - // Also update any custom graphic legend titles if (Array.isArray(option.graphic)) { for (const g of option.graphic) { - // The legend title added in instantiate-spec.ts typically has top: 4 and type: 'text' if (g.type === 'text' && (g.top === 4 || g.top === 20) && g.style && g.style.fontWeight === 'bold') { - g.left = legendLeft; - delete g.right; + delete g.left; + g.right = BUFFER; } } } diff --git a/packages/flint-js/src/image-charts/assemble.ts b/packages/flint-js/src/image-charts/assemble.ts new file mode 100644 index 00000000..8ab7822d --- /dev/null +++ b/packages/flint-js/src/image-charts/assemble.ts @@ -0,0 +1,376 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart assembly — a hosted-image-URL backend. + * + * Unlike the other backends, Image-Charts does not emit a spec object that a + * local renderer draws: it emits a single permanent `https://image-charts.com` + * URL that renders the chart server-side. That URL is embeddable anywhere an + * `` works (email, PDF, Slack, no-code tools) with no runtime JavaScript. + * + * Contract: + * - PURE. No network I/O, no crypto, no npm dependencies. `assembleImageCharts` + * only builds a string; the data reaches Image-Charts only if something later + * loads the `` — an explicit choice by the caller, exactly as choosing + * the Excel backend chooses Office.js. + * - FREE TIER ONLY. Unsigned URLs (no `icac`/`ichm` account/HMAC pair, no + * `chof` output override). Signed enterprise URLs need a server-side secret + * that has no place in a pure, offline compiler function. + * + * Reuses the SAME core analysis pipeline as the other backends (Phase 0 semantic + * resolution + banded-axis overflow filtering), then serializes the resolved + * channel semantics, category/series roles, and values into the Image-Charts + * query grammar (`cht`, `chd=a:`, `chs`, `chxt`/`chxl`, `chco`, `chdl`, `chm`, + * `chtt`). Like the Excel backend it does the work inline rather than through a + * template registry, and it gates chart types to the ones with a faithful `cht`. + */ + +import type { ChartAssemblyInput, ChartEncoding, SemanticResult } from '../core/types'; +import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; +import { detectBandedAxisFromSemantics } from '../core/axis-detection'; +import { computeChannelBudgets, deriveStretchCaps, resolveBaseSize } from '../core/compute-layout'; +import { filterOverflow } from '../core/filter-overflow'; +import type { LayoutDeclaration } from '../core/types'; +import { IMAGE_CHARTS_TYPE_MAP } from './chart-types'; + +/** A backend-native Image-Charts artifact: a permanent hosted-image URL. */ +export interface ImageChartsArtifact { + type: 'image-charts'; + url: string; +} + +type Cell = string | number; + +/** Image-Charts base endpoint (public free tier). */ +const IMAGE_CHARTS_ENDPOINT = 'https://image-charts.com/chart?'; + +/** Free-tier size ceilings: each side ≤ 999px and area ≤ 998001px². */ +const MAX_SIDE = 999; +const MAX_AREA = 998001; + +/** Default target size when the spec provides no `baseSize`. */ +const DEFAULT_SIZE = { width: 700, height: 400 }; + +/** + * Categorical palette (hex, no `#`) used for `chco`. Emitted only when color is + * meaningful (multiple series, pie slices, area fill, scatter markers); a single + * plain series keeps Image-Charts' own default color. + */ +const SERIES_COLORS = [ + '4472C4', 'ED7D31', '70AD47', 'FFC000', '5B9BD5', + 'A5A5A5', '264478', '9E480E', '636363', '997300', +]; + +/** Normalize shorthand (`"x": "field"`) to `{ field }`. */ +function normalizeEncodings(raw: Record): Record { + const out: Record = {}; + for (const [ch, v] of Object.entries(raw ?? {})) { + if (v == null) continue; + out[ch] = typeof v === 'string' ? { field: v } : (v as ChartEncoding); + } + return out; +} + +/** Clamp a target size to the free-tier ceilings (side ≤ 999, area ≤ 998001). */ +function clampChartSize(width: number, height: number): { width: number; height: number } { + let w = Math.min(MAX_SIDE, Math.max(1, Math.round(width))); + let h = Math.min(MAX_SIDE, Math.max(1, Math.round(height))); + if (w * h > MAX_AREA) { + const scale = Math.sqrt(MAX_AREA / (w * h)); + w = Math.max(1, Math.floor(w * scale)); + h = Math.max(1, Math.floor(h * scale)); + } + return { width: w, height: h }; +} + +/** + * Encode one label/title/legend segment: keep ASCII alphanumerics, map spaces to + * `+`, percent-encode everything else (UTF-8). Structural separators (`|`, `,`, + * `:`) are added by the caller between segments and never pass through here, so + * a label that literally contains them stays escaped and cannot break parsing. + */ +function encodeSegment(text: string): string { + let out = ''; + for (const ch of text) { + if (/[0-9A-Za-z]/.test(ch)) out += ch; + else if (ch === ' ') out += '+'; + else out += encodeURIComponent(ch); + } + return out; +} + +/** Format one datum for the `a:` (awesome) encoding; `_` marks a gap/null. */ +function formatValue(value: number | null): string { + if (value == null || !Number.isFinite(value)) return '_'; + if (Number.isInteger(value)) return String(value); + return String(Number(value.toFixed(4))); +} + +function finiteNumber(value: unknown): number | null { + if (value == null || (typeof value === 'string' && value.trim() === '')) return null; + const number = Number(value); + return Number.isFinite(number) ? number : null; +} + +function cellKey(value: unknown): string { + return `${typeof value}:${String(value)}`; +} + +function pairKey(first: unknown, second: unknown): string { + return JSON.stringify([cellKey(first), cellKey(second)]); +} + +/** Distinct values of a field in first-seen order (nulls skipped). */ +function distinct(rows: any[], field: string): Cell[] { + const seen = new Set(); + const out: Cell[] = []; + for (const r of rows) { + const v = r[field]; + if (v == null) continue; + if (!seen.has(v)) { seen.add(v); out.push(v as Cell); } + } + return out; +} + +/** + * Aggregate the long/tidy rows into a per-series × per-category value matrix, + * summing (or averaging) duplicates. `seriesField` undefined ⇒ one implicit + * series holding the whole measure column. + */ +function pivotValues( + rows: any[], + catField: string, + measField: string, + seriesField: string | undefined, + categories: Cell[], + seriesKeys: Cell[], + aggregate: 'sum' | 'average', +): (number | null)[][] { + const SINGLE = '__single__'; + const acc = new Map(); + for (const r of rows) { + const cv = r[catField]; + if (cv == null) continue; + const sv = seriesField ? r[seriesField] : SINGLE; + const num = finiteNumber(r[measField]); + if (num == null) continue; + const key = pairKey(cv, sv); + const e = acc.get(key) ?? { sum: 0, count: 0 }; + e.sum += num; e.count += 1; acc.set(key, e); + } + const valueAt = (cv: Cell, sv: Cell): number | null => { + const e = acc.get(pairKey(cv, seriesField ? sv : SINGLE)); + if (!e) return null; + return aggregate === 'average' ? e.sum / e.count : e.sum; + }; + return seriesKeys.map((sv) => categories.map((cv) => valueAt(cv, sv))); +} + +/** + * Assemble an {@link ImageChartsArtifact} (a permanent hosted-image URL) from a + * {@link ChartAssemblyInput}. + * + * @throws if the chart type has no faithful Image-Charts `cht` equivalent + * (e.g. Boxplot, Sankey, Heatmap) or its roles cannot be resolved. + */ +export function assembleImageCharts(input: ChartAssemblyInput): ImageChartsArtifact { + const flintType = input.chart_spec.chartType; + const mapping = IMAGE_CHARTS_TYPE_MAP[flintType]; + if (!mapping) { + throw new Error(`Image-Charts backend does not support chart type "${flintType}".`); + } + + const semanticTypes = input.semantic_types ?? {}; + const rawData: any[] = input.data.values ?? []; + const encodings = normalizeEncodings(input.chart_spec.encodings); + + if (encodings.column?.field || encodings.row?.field) { + throw new Error(`Image-Charts backend does not support faceting in one chart: "${flintType}".`); + } + + // ── Phase 0 (reused core): resolve per-channel semantics ──────────────── + let table = convertTemporalData(rawData, semanticTypes); + const sem: SemanticResult = resolveChannelSemantics(encodings, rawData, semanticTypes, table); + const typeOf = (ch: string) => sem[ch]?.type; + const isMeasure = (ch: string) => typeOf(ch) === 'quantitative'; + const fieldOf = (ch: string) => encodings[ch]?.field; + + // A categorical color/group binding becomes the series (legend) dimension; + // a quantitative color is not a series and is ignored on this tier. + const seriesCh = encodings.group?.field + ? 'group' + : encodings.color?.field && !isMeasure('color') + ? 'color' + : undefined; + const seriesField = seriesCh ? fieldOf(seriesCh) : undefined; + + // ── Overflow filtering for banded (bar) families, so URLs stay bounded ── + const keptCategoryOrder = new Map(); + if (mapping.cht === 'bvg' || mapping.cht === 'bhg' || mapping.cht === 'bvs' || mapping.cht === 'bhs') { + const detected = detectBandedAxisFromSemantics(sem, table, { preferAxis: 'x' }); + const declaration: LayoutDeclaration = { + axisFlags: detected ? { [detected.axis]: { banded: true } } : { x: { banded: true } }, + resolvedTypes: detected?.resolvedTypes, + }; + const baseSize = resolveBaseSize(input.chart_spec.baseSize, input.chart_spec.canvasSize); + const options = { + facetFixedPadding: { width: 50, height: 40 }, + facetGap: 10, + targetBandAR: 10, + ...deriveStretchCaps(baseSize, input.chart_spec.canvasSize, {}), + }; + const budgets = computeChannelBudgets(sem, declaration, table, baseSize, options); + const overflow = filterOverflow(sem, declaration, encodings, table, budgets, new Set(['bar'])); + table = overflow.filteredData; + overflow.truncations.forEach((t) => keptCategoryOrder.set(t.field, t.keptValues as Cell[])); + } + + const params: string[] = []; + const size = clampChartSize( + input.chart_spec.baseSize?.width ?? DEFAULT_SIZE.width, + input.chart_spec.baseSize?.height ?? DEFAULT_SIZE.height, + ); + + if (mapping.noAxes) { + buildPartToWhole(params, mapping.cht, sem, table, fieldOf); + } else if (mapping.xy) { + buildScatter(params, table, fieldOf, isMeasure, seriesField, flintType); + } else { + buildAxes( + params, mapping, flintType, sem, table, + fieldOf, typeOf, isMeasure, seriesField, keptCategoryOrder, + ); + } + + params.push(`chs=${size.width}x${size.height}`); + const title = input.chart_spec.title?.trim(); + if (title) params.push(`chtt=${encodeSegment(title)}`); + + return { type: 'image-charts', url: IMAGE_CHARTS_ENDPOINT + params.join('&') }; +} + +/** Pie / doughnut: one series of slices, each with its own label and color. */ +function buildPartToWhole( + params: string[], + cht: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, +): void { + const catField = fieldOf('color') ?? fieldOf('x'); + const measField = fieldOf('size') ?? fieldOf('theta') ?? fieldOf('y'); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve slice/value fields for a part-to-whole chart (category=${catField}, value=${measField}).`); + } + const slices = distinct(table, catField); + const measCh = fieldOf('size') === measField ? 'size' : fieldOf('theta') === measField ? 'theta' : 'y'; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const [values] = pivotValues(table, catField, measField, undefined, slices, ['__single__'], aggregate); + + params.push(`cht=${cht}`); + params.push(`chd=a:${values.map(formatValue).join(',')}`); + params.push(`chl=${slices.map((s) => encodeSegment(String(s))).join('|')}`); + params.push(`chco=${slices.map((_s, i) => SERIES_COLORS[i % SERIES_COLORS.length]).join('|')}`); +} + +/** Scatter: `lxy` with one (x-set, y-set) pair per series, drawn as markers. */ +function buildScatter( + params: string[], + table: any[], + fieldOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + flintType: string, +): void { + const xField = fieldOf('x'); + const yField = fieldOf('y'); + if (!xField || !yField || !isMeasure('x') || !isMeasure('y')) { + throw new Error(`Image-Charts backend requires quantitative x and y fields for "${flintType}".`); + } + const seriesKeys = seriesField ? distinct(table, seriesField) : ['__single__']; + const datasets: string[] = []; + const markers: string[] = []; + const colors: string[] = []; + seriesKeys.forEach((key, index) => { + const rows = seriesField ? table.filter((r) => r[seriesField] === key) : table; + const xs = rows.map((r) => finiteNumber(r[xField])); + const ys = rows.map((r) => finiteNumber(r[yField])); + datasets.push(xs.map(formatValue).join(',')); + datasets.push(ys.map(formatValue).join(',')); + const color = SERIES_COLORS[index % SERIES_COLORS.length]; + colors.push(color); + markers.push(`s,${color},${index},-1,6`); + }); + + params.push('cht=lxy'); + params.push(`chd=a:${datasets.join('|')}`); + params.push(`chco=${colors.join(',')}`); + params.push(`chm=${markers.join('|')}`); + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} + +/** Bar / line / area / radar: a category axis plus one measure per series. */ +function buildAxes( + params: string[], + mapping: { cht: string; horizontal?: string; radar?: boolean; area?: boolean }, + flintType: string, + sem: SemanticResult, + table: any[], + fieldOf: (ch: string) => string | undefined, + typeOf: (ch: string) => string | undefined, + isMeasure: (ch: string) => boolean, + seriesField: string | undefined, + keptCategoryOrder: Map, +): void { + // Horizontal bar when the measure sits on x and the category on y. + const horizontal = Boolean(mapping.horizontal) && isMeasure('x') && !isMeasure('y'); + const catCh = horizontal ? 'y' : 'x'; + const measCh = horizontal ? 'x' : 'y'; + const catField = fieldOf(catCh); + const measField = fieldOf(measCh); + if (!catField || !measField) { + throw new Error(`Image-Charts backend could not resolve category/measure for "${flintType}" (category=${catField}, measure=${measField}).`); + } + + let categories = keptCategoryOrder.get(catField) ?? distinct(table, catField); + // Ordered domains (line / area over time or a numeric axis) sort ascending. + if (!mapping.radar && (flintType === 'Line Chart' || flintType === 'Area Chart' || flintType === 'Sparkline')) { + if (typeOf(catCh) === 'temporal') { + categories = [...categories].sort((a, b) => new Date(String(a)).getTime() - new Date(String(b)).getTime()); + } else if (typeOf(catCh) === 'quantitative') { + categories = [...categories].sort((a, b) => Number(a) - Number(b)); + } + } + + const seriesKeys = seriesField ? distinct(table, seriesField) : [measField]; + const aggregate = sem[measCh]?.aggregationDefault ?? 'sum'; + const seriesValues = pivotValues(table, catField, measField, seriesField, categories, seriesKeys, aggregate); + + const cht = horizontal ? (mapping.horizontal as string) : mapping.cht; + params.push(`cht=${cht}`); + params.push(`chd=a:${seriesValues.map((vals) => vals.map(formatValue).join(',')).join('|')}`); + + // Category axis: index 0 (x) for vertical/radar, index 1 (y) for horizontal. + const categoryLabels = categories.map((c) => encodeSegment(String(c))).join('|'); + if (mapping.radar) { + params.push('chxt=r'); + params.push(`chxl=0:|${categoryLabels}`); + } else { + params.push('chxt=x,y'); + params.push(`chxl=${horizontal ? 1 : 0}:|${categoryLabels}`); + } + + const seriesColors = seriesKeys.map((_k, i) => SERIES_COLORS[i % SERIES_COLORS.length]); + if (seriesKeys.length > 1 || mapping.area) { + params.push(`chco=${seriesColors.join(',')}`); + } + if (mapping.area) { + params.push(`chm=${seriesColors.map((c, i) => `B,${c},${i},0,0`).join('|')}`); + } + if (seriesField && seriesKeys.length > 1) { + params.push(`chdl=${seriesKeys.map((s) => encodeSegment(String(s))).join('|')}`); + } +} diff --git a/packages/flint-js/src/image-charts/chart-types.ts b/packages/flint-js/src/image-charts/chart-types.ts new file mode 100644 index 00000000..7a66a373 --- /dev/null +++ b/packages/flint-js/src/image-charts/chart-types.ts @@ -0,0 +1,49 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Image-Charts chart-type mapping. + * + * Image-Charts renders through a fixed set of `cht` chart codes (the Google + * Image Charts / Image-Charts query grammar), so a Flint chart type maps to the + * closest native `cht`. Orientation (vertical vs horizontal) is decided by the + * assembler from channel semantics and selects the `bv*` vs `bh*` family. + * + * Coverage is partial by design (like the Excel backend): only chart types with + * a faithful `cht` equivalent are mapped. Everything else throws in `assemble`. + */ + +/** Which Image-Charts `cht` family a Flint chart type maps to. */ +export interface ImageChartsTypeMapping { + /** Base Image-Charts `cht` value (vertical / category-on-x orientation). */ + cht: string; + /** `cht` for the horizontal (category-on-y) variant, when supported. */ + horizontal?: string; + /** True for pie/doughnut charts: slice labels, no value/category axes. */ + noAxes?: boolean; + /** True for XY (both-measure) scatter charts rendered as `lxy`. */ + xy?: boolean; + /** True for radar charts, which use the `chxt=r` polar axis. */ + radar?: boolean; + /** True for area charts: a line (`lc`) plus a `chm=B` fill to the baseline. */ + area?: boolean; +} + +/** Flint chart type (display name) → Image-Charts `cht` family. */ +export const IMAGE_CHARTS_TYPE_MAP: Record = { + 'Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Grouped Bar Chart': { cht: 'bvg', horizontal: 'bhg' }, + 'Stacked Bar Chart': { cht: 'bvs', horizontal: 'bhs' }, + 'Line Chart': { cht: 'lc' }, + 'Sparkline': { cht: 'ls' }, + 'Area Chart': { cht: 'lc', area: true }, + 'Scatter Plot': { cht: 'lxy', xy: true }, + 'Pie Chart': { cht: 'p', noAxes: true }, + 'Donut Chart': { cht: 'pd', noAxes: true }, + 'Radar Chart': { cht: 'r', radar: true }, +}; + +/** Chart types this backend can render as an Image-Charts URL. */ +export function isImageChartsSupported(flintChartType: string): boolean { + return flintChartType in IMAGE_CHARTS_TYPE_MAP; +} diff --git a/packages/flint-js/src/image-charts/index.ts b/packages/flint-js/src/image-charts/index.ts new file mode 100644 index 00000000..ddc4957c --- /dev/null +++ b/packages/flint-js/src/image-charts/index.ts @@ -0,0 +1,28 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * @module flint-chart/image-charts + * + * Image-Charts backend for flint-chart. + * + * Compiles the core semantic layer into a single permanent + * `https://image-charts.com` chart URL (the Google Image Charts / Image-Charts + * query grammar). The URL renders server-side and embeds anywhere an `` + * works — email, PDF, Slack, no-code tools — with no runtime JavaScript. + * + * Architecture contrast with the other backends: + * VL: encoding-channel spec — { encoding: { x, y }, mark } + * EC: series-based option — { series: [...], xAxis, yAxis } + * CJS: dataset-based config — { type, data: { labels, datasets } } + * Excel: range/matrix spec — { chartType, data: [[...]], axes } + * Image-Charts: hosted-image URL — { type: 'image-charts', url } + * + * `assembleImageCharts` is PURE: it builds a string, performs no network I/O and + * no signing, and emits unsigned free-tier URLs only. + */ + +export { assembleImageCharts } from './assemble'; +export type { ImageChartsArtifact } from './assemble'; +export { IMAGE_CHARTS_TYPE_MAP, isImageChartsSupported } from './chart-types'; +export type { ImageChartsTypeMapping } from './chart-types'; diff --git a/packages/flint-js/src/index.ts b/packages/flint-js/src/index.ts index 824f9746..eeb753d1 100644 --- a/packages/flint-js/src/index.ts +++ b/packages/flint-js/src/index.ts @@ -57,3 +57,6 @@ export * from './plotly'; // Excel backend: assembleExcel + Excel chart spec types export * from './excel'; + +// Image-Charts backend: assembleImageCharts + hosted-image-URL artifact type +export * from './image-charts'; diff --git a/packages/flint-js/src/plotly/theme.ts b/packages/flint-js/src/plotly/theme.ts index 3e930e34..cd4809c2 100644 --- a/packages/flint-js/src/plotly/theme.ts +++ b/packages/flint-js/src/plotly/theme.ts @@ -23,7 +23,7 @@ * is one number for the whole figure rather than per mark. * - Text on marks is a trace property (`text` + `textposition`), and Plotly * places inside/outside labels itself — the geometry stage 2 computed - * (`insideMinValue`/`outsideMaxValue`) is handed over as `textposition: + * (`insideMinValue`) is handed over as `textposition: * 'auto'` rather than realized as two filtered layers. * - A figure may hold several subplot axis pairs (`xaxis2`, `yaxis3`, …) for * facets and composites. Every axis pass walks all of them. @@ -2472,7 +2472,7 @@ function applyDataLabels(figure: any, d: DesignDecisions, table: any[], say: Say } // Plotly places the label inside where it fits and outside where it // does not, which is exactly the geometry stage 2 computed with - // `insideMinValue`/`outsideMaxValue`. `auto` hands that decision to + // `insideMinValue`. `auto` hands that decision to // the renderer, which can measure the drawn bar; `outside` is // honoured literally because it is a house habit, not a fit. // A segment of a stack has no outside — "outside" is the middle of diff --git a/packages/flint-js/src/test-data/image-charts-tests.ts b/packages/flint-js/src/test-data/image-charts-tests.ts new file mode 100644 index 00000000..abd64f6a --- /dev/null +++ b/packages/flint-js/src/test-data/image-charts-tests.ts @@ -0,0 +1,123 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +/** + * Gallery generators for the Image-Charts backend. + * + * These cases exercise the URL-grammar paths the backend builds: a plain bar + * (`cht=bvg`, `chxl` categories), a multi-series grouped bar (`chco` + `chdl` + * legend), a line, a filled area (`chm=B`), a pie (per-slice `chl` + `chco`), + * and a scatter (`cht=lxy` + `chm=s` markers). The data is backend-agnostic — + * the gallery renders it through `assembleImageCharts`. + */ + +import { Type } from './df-types'; +import { TestCase, makeField, makeEncodingItem } from './types'; + +const CATEGORY_META = { type: Type.String, semanticType: 'Category', levels: [] as any[] }; +const QUANTITY_META = { type: Type.Number, semanticType: 'Quantity', levels: [] as any[] }; + +export function genImageChartsTests(): TestCase[] { + return [ + { + title: 'Bar — sales by region', + description: 'A single-series vertical bar, category labels on the x axis.', + tags: ['bar', 'nominal', 'quantitative', 'image-charts'], + chartType: 'Bar Chart', + data: [ + { Region: 'North', Sales: 42 }, + { Region: 'South', Sales: 35 }, + { Region: 'East', Sales: 58 }, + { Region: 'West', Sales: 27 }, + ], + fields: [makeField('Region'), makeField('Sales')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Region'), y: makeEncodingItem('Sales') }, + }, + { + title: 'Grouped bar — sales by region and channel', + description: 'Two series dodge per category, driving a per-series palette and a legend.', + tags: ['bar', 'grouped', 'series', 'legend', 'image-charts'], + chartType: 'Grouped Bar Chart', + data: [ + { Region: 'North', Sales: 42, Channel: 'Retail' }, + { Region: 'North', Sales: 20, Channel: 'Online' }, + { Region: 'South', Sales: 35, Channel: 'Retail' }, + { Region: 'South', Sales: 31, Channel: 'Online' }, + ], + fields: [makeField('Region'), makeField('Sales'), makeField('Channel')], + metadata: { Region: CATEGORY_META, Sales: QUANTITY_META, Channel: CATEGORY_META }, + encodingMap: { + x: makeEncodingItem('Region'), + y: makeEncodingItem('Sales'), + group: makeEncodingItem('Channel'), + }, + }, + { + title: 'Line — monthly signups', + description: 'An ordered category axis with a single quantitative series.', + tags: ['line', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Line Chart', + data: [ + { Month: '2026-01', Signups: 120 }, + { Month: '2026-02', Signups: 150 }, + { Month: '2026-03', Signups: 138 }, + { Month: '2026-04', Signups: 176 }, + ], + fields: [makeField('Month'), makeField('Signups')], + metadata: { + Month: { type: Type.String, semanticType: 'YearMonth', levels: [] }, + Signups: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Month'), y: makeEncodingItem('Signups') }, + }, + { + title: 'Area — traffic over time', + description: 'A line filled to the baseline via a chm=B marker.', + tags: ['area', 'temporal', 'quantitative', 'image-charts'], + chartType: 'Area Chart', + data: [ + { Day: '2026-01-01', Visits: 30 }, + { Day: '2026-01-02', Visits: 52 }, + { Day: '2026-01-03', Visits: 41 }, + { Day: '2026-01-04', Visits: 66 }, + ], + fields: [makeField('Day'), makeField('Visits')], + metadata: { + Day: { type: Type.Date, semanticType: 'Date', levels: [] }, + Visits: QUANTITY_META, + }, + encodingMap: { x: makeEncodingItem('Day'), y: makeEncodingItem('Visits') }, + }, + { + title: 'Pie — market share', + description: 'Slice labels and a per-slice palette.', + tags: ['pie', 'part-to-whole', 'image-charts'], + chartType: 'Pie Chart', + data: [ + { Vendor: 'Acme', Share: 45 }, + { Vendor: 'Globex', Share: 30 }, + { Vendor: 'Initech', Share: 15 }, + { Vendor: 'Umbrella', Share: 10 }, + ], + fields: [makeField('Vendor'), makeField('Share')], + metadata: { Vendor: CATEGORY_META, Share: QUANTITY_META }, + encodingMap: { color: makeEncodingItem('Vendor'), size: makeEncodingItem('Share') }, + }, + { + title: 'Scatter — weight vs mpg', + description: 'Two measures on lxy, drawn as chm=s point markers.', + tags: ['scatter', 'quantitative', 'image-charts'], + chartType: 'Scatter Plot', + data: [ + { Weight: 1.6, Mpg: 32 }, + { Weight: 2.1, Mpg: 27 }, + { Weight: 1.9, Mpg: 29 }, + { Weight: 2.4, Mpg: 24 }, + ], + fields: [makeField('Weight'), makeField('Mpg')], + metadata: { Weight: QUANTITY_META, Mpg: QUANTITY_META }, + encodingMap: { x: makeEncodingItem('Weight'), y: makeEncodingItem('Mpg') }, + }, + ]; +} diff --git a/packages/flint-js/src/test-data/index.ts b/packages/flint-js/src/test-data/index.ts index 3b441185..27761642 100644 --- a/packages/flint-js/src/test-data/index.ts +++ b/packages/flint-js/src/test-data/index.ts @@ -42,6 +42,7 @@ export { genLineAreaStretchTests } from './line-area-stretch-tests'; export { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; export { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; export { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +export { genImageChartsTests } from './image-charts-tests'; export { genDiscreteAxisTests } from './discrete-axis-tests'; export { genDateTests, genDateYearTests, genDateMonthTests, genDateYearMonthTests, genDateDecadeTests, genDateDateTimeTests, genDateHoursTests } from './date-tests'; export { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; @@ -118,6 +119,7 @@ import { genSemanticContextTests, genSnapToBoundTests } from './semantic-tests'; import { genEChartsScatterTests, genEChartsLineTests, genEChartsBarTests, genEChartsStackedBarTests, genEChartsGroupedBarTests, genEChartsStressTests, genEChartsAreaTests, genEChartsPieTests, genEChartsHeatmapTests, genEChartsHistogramTests, genEChartsBoxplotTests, genEChartsRadarTests, genEChartsCandlestickTests, genEChartsStreamgraphTests, genEChartsFacetSmallTests, genEChartsFacetWrapTests, genEChartsFacetClipTests, genEChartsRoseTests, genEChartsGaugeTests, genEChartsFunnelTests, genEChartsTreemapTests, genEChartsSunburstTests, genEChartsSankeyTests, genEChartsUniqueStressTests, genEChartsCalendarTests, genEChartsParallelTests, genEChartsGraphTests, genEChartsTreeTests } from './echarts-tests'; import { genChartJsScatterTests, genChartJsLineTests, genChartJsBarTests, genChartJsStackedBarTests, genChartJsGroupedBarTests, genChartJsAreaTests, genChartJsPieTests, genChartJsHistogramTests, genChartJsRadarTests, genChartJsStressTests, genChartJsRoseTests, genChartJsBubbleTests, genChartJsDoughnutTests, genChartJsComboTests } from './chartjs-tests'; import { genPlotlyCoreTests, genPlotlyFacetTests } from './plotly-tests'; +import { genImageChartsTests } from './image-charts-tests'; import { genGalleryRegionalSurveyScatterTests, genGalleryRegionalSurveyLineTests, @@ -259,6 +261,7 @@ export const TEST_GENERATORS: Record TestCase[]> = { 'Chart.js: Stress Tests': genChartJsStressTests, 'Plotly: Core Templates': genPlotlyCoreTests, 'Plotly: Facets': genPlotlyFacetTests, + 'Image-Charts: Core Templates': genImageChartsTests, 'Gallery: Scatter': genGalleryRegionalSurveyScatterTests, 'Gallery: Line': genGalleryRegionalSurveyLineTests, 'Gallery: Bar': genGalleryRegionalSurveyBarTests, diff --git a/packages/flint-js/src/vegalite/assemble.ts b/packages/flint-js/src/vegalite/assemble.ts index 4ef2a977..2ee4f14f 100644 --- a/packages/flint-js/src/vegalite/assemble.ts +++ b/packages/flint-js/src/vegalite/assemble.ts @@ -60,7 +60,7 @@ import { applyPivot, applyTransform, type PivotSurface, type TransformSurface } import { vlGetTemplateDef } from './templates'; import { inferVisCategory, computeZeroDecision } from '../core/semantic-types'; import { resolveChannelSemantics, convertTemporalData } from '../core/resolve-semantics'; -import { toTypeString, type SemanticAnnotation } from '../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, toTypeString, type SemanticAnnotation } from '../core/field-semantics'; import { filterOverflow } from '../core/filter-overflow'; import { computeLayout, computeChannelBudgets, computeMinSubplotDimensions, deriveStretchCaps, resolveBaseSize, resolveFacetColumnsOption } from '../core/compute-layout'; import { vlApplyLayoutToSpec, vlApplyTooltips } from './instantiate-spec'; @@ -842,7 +842,9 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { titled: Boolean(vgObj.title), headline: headlineText(vgObj.title), hostSurface: (input.options as any)?.background, - valueLabels: resolveValueLabelChoice(chartProperties), + valueLabels: chartTemplate.suppressValueLabels + ? 'off' + : resolveValueLabelChoice(chartProperties), geometryKinds: chartTemplate.geometryKinds, }); @@ -916,8 +918,8 @@ export function assembleVegaLite(input: ChartAssemblyInput): any { // whose template already writes its own text. Templates that print labels // *on request* are the exception: they answer to the toggle themselves. const designCoupledApplicability: Record = { - showValueLabels: ownsLabels - || (design?.dataLabels?.possible === true && !templateDrawsOwnText), + showValueLabels: !chartTemplate.suppressValueLabels && (ownsLabels + || (design?.dataLabels?.possible === true && !templateDrawsOwnText)), // The older spelling stays an accepted *input* for compatibility, but a // host should be shown one switch, not two that fight. showTextLabels: false, @@ -1312,6 +1314,17 @@ function buildVLEncodings( encodingObj.title = fieldDisplayNames[fieldName]; } + // A lexical unit explicitly declared by the author belongs once with + // the field name, independent of whether a visual theme is applied. + const displayUnit = resolveDisplayUnit(cs?.semanticAnnotation); + if ((channel === 'x' || channel === 'y') && cs?.type === 'quantitative' + && displayUnit?.placement === 'field' && encodingObj.title !== null) { + const currentTitle = typeof encodingObj.title === 'string' + ? encodingObj.title + : fieldName; + if (currentTitle) encodingObj.title = titleWithDisplayUnit(currentTitle, displayUnit); + } + // --- Collect resolved encoding --- if (Object.keys(encodingObj).length !== 0) { resolvedEncodings[channel] = encodingObj; diff --git a/packages/flint-js/src/vegalite/instantiate-spec.ts b/packages/flint-js/src/vegalite/instantiate-spec.ts index d89ce135..6e70a9f5 100644 --- a/packages/flint-js/src/vegalite/instantiate-spec.ts +++ b/packages/flint-js/src/vegalite/instantiate-spec.ts @@ -553,6 +553,54 @@ function computeStackedExtremes( return { maxPos, minNeg }; } +const NICE_E10 = Math.sqrt(50); +const NICE_E5 = Math.sqrt(10); +const NICE_E2 = Math.SQRT2; + +function niceStackSpan(start: number, stop: number, count: number): [number, number] { + let lo = start; + let hi = stop; + let previousStep: number | undefined; + for (let index = 0; index < 32; index += 1) { + const rawStep = (hi - lo) / Math.max(1, count); + const power = Math.floor(Math.log10(rawStep)); + const error = rawStep / 10 ** power; + const factor = error >= NICE_E10 ? 10 : error >= NICE_E5 ? 5 : error >= NICE_E2 ? 2 : 1; + const step = power >= 0 ? factor * 10 ** power : -(10 ** -power) / factor; + if (step === previousStep || step === 0 || !Number.isFinite(step)) break; + if (step > 0) { + lo = Math.floor(lo / step) * step; + hi = Math.ceil(hi / step) * step; + } else { + lo = Math.ceil(lo * step) / step; + hi = Math.floor(hi * step) / step; + } + previousStep = step; + } + return [lo, hi]; +} + +/** + * Pin a positive sum stack that already ends on the clean tick `nice` would + * choose. Stored calculated shares can total 99.9999999999; leaving that to + * Vega's post-stack arithmetic may cross the tick by a rounding bit and add a + * whole empty interval. A meaningful excess remains on automatic nice. + */ +function pinCleanStackEndpoint(enc: any, extremes: { maxPos: number; minNeg: number }): void { + if (extremes.minNeg < 0 || !(extremes.maxPos > 0)) return; + if (enc.scale?.domain != null || enc.scale?.domainMax != null || enc.scale?.nice === false) return; + const count = typeof enc.scale?.nice === 'number' ? enc.scale.nice : 10; + const tolerance = Math.max(1, Math.abs(extremes.maxPos)) * 1e-9; + const [, cleanMax] = niceStackSpan(0, extremes.maxPos - tolerance, count); + if (Math.abs(cleanMax - extremes.maxPos) > tolerance) return; + enc.scale = { + ...(enc.scale ?? {}), + domainMin: enc.scale?.domainMin ?? 0, + domainMax: cleanMax, + nice: false, + }; +} + /** * Detect whether a discrete category repeats across rows — i.e., multiple rows * share the same category value, which makes Vega-Lite stack the measure even @@ -745,11 +793,15 @@ function vlApplyFieldContext( const otherChannel = ch === 'y' ? 'x' : 'y'; const otherCS = channelSemantics[otherChannel]; const otherIsDiscrete = otherCS?.type === 'nominal' || otherCS?.type === 'ordinal'; - const isImplicitlyStacked = isBarLike && otherIsDiscrete && enc.stack !== null - && (hasColorEncoding || hasRepeatedCategory(context.table, otherCS?.field, enc.field)); + const isImplicitlyStacked = isBarLike && enc.stack !== null + && (hasColorEncoding + || (otherIsDiscrete && hasRepeatedCategory(context.table, otherCS?.field, enc.field))); const isStacked = isExplicitlyStacked || isImplicitlyStacked; const isNormalizeStacked = enc.stack === 'normalize'; const isSumStacked = isStacked && !isNormalizeStacked; + const stackedExtremes = isSumStacked + ? computeStackedExtremes(context.table, enc.field, ch, channelSemantics) + : undefined; // For sum-stacked charts, check if stacked totals exceed the // intrinsic domain. If they do, skip the domain constraint. @@ -770,9 +822,7 @@ function vlApplyFieldContext( // can't find the intrinsic bounds to snap totals against. const intrinsic = getEffectiveIntrinsicDomain(cs, context.table, enc.field); if (intrinsic) { - const extremes = computeStackedExtremes( - context.table, enc.field, ch, channelSemantics, - ); + const extremes = stackedExtremes; if (extremes !== undefined) { // VL stacks positive and negative contributions @@ -866,6 +916,8 @@ function vlApplyFieldContext( } } + if (stackedExtremes) pinCleanStackEndpoint(enc, stackedExtremes); + // ── 4. Tick constraint (axis.tickMinStep + axis.values) ── // Skip binned encodings — VL handles bin ticks natively. // Without this: Rating 1-5 and Count axes show fractional ticks diff --git a/packages/flint-js/src/vegalite/templates/bar-table.ts b/packages/flint-js/src/vegalite/templates/bar-table.ts index eaab140a..cd807eae 100644 --- a/packages/flint-js/src/vegalite/templates/bar-table.ts +++ b/packages/flint-js/src/vegalite/templates/bar-table.ts @@ -3,7 +3,7 @@ import { ChartTemplateDef, ChartPropertyDef, ChannelSemantics } from '../../core/types'; import { getRegistryEntry } from '../../core/type-registry'; -import type { FormatSpec } from '../../core/field-semantics'; +import { resolveDisplayUnit, titleWithDisplayUnit, type FormatSpec } from '../../core/field-semantics'; import { formatSpecToVegaExpr } from '../format'; /** @@ -36,6 +36,7 @@ export const barTableDef: ChartTemplateDef = { }, channels: ["y", "x", "color", "column", "row"], markCognitiveChannel: 'length', + suppressValueLabels: true, declareLayoutMode: (cs, table, chartProperties) => { // Bar tables split the plot width into 3 horizontal panels // (bar | % | value), so they need a wider canvas than a basic @@ -268,7 +269,6 @@ export const barTableDef: ChartTemplateDef = { // Derived directly from field names; no override knobs. const categoryHeader = yField; const percentHeader = '%'; - const valueHeader = xField; // headerStyle.fontSize is set below once the responsive // `fontSize` constant is available. @@ -284,7 +284,19 @@ export const barTableDef: ChartTemplateDef = { // The %-share column (panel 1) is a different story: it's a // *derived* 0..1 ratio computed by us, so it always needs `%` // formatting. That's `pctPattern` below. - const valueFmt: FormatSpec | undefined = xCS?.format; + const displayUnit = resolveDisplayUnit(xCS?.semanticAnnotation); + const valueFmt: FormatSpec | undefined = displayUnit?.placement === 'value' + ? { + ...(xCS?.format ?? {}), + ...(displayUnit.position === 'prefix' && !xCS?.format?.prefix + ? { prefix: displayUnit.text } + : {}), + ...(displayUnit.position === 'suffix' && !xCS?.format?.suffix + ? { suffix: /^[A-Za-z]/.test(displayUnit.text) ? ` ${displayUnit.text}` : displayUnit.text } + : {}), + } + : xCS?.format; + const valueHeader = titleWithDisplayUnit(xField, displayUnit); const pctPattern = '.1%'; // ── Text-panel transforms ──────────────────────────────────── @@ -616,13 +628,14 @@ export const barTableDef: ChartTemplateDef = { outFieldHint: string, ): any => { if (!fmt || (!fmt.pattern && !fmt.prefix && !fmt.suffix)) { - return { field: sourceField, type: 'quantitative' }; - } - if (!fmt.abbreviate && fmt.pattern && !fmt.prefix && !fmt.suffix) { - return { field: sourceField, type: 'quantitative', format: fmt.pattern }; + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; } const formatExpr = formatSpecToVegaExpr(fmt, `datum[${JSON.stringify(sourceField)}]`); - if (!formatExpr) return { field: sourceField, type: 'quantitative' }; + if (!formatExpr) { + transformsOut.push({ calculate: `datum[${JSON.stringify(sourceField)}] + ''`, as: outFieldHint }); + return { field: outFieldHint, type: 'nominal' }; + } transformsOut.push({ calculate: formatExpr, as: outFieldHint, diff --git a/packages/flint-js/src/vegalite/theme.ts b/packages/flint-js/src/vegalite/theme.ts index 21a32be1..7cdea15d 100644 --- a/packages/flint-js/src/vegalite/theme.ts +++ b/packages/flint-js/src/vegalite/theme.ts @@ -275,12 +275,12 @@ export function realizeThemeVegaLite(spec: any, d: DesignDecisions, table: any[] harmonizeLinePoints(spec, d, table, say); applyConnectors(spec, d, say); applyRedundantChannels(spec, d, say); - demoteSeriesEnd(spec, d, say); + const seriesEndLayout = demoteSeriesEnd(spec, d, table, say); applyLegend(spec, config, d, table, say); applyFacetChrome(config, d); applyPanelTitles(spec, d, say); const valueLayer = applyDataLabels(spec, d, table, say); - applySeriesEndLabels(spec, d, valueLayer, table, say); + applySeriesEndLabels(spec, d, valueLayer, table, say, seriesEndLayout); applyPointEmphasis(spec, d, say); applyPrintedUnits(spec, d, say); applyStatistics(spec, d, table, say); @@ -738,9 +738,15 @@ function applyAxes(spec: any, config: any, d: DesignDecisions, table: any[], say const rightSeated = side === 'right'; // The title clears the topmost value instead of sitting on // it, so the lift carries a line of the label's own size. + // A column facet owns the next line above the plot; clear + // that header too instead of laying the shared y title on + // the final panel's name. const labelSize = axis.label.fontSize ?? 11; const gap = axis.title.gap ?? (axis.title.fontSize ?? 11) + 6; - const lift = gap + Math.round(labelSize * 0.75); + const headerClearance = d.facets.header.show && hasTopFacetHeader(spec) + ? Math.round((d.facets.header.fontSize ?? 11) * 1.7) + : 0; + const lift = gap + Math.round(labelSize * 0.75) + headerClearance; enc.axis = { ...(enc.axis ?? {}), titleAngle: 0, @@ -1441,6 +1447,15 @@ function panelCount(spec: any, table: any[]): number { return panels; } +function hasTopFacetHeader(spec: any): boolean { + let found = false; + walk(spec, (node) => { + if (node.encoding?.facet?.field || node.encoding?.column?.field + || node.facet?.field || node.facet?.column?.field) found = true; + }); + return found; +} + /** * Whether the spec draws a dot per row — a scatter, a strip, a dot plot. Only * then does the crowding budget below have a claim on the plot's area: a @@ -4594,9 +4609,9 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa delete labelEncoding.theta; } - // A label goes where there is room. A mark shorter than its own label - // cannot hold it, and a mark that reaches the end of the scale has no room - // past its end — so each case sends those few labels the other way. + // Inside placement has one legibility exception: a mark shorter than its + // own label cannot hold it, so that label moves outside. Outside placement + // is chart-wide and never flips only the longest mark inward. // Vega-Lite has no conditional `align`, so this is two layers with // complementary filters. const flipInk = (within: boolean): string | undefined => { @@ -4620,13 +4635,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa say('dataLabels.placement', message); }; - // A vertical bar's outside label is cleared by giving the measure scale - // headroom (below); a horizontal one by reserving right margin. The - // scale-end flip — printing the tallest bars' labels inside instead — - // solves the same "no room past the end" problem, so it is only needed - // where headroom is not the remedy: on horizontal bars. - const headroomClears = !inside && onMarkBody && !horizontal && !radial && !cells; - // A stacked segment is exempt: "outside" a segment is the top of the // stack, a different quantity. Segments too short for their number drop it // instead, which the keep test above already arranges. @@ -4634,8 +4642,6 @@ function labelOneBody(spec: any, body: any, d: DesignDecisions, table: any[], sa if (inside && d.dataLabels.insideMinValue != null) { split(d.dataLabels.insideMinValue, '<', 'marks shorter than their own label print it outside instead'); growPadding(spec, horizontal ? 'right' : 'top', (t.fontSize ?? 10) * 2); - } else if (!inside && d.dataLabels.outsideMaxValue != null && !headroomClears) { - split(d.dataLabels.outsideMaxValue, '>', 'marks that reach the end of the scale print their label inside instead'); } } @@ -4778,9 +4784,19 @@ function addMeasureHeadroom( * *before* the legend is drawn — once the colour legends have been suppressed * in favour of end labels there is nothing to fall back to. */ -function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: string) => void): void { - if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; - if (!d.legend.show) return; +interface SeriesEndLayout { + adjustedValues: Map; + maxDisplacement: number; +} + +function demoteSeriesEnd( + spec: any, + d: DesignDecisions, + table: any[], + say: (p: string, m: string) => void, +): SeriesEndLayout | undefined { + if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return undefined; + if (!d.legend.show) return undefined; const body = plotBody(spec); // A band carries its own end label inside itself, so it counts as a run // with an end just as much as a line does. @@ -4816,14 +4832,158 @@ function demoteSeriesEnd(spec: any, d: DesignDecisions, say: (p: string, m: stri : marginTaken ? `the ${runsAlongX ? 'right' : 'top'} margin holds the value axis, so a name too big for its band has nowhere to stand` : null)); - if (!reason) return; + const collision = !reason && !bands + ? planSeriesEndLayout(spec, d, table, enc, field) + : undefined; + const finalReason = reason ?? collision?.reason; + if (!finalReason) return collision?.layout; // The house ranked its placements; a demotion should land on the next one // it named, not on whatever this function happens to prefer. const next = d.legend.fallbacks?.find((p) => p !== 'seriesEnd' && p !== 'inline') ?? 'right'; - say('legend.placement', `${reason} — the key is drawn \`${next}\` instead`); + say('legend.placement', `${finalReason} — the key is drawn \`${next}\` instead`); d.legend.placement = next; d.legend.orient = next === 'inside' ? 'top-right' : next as any; d.legend.direction = next === 'top' || next === 'bottom' ? 'horizontal' : 'vertical'; + return undefined; +} + +function planSeriesEndLayout( + spec: any, + d: DesignDecisions, + table: any[], + enc: any, + seriesField: string | undefined, +): { layout?: SeriesEndLayout; reason?: string } { + if (!seriesField || runChannel(d) !== 'x') return {}; + if (d.bound.isFaceted) return { reason: '`seriesEnd` collision checks do not guess across facet scales' }; + if (d.bound.seriesCount > 8) return { reason: '`seriesEnd` is limited to eight series so the margin stays readable' }; + + const domain = enc.x; + const value = enc.y; + if (!domain?.field || !value?.field || value.type !== 'quantitative') return {}; + if (value.scale?.type && value.scale.type !== 'linear') { + return { reason: '`seriesEnd` collision checks need a linear value scale' }; + } + + const orderedDomain = domain.type === 'quantitative' || domain.type === 'temporal' || domain.type === 'ordinal'; + const explicitOrder: Map | undefined = Array.isArray(domain.sort) + ? new Map(domain.sort.map((entry: unknown, index: number): [unknown, number] => [entry, index])) + : undefined; + const comparable = (raw: unknown): number | undefined => { + if (explicitOrder) return explicitOrder.get(raw); + if (domain.type === 'temporal') { + const time = raw instanceof Date ? raw.getTime() : Date.parse(String(raw)); + return Number.isFinite(time) ? time : undefined; + } + const number = Number(raw); + return Number.isFinite(number) ? number : undefined; + }; + + const endpoints = new Map(); + const allDomain: number[] = []; + const allValues: number[] = []; + table.forEach((row, order) => { + const series = row?.[seriesField]; + const domainValue = comparable(row?.[domain.field]); + const valueNumber = Number(row?.[value.field]); + if (series == null || domainValue == null || !Number.isFinite(valueNumber)) return; + allDomain.push(domainValue); + allValues.push(valueNumber); + const previous = endpoints.get(series); + const takesEnd = !previous || (orderedDomain + ? (domain.sort === 'descending' ? domainValue < previous.domain : domainValue > previous.domain) + : order > previous.order); + if (takesEnd) endpoints.set(series, { domain: domainValue, value: valueNumber, order }); + }); + if (endpoints.size < 2 || allDomain.length < 2 || allValues.length < 2) return {}; + + const plotWidth = Number(d.layout.plotWidth ?? spec.width); + const plotHeight = Number(d.layout.plotHeight ?? plotBody(spec).height ?? spec.height); + if (!(plotWidth > 0) || !(plotHeight > 0)) return { reason: '`seriesEnd` could not measure the plot for collision checks' }; + + const domainMin = Math.min(...allDomain); + const domainMax = Math.max(...allDomain); + const domainSpan = domainMax - domainMin; + if (!(domainSpan > 0)) return {}; + const endDomain = Array.from(endpoints.values(), (endpoint) => endpoint.domain); + const endSpreadPx = (Math.max(...endDomain) - Math.min(...endDomain)) / domainSpan * plotWidth; + const uniqueDomain = [...new Set(allDomain)].sort((a, b) => a - b); + const steps = uniqueDomain.slice(1).map((entry, index) => entry - uniqueDomain[index]).filter((step) => step > 0); + const medianStep = steps.length + ? steps.sort((a, b) => a - b)[Math.floor(steps.length / 2)] / domainSpan * plotWidth + : 0; + const alignmentTolerance = Math.max(8, medianStep * 0.25); + + let valueMin = value.scale?.domainMin ?? Math.min(...allValues); + let valueMax = value.scale?.domainMax ?? Math.max(...allValues); + if (Array.isArray(value.scale?.domain) && value.scale.domain.length >= 2) { + valueMin = Number(value.scale.domain[0]); + valueMax = Number(value.scale.domain[1]); + } + if (value.scale?.zero !== false) { + valueMin = Math.min(0, valueMin); + valueMax = Math.max(0, valueMax); + } + const valueSpan = valueMax - valueMin; + if (!(valueSpan > 0)) return {}; + + const reversed = value.scale?.reverse === true; + const toPixel = (number: number) => reversed + ? (number - valueMin) / valueSpan * plotHeight + : (valueMax - number) / valueSpan * plotHeight; + const fromPixel = (pixel: number) => reversed + ? valueMin + pixel / plotHeight * valueSpan + : valueMax - pixel / plotHeight * valueSpan; + const fontSize = Math.max(9, (d.legend.label.fontSize ?? 11) - 1); + const separation = fontSize + 2; + const naturalRows = Array.from(endpoints.values(), (endpoint) => toPixel(endpoint.value)) + .sort((a, b) => a - b); + const naturallyCollides = naturalRows.some((pixel, index) => + index > 0 && pixel - naturalRows[index - 1] < separation); + if (!naturallyCollides) return {}; + if (endSpreadPx > alignmentTolerance) { + return { reason: `series-end labels overlap and their endpoints span ${Math.round(endSpreadPx)}px horizontally, so they cannot be dodged as one column` }; + } + if (endpoints.size * separation > plotHeight) { + return { reason: '`seriesEnd` labels cannot fit vertically without overlap' }; + } + + const packed = Array.from(endpoints, ([series, endpoint]) => ({ + series, + value: endpoint.value, + desired: toPixel(endpoint.value), + placed: toPixel(endpoint.value), + })).sort((a, b) => a.desired - b.desired); + // A label centred on the top or bottom endpoint may straddle the plot + // boundary; Vega includes that text in the figure bounds. Pulling it half + // a line inward creates a needless dodge and disconnects it from the + // endpoint. Keep boundary labels pinned and pack only their neighbours. + const minCenter = 0; + const maxCenter = plotHeight; + packed[0].placed = Math.max(minCenter, packed[0].desired); + for (let index = 1; index < packed.length; index += 1) { + packed[index].placed = Math.max(packed[index].desired, packed[index - 1].placed + separation); + } + const overflow = packed[packed.length - 1].placed - maxCenter; + if (overflow > 0) packed.forEach((entry) => { entry.placed -= overflow; }); + for (let index = packed.length - 2; index >= 0; index -= 1) { + packed[index].placed = Math.min(packed[index].placed, packed[index + 1].placed - separation); + } + if (packed[0].placed < minCenter) { + const shift = minCenter - packed[0].placed; + packed.forEach((entry) => { entry.placed += shift; }); + } + + const maxDisplacement = Math.max(...packed.map((entry) => Math.abs(entry.placed - entry.desired))); + if (maxDisplacement > fontSize) { + return { reason: `series-end labels need ${Math.round(maxDisplacement)}px of dodge, more than one line of text` }; + } + return { + layout: { + adjustedValues: new Map(packed.map((entry) => [entry.series, fromPixel(entry.placed)])), + maxDisplacement, + }, + }; } /** @@ -4846,6 +5006,7 @@ function applySeriesEndLabels( valueLayer: any, table: any[], say: (p: string, m: string) => void, + layout?: SeriesEndLayout, ): void { if (d.legend.placement !== 'seriesEnd' && d.legend.placement !== 'inline') return; if (!d.legend.show) return; @@ -4964,6 +5125,18 @@ function applySeriesEndLabels( 'series name and final value merged into one label — they compete for the same space'); } + let labelValue = value; + if (layout?.maxDisplacement && layout.maxDisplacement > 0.5) { + const series = `datum[${JSON.stringify(seriesField)}]`; + let adjusted = `datum[${JSON.stringify(value.field)}]`; + for (const [name, number] of layout.adjustedValues) { + adjusted = `${series} === ${JSON.stringify(name)} ? ${number} : (${adjusted})`; + } + transform.push({ calculate: adjusted, as: '__seriesEndLabelValue' }); + labelValue = { ...value, field: '__seriesEndLabelValue' }; + say('legend.placement', `series-end labels dodged by at most ${Math.round(layout.maxDisplacement)}px to avoid overlap`); + } + const labelLayer: any = { __themeSynthetic: true, transform, @@ -4974,13 +5147,13 @@ function applySeriesEndLabels( dx: domainChannel === 'x' ? 5 : 0, dy: domainChannel === 'x' ? 0 : -5, font: t.font, - fontSize: t.fontSize, + fontSize: Math.max(9, (t.fontSize ?? 11) - 1), ...(t.fontWeight ? { fontWeight: t.fontWeight } : {}), ...(t.fontStyle ? { fontStyle: t.fontStyle } : {}), }, encoding: { [domainChannel]: stripAxis(domain), - [valueChannel]: stripAxis(value), + [valueChannel]: layout ? { ...stripAxis(labelValue), title: null } : stripAxis(labelValue), text: { field: textField, type: 'nominal' }, ...(colourEnc?.field ? { color: { ...colourEnc, legend: null } } : {}), }, diff --git a/packages/flint-js/tests/bar-table-labels.test.ts b/packages/flint-js/tests/bar-table-labels.test.ts new file mode 100644 index 00000000..0d06e9ad --- /dev/null +++ b/packages/flint-js/tests/bar-table-labels.test.ts @@ -0,0 +1,57 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; + +describe('Bar Table labels', () => { + const titleText = (title: string | string[]) => Array.isArray(title) ? title.join(' ') : title; + + function barTable(unit?: string, field = 'life_expect_gain'): any { + return assembleVegaLite({ + data: { values: [ + { country: 'Peru', [field]: 33.49 }, + { country: 'Iran', [field]: 32.34 }, + ] }, + semantic_types: { + country: 'Country', + [field]: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Table', + encodings: { y: 'country', x: field }, + baseSize: { width: 600, height: 300 }, + }, + theme_spec: 'nyt', + } as any) as any; + } + + it('does not repeat the value as a generic annotation on each bar', () => { + const spec = barTable('years'); + + expect(spec.hconcat[0].mark.type).toBe('bar'); + expect(spec.hconcat[0].layer).toBeUndefined(); + const valuePanel = spec.hconcat.at(-1); + expect(valuePanel.mark.type).toBe('text'); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + expect(valuePanel.encoding.text.type).toBe('nominal'); + expect(JSON.stringify(valuePanel.transform)).not.toContain('years'); + }); + + it('prints a declared compact unit beside values', () => { + const valuePanel = barTable('kg').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).toContain(' kg'); + }); + + it('does not display an undeclared unit', () => { + const valuePanel = barTable().hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain'); + expect(JSON.stringify(valuePanel.transform)).not.toMatch(/years| kg/); + }); + + it('does not duplicate a lexical unit already present in the field name', () => { + const valuePanel = barTable('years', 'life_expect_gain (years)').hconcat.at(-1); + expect(titleText(valuePanel.title.text)).toBe('life_expect_gain (years)'); + }); +}); \ No newline at end of file diff --git a/packages/flint-js/tests/series-end-collision.test.ts b/packages/flint-js/tests/series-end-collision.test.ts new file mode 100644 index 00000000..a4b3029d --- /dev/null +++ b/packages/flint-js/tests/series-end-collision.test.ts @@ -0,0 +1,106 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import type { ThemeSpec } from '../src/core/theme/types'; + +const theme: ThemeSpec = { + id: 'series-end-test', + label: 'Series end test', + ink: { + surface: { canvas: '#fff', plot: '#fff' }, + text: { primary: '#111' }, + series: { single: '#333', categorical: ['#1261a0', '#d1495b', '#2a9d8f', '#725ac1'] }, + }, + legend: { show: 'always', placement: ['seriesEnd', 'right'] }, +} as ThemeSpec; + +function rows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 70 - seriesIndex * 4 - index * 5, + })); + }); +} + +function build(endValues: number[], endYears?: number[]): any { + return assembleVegaLite({ + data: { values: rows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 480, height: 300 }, + }, + theme_spec: theme, + } as any) as any; +} + +function layers(spec: any): any[] { + const body = spec.layer ?? []; + return Array.isArray(body) ? body : []; +} + +function endLabel(spec: any): any | undefined { + return layers(spec).find((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'text' && ['series', '__seriesEndLabel'].includes(layer.encoding?.text?.field); + }); +} + +function messages(spec: any): string { + return (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message) + .join(' '); +} + +describe('series-end collision policy', () => { + it('keeps aligned, separated endpoints directly labelled', () => { + const spec = build([20, 40, 60]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(endLabel(spec)?.mark.fontSize).toBe(10); + expect(messages(spec)).toContain('synthesized text layer'); + }); + + it('slightly dodges close labels without adding connector ticks', () => { + const spec = build([50, 52]); + expect(endLabel(spec)?.encoding.y.field).toBe('__seriesEndLabelValue'); + expect(layers(spec).some((layer) => { + const mark = typeof layer.mark === 'string' ? layer.mark : layer.mark?.type; + return mark === 'rule' && layer.encoding?.y2?.field === '__seriesEndLabelValue'; + })).toBe(false); + expect(messages(spec)).toMatch(/dodged by at most \d+px/); + }); + + it.each([ + { edge: 'top', values: [25, 48, 72] }, + { edge: 'bottom', values: [0, 25, 48] }, + ])('keeps a label on the $edge boundary anchored to its endpoint', ({ values }) => { + const spec = build(values); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('dodged by at most'); + }); + + it('falls back as a set when dense labels need too much displacement', () => { + const spec = build([50, 51, 52, 53]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/more than one line of text/); + }); + + it('keeps staggered endpoints direct when their labels do not collide', () => { + const spec = build([20, 45, 70], [2020, 2010, 2000]); + expect(endLabel(spec)?.encoding.y.field).toBe('value'); + expect(messages(spec)).not.toContain('key is drawn'); + }); + + it('falls back when staggered endpoint labels actually collide', () => { + const spec = build([50, 52], [2020, 2000]); + expect(endLabel(spec)).toBeUndefined(); + expect(messages(spec)).toMatch(/labels overlap.*cannot be dodged as one column/); + }); +}); diff --git a/packages/flint-js/tests/slope.test.ts b/packages/flint-js/tests/slope.test.ts index 3490ea8e..375fd83d 100644 --- a/packages/flint-js/tests/slope.test.ts +++ b/packages/flint-js/tests/slope.test.ts @@ -144,6 +144,21 @@ describe('ECharts Slope chart', () => { expect(option.yAxis.type).toBe('value'); }); + it('anchors the color legend from the right so chart.resize() keeps the gutter', () => { + // Design-canvas `left` (e.g. 422 of 534) overlaps the plot once the host + // is wider than `_width`. `right` is the inset to the legend box edge. + expect(option.legend.right).toBe(16); + expect(option.legend.left).toBeUndefined(); + expect(option.legend.orient).toBe('vertical'); + expect(option.grid.right).toBeGreaterThan(option.legend.right); + const title = (option.graphic ?? []).find( + (g: { type?: string; style?: { fontWeight?: string } }) => + g.type === 'text' && g.style?.fontWeight === 'bold', + ); + expect(title?.right).toBe(16); + expect(title?.left).toBeUndefined(); + }); + it('orders temporal year periods as two ordered categories', () => { const temporal = byTitle( cases, diff --git a/packages/flint-js/tests/smoke.test.ts b/packages/flint-js/tests/smoke.test.ts index 0b0bdb5f..c6aaad15 100644 --- a/packages/flint-js/tests/smoke.test.ts +++ b/packages/flint-js/tests/smoke.test.ts @@ -8,6 +8,7 @@ import { assembleChartjs, assemblePlotly, assembleExcel, + assembleImageCharts, } from '../src'; const DATA = [ @@ -98,6 +99,41 @@ describe('public API smoke', () => { expect(spec.seriesBy).toBe('Columns'); }); + it('assembleImageCharts returns a permanent free-tier Image-Charts URL', () => { + const artifact = assembleImageCharts({ + data: { values: [ + { Category: 'A', Value: 10 }, + { Category: 'B', Value: 20 }, + { Category: 'C', Value: 15 }, + ] }, + semantic_types: { Category: 'Category', Value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'Category', y: 'Value' }, + title: 'Sales by region', + }, + }); + + expect(artifact.type).toBe('image-charts'); + expect(artifact.url.startsWith('https://image-charts.com/chart?')).toBe(true); + expect(artifact.url).toContain('cht=bvg'); + expect(artifact.url).toContain('chd=a:10,20,15'); + expect(artifact.url).toContain('chxl=0:|A|B|C'); + expect(artifact.url).toContain('chtt=Sales+by+region'); + // Free tier only: never signed, never an output override. + expect(artifact.url).not.toContain('icac'); + expect(artifact.url).not.toContain('ichm'); + expect(artifact.url).not.toContain('chof'); + }); + + it('assembleImageCharts throws on chart types with no faithful cht', () => { + expect(() => assembleImageCharts({ + data: { values: [{ Group: 'A', Value: 1 }, { Group: 'A', Value: 5 }] }, + semantic_types: { Group: 'Category', Value: 'Quantity' }, + chart_spec: { chartType: 'Boxplot', encodings: { x: 'Group', y: 'Value' } }, + })).toThrow('does not support chart type "Boxplot"'); + }); + it('assembleExcel uses field display names for native axis titles', () => { const spec = assembleExcel({ data: { values: [ diff --git a/packages/flint-js/tests/theme-axis-labels.test.ts b/packages/flint-js/tests/theme-axis-labels.test.ts index 6b1408b3..5aa5f8da 100644 --- a/packages/flint-js/tests/theme-axis-labels.test.ts +++ b/packages/flint-js/tests/theme-axis-labels.test.ts @@ -2,6 +2,7 @@ // Licensed under the MIT License. import { describe, it, expect } from 'vitest'; +import { compile } from 'vega-lite'; import { assembleVegaLite } from '../src'; /** @@ -114,3 +115,88 @@ describe('an axis is ticked at observations only where they are a step', () => { expect(enc.axis?.values).toEqual([2012, 2016, 2020, 2024]); }); }); + +describe('stacked measure endpoints', () => { + function stackedArea(total: number): any { + const values = [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: total - 40 }, + { year: 2001, cluster: 'A', share: 35 }, + { year: 2001, cluster: 'B', share: total - 35 }, + ]; + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + theme_spec: 'swiss', + } as any); + return out.spec ?? out; + } + + it('keeps a clean stacked maximum flush with the axis', () => { + const spec = stackedArea(100); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('treats floating-point residue from calculated shares as flush', () => { + const yearlyShares = [ + ['1955', 22.7131238639, 16.6700124857, 2.9797012748, 16.2510873496, 38.8083620953, 2.5777129306], + ['1960', 23.1673306654, 15.8887417506, 3.0347038067, 16.6089891163, 38.6352683699, 2.6649662911], + ['1965', 23.5800548972, 15.0968881093, 3.1139372122, 16.7420827415, 38.6837925492, 2.7832444907], + ['1970', 23.8122264795, 14.1851153816, 3.1965034231, 16.5995289954, 39.3139453013, 2.8926804191], + ['1975', 24.1962723441, 13.3226671714, 3.3192782807, 16.5053852188, 39.6345427027, 3.0218542823], + ['1980', 24.9156312003, 12.5591286953, 3.5311844524, 16.5577898534, 39.2200529277, 3.2162128709], + ['1985', 25.6874049251, 11.8031165523, 3.7351451602, 16.4680484502, 38.8244431604, 3.4818417517], + ['1990', 26.3861262603, 11.0895550616, 3.9583434243, 16.3135719813, 38.5452173843, 3.7071858881], + ['1995', 27.3018090463, 10.5337539377, 4.0952183708, 16.374040122, 37.8327450169, 3.8624335062], + ['2000', 28.247815136, 10.0455245409, 4.3245615068, 16.4175767489, 36.9529066531, 4.0116154143], + ['2005', 29.121162734, 9.7053050731, 4.5674750342, 16.3698617817, 36.0714490806, 4.1647462963], + ] as const; + const values = yearlyShares.flatMap(([year, ...shares]) => + shares.map((population_share, cluster) => ({ year, cluster: String(cluster), population_share })) + ); + const out: any = assembleVegaLite({ + data: { values }, + semantic_types: { year: 'Year', cluster: 'Category', population_share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'population_share', color: 'cluster' }, + baseSize: { width: 300, height: 300 }, + title: 'Population share by cluster over time', + subtitle: 'Shares are calculated within each year', + }, + } as any); + const spec = out.spec ?? out; + const totals = yearlyShares.map(([, ...shares]) => shares.reduce((sum, share) => sum + share, 0)); + expect(Math.max(...totals)).toBeGreaterThan(100); + expect(Math.max(...totals)).toBeCloseTo(100, 8); + expect(spec.encoding.y.scale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + + const compiled = compile(spec).spec as any; + const yScale = compiled.scales.find((scale: any) => scale.name === 'y'); + expect(yScale).toMatchObject({ domainMin: 0, domainMax: 100, nice: false }); + }); + + it('leaves a meaningful stacked excess eligible for outward nice rounding', () => { + const out: any = assembleVegaLite({ + data: { values: [ + { year: 2000, cluster: 'A', share: 40 }, + { year: 2000, cluster: 'B', share: 60.3 }, + ] }, + semantic_types: { year: 'Year', cluster: 'Category', share: 'Quantity' }, + chart_spec: { + chartType: 'Area Chart', + encodings: { x: 'year', y: 'share', color: 'cluster' }, + baseSize: { width: 400, height: 300 }, + }, + } as any); + const spec = out.spec ?? out; + expect(spec.encoding.y.scale.domainMax).toBeUndefined(); + expect(spec.encoding.y.scale.nice).not.toBe(false); + }); + +}); diff --git a/packages/flint-js/tests/theme-titles.test.ts b/packages/flint-js/tests/theme-titles.test.ts index 731dc598..83c00906 100644 --- a/packages/flint-js/tests/theme-titles.test.ts +++ b/packages/flint-js/tests/theme-titles.test.ts @@ -129,6 +129,30 @@ describe('axis titles', () => { expect(bare.title).toBeUndefined(); }); + it('lifts a flat y title above column facet headers', () => { + const spec = assembleVegaLite({ + data: { values: [ + { Year: 2000, Country: 'Germany', Rate: 8 }, + { Year: 2020, Country: 'Germany', Rate: 4 }, + { Year: 2000, Country: 'United States', Rate: 4 }, + { Year: 2020, Country: 'United States', Rate: 8 }, + ] }, + semantic_types: { Year: 'Year', Country: 'Country', Rate: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + title: 'Out of work', + encodings: { x: 'Year', y: 'Rate', column: 'Country' }, + }, + theme_spec: { + ...house({ axisTitles: 'whenAmbiguous', axisTitlePlacement: 'flatAboveAxis', axisTitleGap: 8 }), + structure: { axis: { measure: { placement: 'opposite' } } }, + }, + } as any) as any; + const y = spec.encoding?.y ?? spec.spec?.encoding?.y; + expect(y.axis.orient).toBe('right'); + expect(y.axis.titleY).toBeLessThanOrEqual(-30); + }); + it('leaves an authored subtitle untouched and keeps the measure named', () => { const spec = assembleVegaLite({ data: { values: MONTHLY }, diff --git a/packages/flint-js/tests/unit-display.test.ts b/packages/flint-js/tests/unit-display.test.ts new file mode 100644 index 00000000..a3484622 --- /dev/null +++ b/packages/flint-js/tests/unit-display.test.ts @@ -0,0 +1,64 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { describe, expect, it } from 'vitest'; +import { assembleVegaLite } from '../src'; +import { resolveDisplayUnit } from '../src/core/field-semantics'; + +const values = [ + { country: 'Peru', gain: 33.49 }, + { country: 'Iran', gain: 32.34 }, +]; + +function bars(unit?: string, themed = true): any { + return assembleVegaLite({ + data: { values }, + semantic_types: { + country: 'Country', + gain: unit ? { semanticType: 'Duration', unit } : 'Duration', + }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { x: 'country', y: 'gain' }, + baseSize: { width: 400, height: 300 }, + }, + ...(themed ? { theme_spec: 'economist' } : {}), + } as any) as any; +} + +describe('explicit unit display policy', () => { + it('does not infer a visible unit from the semantic type', () => { + const axis = bars()._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBeUndefined(); + }); + + it('places a declared compact unit beside values', () => { + const axis = bars('kg')._theme.decisions.axes.y; + expect(axis.unit).toMatchObject({ text: 'kg' }); + expect(axis.title.unit).toBeUndefined(); + }); + + it('normalizes conventional compact unit names', () => { + expect(resolveDisplayUnit({ semanticType: 'Duration', unit: 'hours' })) + .toEqual({ text: 'hr', placement: 'value', position: 'suffix' }); + expect(resolveDisplayUnit({ semanticType: 'Amount', unit: 'USD' })) + .toEqual({ text: '$', placement: 'value', position: 'prefix' }); + }); + + it('places a declared lexical unit beside the field name', () => { + const axis = bars('years')._theme.decisions.axes.y; + expect(axis.unit).toBeUndefined(); + expect(axis.title.unit).toBe('years'); + + const unthemed = bars('years', false); + expect(unthemed.encoding.y.title).toBe('gain (years)'); + }); + + it('does not display prose as a unit', () => { + expect(resolveDisplayUnit({ + semanticType: 'Quantity', + unit: 'per working-age resident in constant prices', + })).toBeUndefined(); + }); +}); diff --git a/packages/flint-js/tests/value-label-format.test.ts b/packages/flint-js/tests/value-label-format.test.ts index a57b1831..0d445abe 100644 --- a/packages/flint-js/tests/value-label-format.test.ts +++ b/packages/flint-js/tests/value-label-format.test.ts @@ -231,6 +231,31 @@ describe('value label precision', () => { const labelMark = (spec: any) => (spec.layer ?? []).find((l: any) => (l.mark?.type ?? l.mark) === 'text')?.mark; + it('keeps every label outside when the chart chooses outside placement', () => { + const spec: any = assembleVegaLite({ + data: { + values: [703, 608, 227, 165, 148, 120, 102, 58, 55, 49] + .map((value, index) => ({ cause: `Cause ${index + 1}`, value })), + }, + semantic_types: { cause: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Bar Chart', + encodings: { y: 'cause', x: 'value' }, + baseSize: { width: 420, height: 320 }, + chartProperties: { showValueLabels: true }, + }, + theme_spec: 'datawrapper', + } as any); + const body = spec.layer ? spec : spec.vconcat?.[0]; + const labels = (body?.layer ?? []) + .filter((layer: any) => (layer.mark?.type ?? layer.mark) === 'text'); + expect(spec._theme.decisions.dataLabels.placement).toBe('outsideMark'); + expect(labels).toHaveLength(1); + expect(labels[0].mark.align).toBe('left'); + expect(labels[0].mark.dx).toBeGreaterThan(0); + expect(labels[0].transform).toBeUndefined(); + }); + it('sends the label below a bar that runs down from zero', () => { // A bar drawn downwards ends at the bottom, so "outside" is below it. // Placed above, the number lands on top of the bar it labels. A narrow diff --git a/packages/flint-js/tsup.config.ts b/packages/flint-js/tsup.config.ts index 519b97fa..8d27d220 100644 --- a/packages/flint-js/tsup.config.ts +++ b/packages/flint-js/tsup.config.ts @@ -9,6 +9,7 @@ export default defineConfig({ 'chartjs/index': 'src/chartjs/index.ts', 'plotly/index': 'src/plotly/index.ts', 'excel/index': 'src/excel/index.ts', + 'image-charts/index': 'src/image-charts/index.ts', 'test-data/index': 'src/test-data/index.ts', 'gallery/index': 'src/gallery/index.ts', }, diff --git a/packages/flint-mcp/assets/flint-chart-author.SKILL.md b/packages/flint-mcp/assets/flint-chart-author.SKILL.md index af1a890f..f7326fb1 100644 --- a/packages/flint-mcp/assets/flint-chart-author.SKILL.md +++ b/packages/flint-mcp/assets/flint-chart-author.SKILL.md @@ -421,7 +421,20 @@ understates what you know: } ``` -- `unit` — the unit or currency code: `"USD"`, `"°C"`, `"kg"`. +- `unit` — an optional assertion that authorizes Flint to display a unit. Add + it only when the data or surrounding context establishes the measurement + and seeing it materially changes how a reader interprets the number. A type + such as `Duration`, a field name such as `life_expectancy`, or values that + merely look plausible are not enough evidence by themselves. + - Prefer canonical codes: `"USD"`, `"°C"`, `"kg"`, `"km/h"`, `"min"`. + - Conventional compact units are normalized and may appear beside values + (`USD` → `$`, `hours` → `hr`). + - Lexical units such as `"years"` are stated once beside the field name as + `field (years)`, not repeated after every value. + - Do not put explanatory phrases in `unit`. Put qualifications such as + `"per working-age resident"` or `"constant 2024 prices"` in the subtitle. + - Omit `unit` when its meaning, scale, or denominator is uncertain. Flint + does not infer a visible unit from the semantic type or field name. - `intrinsicDomain` — the field's own bounds, for bounded scales only: `[1, 5]` for a five-star rating, `[0, 100]` for a percentage score. Not for open-ended measures. diff --git a/scripts/issue-98-slope-shots.mjs b/scripts/issue-98-slope-shots.mjs new file mode 100644 index 00000000..3e3c9ee0 --- /dev/null +++ b/scripts/issue-98-slope-shots.mjs @@ -0,0 +1,146 @@ +#!/usr/bin/env node +/** + * One-off shots for microsoft/flint-chart#98. Not part of the test suite. + * Usage: node scripts/issue-98-slope-shots.mjs + */ +import { writeFileSync, mkdirSync, readFileSync } from 'node:fs'; +import { createServer } from 'node:http'; +import { dirname, join } from 'node:path'; +import { fileURLToPath } from 'node:url'; +import puppeteer from 'puppeteer-core'; +import { assembleECharts } from '../packages/flint-js/dist/echarts/index.js'; + +const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const outDir = join(root, 'docs/figs'); + +const assembled = assembleECharts({ + data: { + values: [ + { period: '2024', team: 'Alpha', nps: 32 }, + { period: '2025', team: 'Alpha', nps: 48 }, + { period: '2024', team: 'Beta', nps: 41 }, + { period: '2025', team: 'Beta', nps: 39 }, + { period: '2024', team: 'Gamma', nps: 28 }, + { period: '2025', team: 'Gamma', nps: 52 }, + { period: '2024', team: 'Delta', nps: 55 }, + { period: '2025', team: 'Delta', nps: 61 }, + ], + }, + semantic_types: { period: 'Year', team: 'Name', nps: 'Score' }, + chart_spec: { + chartType: 'Slope Chart', + encodings: { + x: { field: 'period' }, + y: { field: 'nps' }, + color: { field: 'team' }, + }, + baseSize: { width: 420, height: 280 }, + }, +}); + +const { _width: designW, _height: designH, _warnings, _dataLength, _pivot, ...option } = + assembled; +void _warnings; +void _dataLength; +void _pivot; + +const cloneOpt = (o) => + JSON.parse(JSON.stringify(o, (_k, v) => (typeof v === 'function' ? undefined : v))); +const before = cloneOpt(option); +const gutter = designW - (option.grid?.right ?? 112); +before.legend = { ...before.legend, left: gutter }; +delete before.legend.right; +if (Array.isArray(before.graphic)) { + before.graphic = before.graphic.map((g) => { + if (g?.type === 'text' && g.style?.fontWeight === 'bold') { + const { right: _r, ...rest } = g; + void _r; + return { ...rest, left: gutter }; + } + return g; + }); +} + +const payload = { after: cloneOpt(option), before, designW, designH }; + +const html = ` + + + + + + +
+ + +`; + +mkdirSync(outDir, { recursive: true }); +const htmlPath = join(outDir, '.issue-98-host.html'); +writeFileSync(htmlPath, html); + +const echartsFile = join(root, 'node_modules/echarts/dist/echarts.esm.min.js'); +const server = createServer((req, res) => { + if (req.url === '/echarts.js') { + res.writeHead(200, { 'content-type': 'text/javascript' }); + res.end(readFileSync(echartsFile)); + return; + } + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(html); +}); + +await new Promise((resolve) => server.listen(0, '127.0.0.1', resolve)); +const { port } = server.address(); +const chrome = '/Applications/Google Chrome.app/Contents/MacOS/Google Chrome'; +const browser = await puppeteer.launch({ + executablePath: chrome, + headless: true, + args: ['--hide-scrollbars'], +}); +const page = await browser.newPage(); +await page.goto(`http://127.0.0.1:${port}/`, { waitUntil: 'networkidle0' }); +await page.waitForFunction(() => window.__ready === true); + +async function shot(name, width, which) { + await page.setViewport({ width, height: designH, deviceScaleFactor: 2 }); + await page.evaluate( + async ({ width: w, height, which: key }) => { + const echarts = window.__echarts; + const payload = window.__payload; + const el = document.getElementById('c'); + el.style.width = `${w}px`; + el.style.height = `${height}px`; + echarts.dispose(el); + const chart = echarts.init(el, undefined, { renderer: 'canvas', width: w, height }); + const opt = { ...payload[key], animation: false }; + chart.setOption(opt, { notMerge: true }); + if (w !== payload.designW) chart.resize({ width: w, height }); + await new Promise((r) => requestAnimationFrame(() => requestAnimationFrame(r))); + await new Promise((r) => setTimeout(r, 50)); + }, + { width, height: designH, which }, + ); + const path = join(outDir, name); + await page.screenshot({ path, type: 'png', clip: { x: 0, y: 0, width, height: designH } }); + console.log('wrote', path); +} + +await shot('issue-98-slope-534-before.png', designW, 'before'); +await shot('issue-98-slope-534.png', designW, 'after'); +await shot('issue-98-slope-800-before.png', 800, 'before'); +await shot('issue-98-slope-800.png', 800, 'after'); + +await browser.close(); +server.close(); +try { + const { unlinkSync } = await import('node:fs'); + unlinkSync(htmlPath); +} catch { + /* ignore */ +} diff --git a/site/src/components/EChartsView.tsx b/site/src/components/EChartsView.tsx index 3836c1da..ffaea6ed 100644 --- a/site/src/components/EChartsView.tsx +++ b/site/src/components/EChartsView.tsx @@ -20,12 +20,10 @@ export function EChartsView({ const chartRef = useRef(null); const [error, setError] = useState(null); - // The flint ECharts assembler computes a designed canvas size (`_width`/`_height`) - // and positions legends / visualMaps with absolute pixels relative to it — the same - // way Vega-Lite sizes its plot area and lets the SVG wrap around it. Render at those - // dimensions so the legend lands where it was designed, instead of snapping to the - // live container's bounding box (which made rose legends drift far right, streamgraph - // legends overlap the plot, and heatmap colour bars float below a stretched plot). + // The assembler still designs a canvas (`_width`/`_height`). Categorical legends + // are now `right`-anchored (issue #98) and survive container resize(); other + // chrome (visualMap, rose, some radii) is still design-px. Render at the + // designed size so those leftovers land where they were laid out. const designedWidth = asFinite(option?._width); const designedHeight = asFinite(option?._height); const renderHeight = designedHeight ?? height ?? 320; diff --git a/site/src/main.tsx b/site/src/main.tsx index fb8c034f..003d6a7c 100644 --- a/site/src/main.tsx +++ b/site/src/main.tsx @@ -22,6 +22,7 @@ import { ThemeLab } from './playground/ThemeLab'; import { ThemeLabR2 } from './playground/ThemeLabR2'; import { ThemeLabReal } from './playground/ThemeLabReal'; import { BandStretchingLab } from './playground/BandStretchingLab'; +import { LabelExperimentLab } from './playground/LabelExperimentLab'; import { StyleReferences } from './playground/StyleReferences'; import { FullTestCases } from './playground/FullTestCases'; import { LocaleProvider, useLocale } from './i18n/LocaleContext'; @@ -70,6 +71,7 @@ function AppRoutes({ locale }: { locale: Locale }) { } /> } /> } /> + } /> } /> {/* The Swiss and cartoon labs were the same page twice; keep the links they were reached by working. */} diff --git a/site/src/playground/LabelExperimentLab.tsx b/site/src/playground/LabelExperimentLab.tsx new file mode 100644 index 00000000..eb20a05e --- /dev/null +++ b/site/src/playground/LabelExperimentLab.tsx @@ -0,0 +1,209 @@ +// Copyright (c) Microsoft Corporation. +// Licensed under the MIT License. + +import { useMemo } from 'react'; +import { assembleVegaLite, THEME_PRESETS, type ChartAssemblyInput } from 'flint-chart'; +import { ScaleToFit } from '../components/ScaleToFit'; +import { VegaLiteView } from '../components/VegaLiteView'; +import './label-experiment-lab.css'; + +type Outcome = 'direct' | 'dodge' | 'fallback'; + +interface ExperimentCase { + id: string; + title: string; + note: string; + expected: Outcome; + input: ChartAssemblyInput; +} + +const baseTheme = (THEME_PRESETS as any).datawrapper.spec; +const labelTheme = { + ...baseTheme, + id: 'label-experiment', + label: 'Label experiment', + legend: { + ...baseTheme.legend, + show: 'always', + placement: ['seriesEnd', 'right'], + }, +}; + +function lineRows(endValues: number[], endYears?: number[]): any[] { + return endValues.flatMap((endValue, seriesIndex) => { + const endYear = endYears?.[seriesIndex] ?? 2020; + return [1950, 1980, endYear].map((year, index) => ({ + year, + series: `S${seriesIndex + 1}`, + value: index === 2 ? endValue : 72 - seriesIndex * 4 - index * 5, + })); + }); +} + +function lineInput(endValues: number[], endYears?: number[]): ChartAssemblyInput { + return { + data: { values: lineRows(endValues, endYears) }, + semantic_types: { year: 'Year', series: 'Category', value: 'Quantity' }, + chart_spec: { + chartType: 'Line Chart', + encodings: { x: 'year', y: 'value', color: 'series' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput; +} + +const connectedRows = [ + ['0', 1955, 4.3, 37], ['0', 1980, 5.5, 55], ['0', 2005, 6.1, 58], + ['1', 1955, 1.8, 79], ['1', 1980, 2.8, 69], ['1', 2005, 6.5, 54], + ['2', 1955, 1.6, 77], ['2', 1980, 2.7, 68], ['2', 2005, 6.7, 55], + ['3', 1955, 1.9, 75], ['3', 1980, 3.2, 66], ['3', 2005, 4.9, 63], + ['4', 1955, 1.7, 73], ['4', 1980, 3.0, 67], ['4', 2005, 5.9, 60], + ['5', 1955, 2.8, 71], ['5', 1980, 4.1, 68], ['5', 2005, 6.7, 47], +].map(([cluster, year, fertility, longevity]) => ({ cluster, year, fertility, longevity })); + +const CASES: ExperimentCase[] = [ + { + id: 'separated', + title: 'Aligned and separated', + note: 'One endpoint column; labels already have enough vertical air.', + expected: 'direct', + input: lineInput([18, 38, 58]), + }, + { + id: 'small-dodge', + title: 'Two close endpoints', + note: 'A sub-line-height adjustment is accepted and connected back to each point.', + expected: 'dodge', + input: lineInput([50, 52]), + }, + { + id: 'dense', + title: 'Dense endpoint cluster', + note: 'The required movement exceeds one line of text, so the whole set returns to a legend.', + expected: 'fallback', + input: lineInput([50, 51, 52, 53]), + }, + { + id: 'staggered', + title: 'Staggered final x positions', + note: 'Different final years are harmless when the natural label rows do not overlap.', + expected: 'direct', + input: lineInput([20, 42, 64], [2020, 2010, 2000]), + }, + { + id: 'boundary', + title: 'Top boundary endpoint', + note: 'The highest label stays centred on its endpoint; the figure bounds carry the overhang.', + expected: 'direct', + input: lineInput([25, 48, 72]), + }, + { + id: 'connected', + title: 'Connected scatter trajectories', + note: 'Rightmost points occupy a broad x range, matching the difficult real-world pattern.', + expected: 'fallback', + input: { + data: { values: connectedRows }, + semantic_types: { + cluster: 'Category', + year: 'Year', + fertility: { semanticType: 'Quantity', unit: 'children per woman' }, + longevity: { semanticType: 'Duration', unit: 'years' }, + }, + chart_spec: { + chartType: 'Connected Scatter Plot', + title: 'Cluster development trajectories', + subtitle: 'Synthetic endpoints modeled after the reported collision', + encodings: { x: 'fertility', y: 'longevity', color: 'cluster', order: 'year' }, + baseSize: { width: 420, height: 280 }, + }, + theme_spec: labelTheme, + } as ChartAssemblyInput, + }, +]; + +function stripInternal(node: any): void { + if (!node || typeof node !== 'object') return; + if (Array.isArray(node)) { + node.forEach(stripInternal); + return; + } + for (const key of Object.keys(node)) { + if (/^_[^_]/.test(key)) delete node[key]; + else stripInternal(node[key]); + } +} + +function buildCase(testCase: ExperimentCase): { spec?: any; outcome: Outcome; message: string; error?: string } { + try { + const spec = assembleVegaLite(testCase.input as any) as any; + const messages = (spec._theme?.report ?? []) + .filter((entry: any) => entry.path === 'legend.placement') + .map((entry: any) => entry.message); + const message = messages.find((entry: string) => + entry.includes('dodged by at most') + || entry.includes('key is drawn') + || entry.includes('do not form one readable label column') + || entry.includes('more than one line of text') + ) ?? messages.at(-1) ?? 'No placement report'; + const outcome: Outcome = messages.some((entry: string) => entry.includes('dodged by at most')) + ? 'dodge' + : messages.some((entry: string) => entry.includes('key is drawn')) + ? 'fallback' + : 'direct'; + stripInternal(spec); + return { spec, outcome, message }; + } catch (error) { + return { outcome: 'fallback', message: 'Assembly failed', error: String((error as Error)?.message ?? error) }; + } +} + +function CaseTile({ testCase }: { testCase: ExperimentCase }) { + const built = useMemo(() => buildCase(testCase), [testCase]); + const matches = built.outcome === testCase.expected; + + return ( +
+
+
+

{testCase.title}

+

{testCase.note}

+
+ + {built.outcome} + +
+
+ {built.error || !built.spec + ?
{built.error}
+ : ( + + + + )} +
+
+ {built.message} + {!matches && Expected {testCase.expected}} +
+
+ ); +} + +export function LabelExperimentLab() { + return ( +
+
+

Series-end label experiment

+

+ Direct labels stay when no more than eight endpoints form one column and need at most one + line of vertical adjustment. Otherwise, the complete set falls back to a legend. +

+
+
+ {CASES.map((testCase) => )} +
+
+ ); +} diff --git a/site/src/playground/PlaygroundShell.tsx b/site/src/playground/PlaygroundShell.tsx index 3c0cc16d..6be6b7b2 100644 --- a/site/src/playground/PlaygroundShell.tsx +++ b/site/src/playground/PlaygroundShell.tsx @@ -17,6 +17,7 @@ const pages: NavEntry[] = [ { to: 'theme-lab-r2', label: 'Theme lab R2' }, { to: 'theme-lab-real', label: 'Theme lab real' }, { to: 'band-stretching', label: 'Band stretching' }, + { to: 'label-experiment', label: 'Label experiment' }, { to: 'style-references', label: 'Style references' }, ], }, diff --git a/site/src/playground/label-experiment-lab.css b/site/src/playground/label-experiment-lab.css new file mode 100644 index 00000000..c89cd6c3 --- /dev/null +++ b/site/src/playground/label-experiment-lab.css @@ -0,0 +1,134 @@ +.label-lab { + max-width: 960px; + margin: 0 auto; + padding: 10px 4px 56px; + color: #16202a; +} + +.label-lab-intro { + margin-bottom: 18px; +} + +.label-lab-intro h1 { + margin: 0 0 5px; + font-size: 20px; + font-weight: 650; + letter-spacing: 0; +} + +.label-lab-intro p { + max-width: 820px; + margin: 0; + color: #5d6872; + font-size: 13px; + line-height: 1.55; +} + +.label-case-grid { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 16px; +} + +.label-case { + display: grid; + grid-template-rows: auto 300px auto; + min-width: 0; + border-top: 1px solid #d8dde2; + background: #fff; +} + +.label-case-header { + display: flex; + justify-content: space-between; + gap: 20px; + align-items: flex-start; + padding: 12px 0 10px; +} + +.label-case-header h2 { + margin: 0; + font-size: 15px; + line-height: 1.25; + letter-spacing: 0; +} + +.label-case-header p { + max-width: 520px; + margin: 5px 0 0; + color: #6a747d; + font-size: 12px; + line-height: 1.4; +} + +.label-outcome { + flex: 0 0 auto; + padding-top: 2px; + color: #737d86; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 10px; + text-transform: uppercase; +} + +.label-chart-frame { + position: relative; + min-width: 0; + overflow: hidden; + border-top: 1px solid #edf0f2; + border-bottom: 1px solid #edf0f2; + background: #fff; +} + +.label-error { + display: grid; + height: 100%; + place-items: center; + color: #a52c24; + font-family: ui-monospace, SFMono-Regular, Menlo, monospace; + font-size: 12px; +} + +.label-case-footer { + display: flex; + justify-content: space-between; + gap: 10px; + padding: 8px 0 12px; +} + +.label-case-footer > span { + font-size: 10px; + font-weight: 700; + text-transform: uppercase; + white-space: nowrap; +} + +.label-mismatch { color: #a52c24; } + +.label-case-footer code { + min-width: 0; + color: #5d6872; + font-size: 10px; + line-height: 1.45; + white-space: normal; + overflow-wrap: anywhere; +} + +@media (max-width: 980px) { + .label-lab { + padding: 10px 0 40px; + } + + .label-case-grid { + grid-template-columns: 1fr; + } +} + +@media (max-width: 560px) { + .label-case { + grid-template-rows: auto 300px auto; + } + + .label-case-footer { + grid-template-columns: 1fr; + } +} diff --git a/site/src/shared/docs-catalog.ts b/site/src/shared/docs-catalog.ts index 2cb735fd..5069074f 100644 --- a/site/src/shared/docs-catalog.ts +++ b/site/src/shared/docs-catalog.ts @@ -136,6 +136,12 @@ export const DOCUMENTATION_GROUPS: DocGroup[] = [ description: 'Every native Excel chart type, its channels, and Office.js mapping.', file: '../../../docs/reference-excel.md', }, + { + slug: 'community-backends', + title: 'Community backends', + description: 'Community-contributed renderers and delivery targets, their coverage, and integration notes.', + file: '../../../docs/community-backends.md', + }, ], }, {