From d3637cbcfb4357dd145f3e735df4880597270ada Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 15:28:14 -0500 Subject: [PATCH 01/17] feat!: enforce mark-specific authoring contracts --- docs/codebase-map.md | 3 +- docs/generating-specs.md | 16 +- packages/core/schema/chart.schema.json | 5201 +------- packages/core/schema/vizspec.schema.json | 10170 ++-------------- packages/core/src/accessibility/alt-text.ts | 4 +- packages/core/src/types/spec.ts | 96 +- .../src/__test-fixtures__/mark-options.ts | 88 + packages/engine/src/charts/line/curves.ts | 11 +- packages/engine/src/charts/post-process.ts | 4 +- packages/engine/src/compile.ts | 6 +- packages/engine/src/compile/fill-patterns.ts | 8 +- packages/engine/src/compile/spec-sugar.ts | 6 +- .../compiler/__tests__/mark-options.test.ts | 241 +- packages/engine/src/compiler/mark-options.ts | 39 +- packages/engine/src/compiler/normalize.ts | 6 +- .../engine/src/compiler/sparkline-defaults.ts | 5 +- packages/engine/src/compiler/types.ts | 14 +- packages/engine/src/compiler/validate.ts | 10 +- scripts/llm-eval/fixtures.json | 20 +- scripts/llm-eval/run.mjs | 6 + 20 files changed, 1864 insertions(+), 14090 deletions(-) create mode 100644 packages/engine/src/__test-fixtures__/mark-options.ts diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 2f636aef..e65d146b 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -409,7 +409,8 @@ regular, shared-layer and dual-axis column separation. | Compact priority/source count limit | `annotations/thinning.ts`: optional final `compactLimit`; `compile.ts` gates on outer width and height before coverage/collision thinning | | Resolved note arithmetic and compatibility exports | `packages/core/src/layout/chrome.ts`: `computeNoteBandHeight` and deprecated `footnoteBandHeight`, exported through core index; vanilla adapter in `renderers/chrome.ts`; renderers/brand consume resolved `noteBandHeight` | | Exactly-once facet grow metadata | `packages/engine/src/compile.ts` facet assembly and `layout/dimensions.ts` populate optional `chrome.grownHeight`; `packages/vanilla/src/mount.ts` deducts engine-owned growth | -| Authored mark-option warnings | `packages/engine/src/compiler/mark-options.ts`: `collectMarkOptionWarnings`; `compile/spec-sugar.ts` calls it before rewrites; `validate.ts` exposes optional warnings | +| Authored mark-option contract | `core/src/types/spec.ts`: discriminated `MarkDef`; `engine/src/compiler/types.ts`: internal wide `NormalizedMarkDef`; `compiler/mark-options.ts`: `collectMarkOptionErrors` / `assertMarkOptions`, checked before sugar with authored JSON Pointer paths | +| Mark contract parity matrix | `packages/engine/src/__test-fixtures__/mark-options.ts`: every mark × option plus compile-time union assertions; `compiler/__tests__/mark-options.test.ts`: published schema, strict-null transform, and runtime agreement | | 8.x default materialization | `packages/engine/src/migration/v8.ts`: `migrateSpecV8`; parity fixtures in `examples/src/__tests__/migration-parity.test.ts` | | Layout opt-in browser fixtures | `examples/src/gallery/features-annotations.demos.ts` and `.stories.tsx`: ReferenceLabels, CrowdedCallouts, NotesSizing; `e2e/annotation-invariants/layout.spec.ts` covers geometry, note counts, heights, SVG/PNG and canvas | diff --git a/docs/generating-specs.md b/docs/generating-specs.md index 8fd98723..fe24d9e6 100644 --- a/docs/generating-specs.md +++ b/docs/generating-specs.md @@ -23,7 +23,7 @@ export (not the main barrel, so a large JSON never lands in a consumer bundle): | Import | Covers | Use for | |--------|--------|---------| | `@opendata-ai/openchart-core/schema` | Full `VizSpec` union (every spec kind) | Plain, non-strict tool `input_schema` | -| `@opendata-ai/openchart-core/schema/chart.schema.json` | `ChartSpec` (all 16 marks) | The LLM chart workhorse; strict-mode subset | +| `@opendata-ai/openchart-core/schema/chart.schema.json` | `ChartSpec` (all 18 marks) | The LLM chart workhorse; strict-mode subset | | `@opendata-ai/openchart-core/schema/table.schema.json` | `TableSpec` | Data tables; strict-mode subset | The schemas are draft-07 and generated directly from the TypeScript spec types, @@ -36,7 +36,7 @@ import chartSchema from '@opendata-ai/openchart-core/schema/chart.schema.json' w ## Which schema to use -- **Charts** (bar, line, area, point, arc, and the rest of the 16 marks): use +- **Charts** (bar, line, area, point, arc, and the rest of the 18 marks): use `chart.schema.json`. It is the primary generation surface. - **Tables**: use `table.schema.json`. - **Anything, or "let the model pick the visualization"**: use the full VizSpec @@ -108,6 +108,18 @@ For strict modes, use a per-type **subset** schema (`chart.schema.json` or 3. Strip keywords the target API does not support (`$comment`, format annotations it rejects, etc.). +Preserve each `anyOf` branch separately, including nested mark unions. In v9, +`{ type: 'density', bandwidth: 2 }` is valid, while +`{ type: 'bar', bandwidth: 2 }` is rejected by the TypeScript contract, the +published schema, and runtime validation. Merging branch properties into one +object would erase this contract. The mark-option matrix tests the published +schema after the required-and-nullable transform for every mark and option. + +Nulls added by the strict transform represent omitted fields. Remove those +placeholder nulls before calling `validateSpec`; preserve authored nulls only for +fields whose public contract accepts them (for example `mark.tooltip`). Strict +schema transforms are provider-specific adapters, not a different chart format. + Never claim strict-mode compatibility for the full VizSpec schema — ship the subset. Measure the serialized token size of the subset against your API's limit before shipping; the ChartSpec subset is the tightest surface the library offers. diff --git a/packages/core/schema/chart.schema.json b/packages/core/schema/chart.schema.json index 944f3498..8d6f03c4 100644 --- a/packages/core/schema/chart.schema.json +++ b/packages/core/schema/chart.schema.json @@ -31,56 +31,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, "innerRadius": { "type": "number", "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." @@ -120,17 +70,9 @@ ], "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "arc" }, "size": { "type": "number", @@ -148,26 +90,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -198,47 +120,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -246,99 +127,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -377,10 +165,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -419,52 +203,108 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." + "const": "line" }, - "innerRadius": { + "size": { "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." }, - "startAngle": { + "opacity": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Default opacity (0-1)." }, - "endAngle": { + "fillOpacity": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "centerLabel": { + "fill": { "anyOf": [ { "type": "string" }, { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Default fill color or gradient." + }, + "stroke": { + "type": "string", + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" + ], + "description": "Tooltip behavior. null disables tooltips." + }, + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/LineEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "bar" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "orient": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ], + "description": "Explicit orientation override for bar marks." }, "cornerRadius": { "anyOf": [ @@ -478,6 +318,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "bar" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -494,26 +338,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -544,47 +368,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -592,99 +375,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -694,7 +384,7 @@ ] }, "encoding": { - "$ref": "#/definitions/LineEncoding%3CDataRow%3E" + "$ref": "#/definitions/BarEncoding%3CDataRow%3E" } } } @@ -717,16 +407,12 @@ "anyOf": [ { "type": "string", - "const": "bar" + "const": "area" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -765,72 +451,17 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." + "const": "area" }, - "innerRadius": { + "size": { "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." }, "opacity": { "type": "number", @@ -840,26 +471,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -890,6 +501,51 @@ "type": "boolean", "description": "Clip marks to the chart area." }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/AreaEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "point" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "trendline": { "anyOf": [ { @@ -901,136 +557,63 @@ ], "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { + "type": { "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." + "const": "point" }, - "fontSize": { + "size": { "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "colorByDirection": { + "filled": { "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + "description": "Whether the mark is filled (vs stroked only)." }, - "units": { + "opacity": { "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." + "description": "Default opacity (0-1)." }, - "columns": { + "fillOpacity": { "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "other": { + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." + "description": "Default fill color or gradient." }, - "weekStart": { + "stroke": { "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." + "description": "Default stroke color." }, - "cellRadius": { + "strokeWidth": { "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "description": "Default stroke width." }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Tooltip behavior. null disables tooltips." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -1040,7 +623,7 @@ ] }, "encoding": { - "$ref": "#/definitions/BarEncoding%3CDataRow%3E" + "$ref": "#/definitions/PointEncoding%3CDataRow%3E" } } } @@ -1063,112 +646,15 @@ "anyOf": [ { "type": "string", - "const": "area" + "const": "circle" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "circle" }, "size": { "type": "number", @@ -1186,26 +672,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1236,47 +702,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -1284,99 +709,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -1386,7 +718,7 @@ ] }, "encoding": { - "$ref": "#/definitions/AreaEncoding%3CDataRow%3E" + "$ref": "#/definitions/CircleEncoding%3CDataRow%3E" } } } @@ -1409,112 +741,15 @@ "anyOf": [ { "type": "string", - "const": "point" + "const": "lollipop" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "lollipop" }, "size": { "type": "number", @@ -1532,26 +767,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1582,47 +797,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -1630,99 +804,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -1732,7 +813,7 @@ ] }, "encoding": { - "$ref": "#/definitions/PointEncoding%3CDataRow%3E" + "$ref": "#/definitions/LollipopEncoding%3CDataRow%3E" } } } @@ -1755,112 +836,15 @@ "anyOf": [ { "type": "string", - "const": "circle" + "const": "beeswarm" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": "string", + "const": "beeswarm" }, "size": { "type": "number", @@ -1878,26 +862,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1928,47 +892,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -1976,99 +899,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -2078,7 +908,7 @@ ] }, "encoding": { - "$ref": "#/definitions/CircleEncoding%3CDataRow%3E" + "$ref": "#/definitions/BeeswarmEncoding%3CDataRow%3E" } } } @@ -2101,100 +931,150 @@ "anyOf": [ { "type": "string", - "const": "lollipop" + "const": "range" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { + "style": { + "type": "string", + "enum": [ + "dumbbell", + "arrow", + "bar" + ], + "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + }, + "colorByDirection": { + "type": "boolean", + "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + }, + "type": { + "type": "string", + "const": "range" + }, + "size": { + "type": "number", + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." + }, + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." + }, + "opacity": { + "type": "number", + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." + }, + "fill": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" + "type": "string" }, { - "type": "string", - "const": "first" + "$ref": "#/definitions/GradientDef" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" + "description": "Default fill color or gradient." }, - "interpolate": { + "stroke": { "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + "description": "Tooltip behavior. null disables tooltips." }, - "orient": { + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { "type": "string", "enum": [ - "horizontal", - "vertical" + "auto", + "none" ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/RangeEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "waffle" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "units": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." }, - "endAngle": { + "columns": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." }, - "centerLabel": { + "other": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "object", "properties": { - "text": { - "type": "string" + "threshold": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, "required": [ - "text" + "threshold" ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." }, "cornerRadius": { "anyOf": [ @@ -2208,6 +1088,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "waffle" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -2224,26 +1108,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2274,17 +1138,51 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/WaffleEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "text" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "dx": { "type": "number", "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." @@ -2315,106 +1213,63 @@ "type": "number", "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." }, - "fillPattern": { + "type": { "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + "const": "text" }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + "size": { + "type": "number", + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "colorByDirection": { + "filled": { "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + "description": "Whether the mark is filled (vs stroked only)." }, - "units": { + "opacity": { "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." + "description": "Default opacity (0-1)." }, - "columns": { + "fillOpacity": { "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "other": { + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." + "description": "Default fill color or gradient." }, - "weekStart": { + "stroke": { "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." + "description": "Default stroke color." }, - "cellRadius": { + "strokeWidth": { "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "description": "Default stroke width." }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Tooltip behavior. null disables tooltips." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -2424,7 +1279,7 @@ ] }, "encoding": { - "$ref": "#/definitions/LollipopEncoding%3CDataRow%3E" + "$ref": "#/definitions/TextEncoding%3CDataRow%3E" } } } @@ -2447,112 +1302,15 @@ "anyOf": [ { "type": "string", - "const": "beeswarm" + "const": "tick" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "tick" }, "size": { "type": "number", @@ -2570,26 +1328,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2620,47 +1358,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -2668,99 +1365,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -2770,7 +1374,7 @@ ] }, "encoding": { - "$ref": "#/definitions/BeeswarmEncoding%3CDataRow%3E" + "$ref": "#/definitions/TickEncoding%3CDataRow%3E" } } } @@ -2793,101 +1397,12 @@ "anyOf": [ { "type": "string", - "const": "range" + "const": "rect" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, "cornerRadius": { "anyOf": [ { @@ -2900,6 +1415,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "rect" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -2916,26 +1435,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2966,47 +1465,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -3014,99 +1472,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -3116,7 +1481,7 @@ ] }, "encoding": { - "$ref": "#/definitions/RangeEncoding%3CDataRow%3E" + "$ref": "#/definitions/RectEncoding%3CDataRow%3E" } } } @@ -3139,112 +1504,15 @@ "anyOf": [ { "type": "string", - "const": "waffle" + "const": "rule" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "rule" }, "size": { "type": "number", @@ -3262,26 +1530,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -3312,47 +1560,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -3360,1483 +1567,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/WaffleEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "text" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/TextEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "tick" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/TickEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "rect" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/RectEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "rule" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -4866,115 +1596,30 @@ ], "properties": { "mark": { - "anyOf": [ - { - "type": "string", - "const": "calendar" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } + "anyOf": [ + { + "type": "string", + "const": "calendar" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "weekStart": { + "type": "string", + "enum": [ + "monday", + "sunday" ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "cellRadius": { + "type": "number", + "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." + }, + "type": { + "type": "string", + "const": "calendar" }, "size": { "type": "number", @@ -4992,26 +1637,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -5042,47 +1667,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -5090,99 +1674,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -5221,106 +1712,46 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." + "shape": { + "type": "string", + "const": "hemicycle", + "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." }, - "point": { + "seatRadius": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" + "type": "number" }, { "type": "string", - "const": "first" + "const": "auto" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." }, - "centerLabel": { + "majorityLine": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "object", "properties": { - "text": { - "type": "string" + "seats": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, - "required": [ - "text" - ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "parliament" }, "size": { "type": "number", @@ -5338,26 +1769,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -5388,47 +1799,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -5436,99 +1806,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -5567,94 +1844,13 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, "orient": { "type": "string", "enum": [ "horizontal", "vertical" ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Explicit orientation override for bar marks." }, "cornerRadius": { "anyOf": [ @@ -5668,6 +1864,18 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "binCount": { + "type": "number", + "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." + }, + "normalize": { + "type": "boolean", + "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." + }, + "type": { + "type": "string", + "const": "histogram" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -5684,26 +1892,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -5734,47 +1922,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -5782,99 +1929,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -5911,108 +1965,61 @@ }, { "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { + "additionalProperties": false, + "properties": { + "point": { "anyOf": [ { - "type": "number" + "type": "boolean" }, { "type": "string", - "const": "pill" + "const": "transparent" + }, + { + "type": "string", + "const": "endpoints" + }, + { + "type": "string", + "const": "last" + }, + { + "type": "string", + "const": "first" } ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" + }, + "interpolate": { + "type": "string", + "enum": [ + "linear", + "monotone", + "step", + "step-before", + "step-after", + "basis", + "cardinal", + "natural" + ], + "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + }, + "bandwidth": { + "type": "number", + "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." + }, + "cumulative": { + "type": "boolean", + "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." + }, + "steps": { + "type": "number", + "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." + }, + "type": { + "type": "string", + "const": "density" }, "size": { "type": "number", @@ -6030,26 +2037,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -6080,47 +2067,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -6128,99 +2074,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -6239,30 +2092,6 @@ ], "description": "Chart specification: the primary input for standard chart types.\n\nUses the Vega-Lite `mark` property instead of `type` to specify the visualization mark. The mark can be a string shorthand or an object with additional properties (interpolation, point markers, etc.).\n\nThis is a discriminated union — the `mark` value determines which encoding channels are required. TypeScript enforces required channels at compile time, matching the runtime rules in `MARK_ENCODING_RULES`." }, - "MarkType": { - "type": "string", - "enum": [ - "bar", - "line", - "area", - "point", - "circle", - "arc", - "text", - "rule", - "tick", - "rect", - "lollipop", - "beeswarm", - "range", - "waffle", - "calendar", - "parliament", - "histogram", - "density" - ], - "description": "Supported mark types, following Vega-Lite conventions.\n\nMapping from previous OpenChart chart types:\n- 'bar' covers both horizontal bars (old 'bar') and vertical columns (old 'column')\n- 'arc' covers both pie (old 'pie') and donut (old 'donut')\n- 'point' replaces old 'scatter'\n- 'circle' replaces old 'dot'\n- 'line' and 'area' unchanged\n\nNew mark types not in previous OpenChart:\n- 'text': data-positioned text labels\n- 'rule': reference lines as data marks\n- 'tick': strip/rug plot marks\n- 'rect': heatmaps and 2D binned plots\n- 'range': dumbbell / arrow / range-bar plots spanning two values per category\n- 'waffle': unit grids (\"x of 100\") for part-to-whole counts\n- 'calendar': GitHub-style calendar heatmap (weeks x weekdays, daily dates)\n- 'parliament': hemicycle seat-dot chart for election/legislature results\n- 'histogram': binned bars over a continuous field; sugar for a bar with `x.bin` + `y: { aggregate: 'count' }`\n- 'density': kernel density estimate drawn as a filled curve; sugar for an area over a density transform" - }, "GradientDef": { "anyOf": [ { @@ -6381,29 +2210,6 @@ "additionalProperties": false, "description": "Radial gradient definition. Coordinates are in [0,1] normalized space relative to the mark's bounding box." }, - "TrendlineConfig": { - "type": "object", - "properties": { - "layer": { - "type": "string", - "enum": [ - "above", - "below" - ], - "description": "Stacking order relative to the point marks. `'below'` (default) draws the line behind the dots; `'above'` draws it on top so it stays visible over a dense cloud." - }, - "stroke": { - "type": "string", - "description": "Stroke color. Defaults to the theme's high-contrast text color so the line reads on both light and dark grounds. Set an explicit CSS color to override." - }, - "strokeWidth": { - "type": "number", - "description": "Stroke width in pixels. Defaults to 1.5. Over a dense cloud with `layer: 'above'`, a heavier line (e.g. 2.5) stays legible where the thin default disappears into the dots." - } - }, - "additionalProperties": false, - "description": "Configuration for the scatter regression trend line. Passed as the object form of `MarkDef.trendline`." - }, "ArcEncoding": { "type": "object", "properties": { @@ -9783,6 +5589,29 @@ "additionalProperties": false, "description": "Encoding for area marks.\n- `x`: required (temporal or ordinal)\n- `y`: required (quantitative)" }, + "TrendlineConfig": { + "type": "object", + "properties": { + "layer": { + "type": "string", + "enum": [ + "above", + "below" + ], + "description": "Stacking order relative to the point marks. `'below'` (default) draws the line behind the dots; `'above'` draws it on top so it stays visible over a dense cloud." + }, + "stroke": { + "type": "string", + "description": "Stroke color. Defaults to the theme's high-contrast text color so the line reads on both light and dark grounds. Set an explicit CSS color to override." + }, + "strokeWidth": { + "type": "number", + "description": "Stroke width in pixels. Defaults to 1.5. Over a dense cloud with `layer: 'above'`, a heavier line (e.g. 2.5) stays legible where the thin default disappears into the dots." + } + }, + "additionalProperties": false, + "description": "Configuration for the scatter regression trend line. Passed as the object form of `MarkDef.trendline`." + }, "PointEncoding": { "type": "object", "properties": { diff --git a/packages/core/schema/vizspec.schema.json b/packages/core/schema/vizspec.schema.json index 4cdabd4b..a4837d98 100644 --- a/packages/core/schema/vizspec.schema.json +++ b/packages/core/schema/vizspec.schema.json @@ -60,56 +60,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, "innerRadius": { "type": "number", "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." @@ -149,17 +99,9 @@ ], "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "arc" }, "size": { "type": "number", @@ -177,26 +119,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -227,47 +149,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -275,99 +156,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -406,10 +194,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -448,52 +232,108 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." + "const": "line" }, - "innerRadius": { + "size": { "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." }, - "startAngle": { + "opacity": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Default opacity (0-1)." }, - "endAngle": { + "fillOpacity": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "centerLabel": { + "fill": { "anyOf": [ { "type": "string" }, { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Default fill color or gradient." + }, + "stroke": { + "type": "string", + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" + ], + "description": "Tooltip behavior. null disables tooltips." + }, + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/LineEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "bar" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "orient": { + "type": "string", + "enum": [ + "horizontal", + "vertical" + ], + "description": "Explicit orientation override for bar marks." }, "cornerRadius": { "anyOf": [ @@ -507,6 +347,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "bar" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -523,26 +367,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -573,47 +397,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -621,99 +404,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -723,7 +413,7 @@ ] }, "encoding": { - "$ref": "#/definitions/LineEncoding%3CDataRow%3E" + "$ref": "#/definitions/BarEncoding%3CDataRow%3E" } } } @@ -746,16 +436,12 @@ "anyOf": [ { "type": "string", - "const": "bar" + "const": "area" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -794,72 +480,17 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." + "const": "area" }, - "innerRadius": { + "size": { "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." }, "opacity": { "type": "number", @@ -869,26 +500,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -919,6 +530,51 @@ "type": "boolean", "description": "Clip marks to the chart area." }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/AreaEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "point" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "trendline": { "anyOf": [ { @@ -930,136 +586,63 @@ ], "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { + "type": { "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." + "const": "point" }, - "fontSize": { + "size": { "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "colorByDirection": { + "filled": { "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + "description": "Whether the mark is filled (vs stroked only)." }, - "units": { + "opacity": { "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." + "description": "Default opacity (0-1)." }, - "columns": { + "fillOpacity": { "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "other": { + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." + "description": "Default fill color or gradient." }, - "weekStart": { + "stroke": { "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." + "description": "Default stroke color." }, - "cellRadius": { + "strokeWidth": { "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "description": "Default stroke width." }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Tooltip behavior. null disables tooltips." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -1069,7 +652,7 @@ ] }, "encoding": { - "$ref": "#/definitions/BarEncoding%3CDataRow%3E" + "$ref": "#/definitions/PointEncoding%3CDataRow%3E" } } } @@ -1092,112 +675,15 @@ "anyOf": [ { "type": "string", - "const": "area" + "const": "circle" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "circle" }, "size": { "type": "number", @@ -1215,26 +701,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1265,47 +731,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -1313,99 +738,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -1415,7 +747,7 @@ ] }, "encoding": { - "$ref": "#/definitions/AreaEncoding%3CDataRow%3E" + "$ref": "#/definitions/CircleEncoding%3CDataRow%3E" } } } @@ -1438,112 +770,15 @@ "anyOf": [ { "type": "string", - "const": "point" + "const": "lollipop" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "lollipop" }, "size": { "type": "number", @@ -1561,26 +796,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1611,47 +826,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -1659,99 +833,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -1761,7 +842,7 @@ ] }, "encoding": { - "$ref": "#/definitions/PointEncoding%3CDataRow%3E" + "$ref": "#/definitions/LollipopEncoding%3CDataRow%3E" } } } @@ -1784,112 +865,15 @@ "anyOf": [ { "type": "string", - "const": "circle" + "const": "beeswarm" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": "string", + "const": "beeswarm" }, "size": { "type": "number", @@ -1907,26 +891,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -1957,47 +921,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -2005,99 +928,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -2107,7 +937,7 @@ ] }, "encoding": { - "$ref": "#/definitions/CircleEncoding%3CDataRow%3E" + "$ref": "#/definitions/BeeswarmEncoding%3CDataRow%3E" } } } @@ -2130,100 +960,150 @@ "anyOf": [ { "type": "string", - "const": "lollipop" + "const": "range" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { + "style": { + "type": "string", + "enum": [ + "dumbbell", + "arrow", + "bar" + ], + "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + }, + "colorByDirection": { + "type": "boolean", + "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + }, + "type": { + "type": "string", + "const": "range" + }, + "size": { + "type": "number", + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." + }, + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." + }, + "opacity": { + "type": "number", + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." + }, + "fill": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" + "type": "string" }, { - "type": "string", - "const": "first" + "$ref": "#/definitions/GradientDef" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" + "description": "Default fill color or gradient." }, - "interpolate": { + "stroke": { "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + "description": "Tooltip behavior. null disables tooltips." }, - "orient": { + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { "type": "string", "enum": [ - "horizontal", - "vertical" + "auto", + "none" ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/RangeEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "waffle" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "units": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." }, - "endAngle": { + "columns": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." }, - "centerLabel": { + "other": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "object", "properties": { - "text": { - "type": "string" + "threshold": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, "required": [ - "text" + "threshold" ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." }, "cornerRadius": { "anyOf": [ @@ -2237,6 +1117,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "waffle" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -2253,26 +1137,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2303,17 +1167,51 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/WaffleEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "text" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "dx": { "type": "number", "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." @@ -2344,106 +1242,63 @@ "type": "number", "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." }, - "fillPattern": { + "type": { "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." + "const": "text" }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." + "size": { + "type": "number", + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "colorByDirection": { + "filled": { "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + "description": "Whether the mark is filled (vs stroked only)." }, - "units": { + "opacity": { "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." + "description": "Default opacity (0-1)." }, - "columns": { + "fillOpacity": { "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "other": { + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false + "$ref": "#/definitions/GradientDef" } ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." + "description": "Default fill color or gradient." }, - "weekStart": { + "stroke": { "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." + "description": "Default stroke color." }, - "cellRadius": { + "strokeWidth": { "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "description": "Default stroke width." }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Tooltip behavior. null disables tooltips." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -2453,7 +1308,7 @@ ] }, "encoding": { - "$ref": "#/definitions/LollipopEncoding%3CDataRow%3E" + "$ref": "#/definitions/TextEncoding%3CDataRow%3E" } } } @@ -2476,112 +1331,15 @@ "anyOf": [ { "type": "string", - "const": "beeswarm" + "const": "tick" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "tick" }, "size": { "type": "number", @@ -2599,26 +1357,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2649,47 +1387,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -2697,99 +1394,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -2799,7 +1403,7 @@ ] }, "encoding": { - "$ref": "#/definitions/BeeswarmEncoding%3CDataRow%3E" + "$ref": "#/definitions/TickEncoding%3CDataRow%3E" } } } @@ -2822,101 +1426,12 @@ "anyOf": [ { "type": "string", - "const": "range" + "const": "rect" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, "cornerRadius": { "anyOf": [ { @@ -2929,6 +1444,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "rect" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -2945,26 +1464,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -2995,47 +1494,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -3043,99 +1501,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -3145,7 +1510,7 @@ ] }, "encoding": { - "$ref": "#/definitions/RangeEncoding%3CDataRow%3E" + "$ref": "#/definitions/RectEncoding%3CDataRow%3E" } } } @@ -3168,112 +1533,15 @@ "anyOf": [ { "type": "string", - "const": "waffle" + "const": "rule" }, { "type": "object", "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "rule" }, "size": { "type": "number", @@ -3291,26 +1559,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -3341,47 +1589,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -3389,51 +1596,44 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/Encoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "calendar" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "weekStart": { "type": "string", "enum": [ @@ -3446,42 +1646,63 @@ "type": "number", "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." }, - "shape": { + "type": { "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "const": "calendar" }, - "seatRadius": { + "size": { + "type": "number", + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." + }, + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." + }, + "opacity": { + "type": "number", + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." + }, + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "string", - "const": "auto" + "$ref": "#/definitions/GradientDef" } ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Default fill color or gradient." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "stroke": { + "type": "string", + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Tooltip behavior. null disables tooltips." + }, + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -3491,7 +1712,7 @@ ] }, "encoding": { - "$ref": "#/definitions/WaffleEncoding%3CDataRow%3E" + "$ref": "#/definitions/CalendarEncoding%3CDataRow%3E" } } } @@ -3514,112 +1735,52 @@ "anyOf": [ { "type": "string", - "const": "text" + "const": "parliament" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." + "shape": { + "type": "string", + "const": "hemicycle", + "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." }, - "point": { + "seatRadius": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" + "type": "number" }, { "type": "string", - "const": "first" + "const": "auto" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." }, - "centerLabel": { + "majorityLine": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "object", "properties": { - "text": { - "type": "string" + "seats": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, - "required": [ - "text" - ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "parliament" }, "size": { "type": "number", @@ -3637,26 +1798,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -3687,47 +1828,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -3735,99 +1835,129 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { + } + }, + "required": [ + "type" + ] + } + ] + }, + "encoding": { + "$ref": "#/definitions/ParliamentEncoding%3CDataRow%3E" + } + } + } + ] + }, + { + "allOf": [ + { + "$ref": "#/definitions/ChartSpecBase" + }, + { + "type": "object", + "required": [ + "data", + "encoding", + "mark" + ], + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "histogram" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "orient": { "type": "string", "enum": [ - "dumbbell", - "arrow", - "bar" + "horizontal", + "vertical" ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." + "description": "Explicit orientation override for bar marks." }, - "other": { + "cornerRadius": { "anyOf": [ { "type": "number" }, { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false + "type": "string", + "const": "pill" } ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." + "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, - "weekStart": { + "binCount": { + "type": "number", + "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." + }, + "normalize": { + "type": "boolean", + "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." + }, + "type": { "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." + "const": "histogram" }, - "cellRadius": { + "size": { "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." }, - "seatRadius": { + "opacity": { + "type": "number", + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." + }, + "fill": { "anyOf": [ { - "type": "number" + "type": "string" }, { - "type": "string", - "const": "auto" + "$ref": "#/definitions/GradientDef" } ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Default fill color or gradient." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "stroke": { + "type": "string", + "description": "Default stroke color." + }, + "strokeWidth": { + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Tooltip behavior. null disables tooltips." + }, + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" + ], + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -3837,7 +1967,7 @@ ] }, "encoding": { - "$ref": "#/definitions/TextEncoding%3CDataRow%3E" + "$ref": "#/definitions/HistogramEncoding%3CDataRow%3E" } } } @@ -3860,16 +1990,12 @@ "anyOf": [ { "type": "string", - "const": "tick" + "const": "density" }, { "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -3908,2178 +2034,39 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." + "bandwidth": { + "type": "number", + "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." }, - "innerRadius": { + "cumulative": { + "type": "boolean", + "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." + }, + "steps": { "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." + "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." }, - "outerRadius": { + "type": { + "type": "string", + "const": "density" + }, + "size": { "type": "number", - "description": "Outer radius for arc marks." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "startAngle": { + "filled": { + "type": "boolean", + "description": "Whether the mark is filled (vs stroked only)." + }, + "opacity": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Default opacity (0-1)." }, - "endAngle": { + "fillOpacity": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/TickEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "rect" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/RectEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "rule" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/Encoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "calendar" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/CalendarEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "parliament" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/ParliamentEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "histogram" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { - "anyOf": [ - { - "type": "string" - }, - { - "$ref": "#/definitions/GradientDef" - } - ], - "description": "Default fill color or gradient." - }, - "stroke": { - "type": "string", - "description": "Default stroke color." - }, - "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." - } - }, - "required": [ - "type" - ] - } - ] - }, - "encoding": { - "$ref": "#/definitions/HistogramEncoding%3CDataRow%3E" - } - } - } - ] - }, - { - "allOf": [ - { - "$ref": "#/definitions/ChartSpecBase" - }, - { - "type": "object", - "required": [ - "data", - "encoding", - "mark" - ], - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "density" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." - }, - "size": { - "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, - "fill": { + "fill": { "anyOf": [ { "type": "string" @@ -6109,47 +2096,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -6157,99 +2103,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -6268,30 +2121,6 @@ ], "description": "Chart specification: the primary input for standard chart types.\n\nUses the Vega-Lite `mark` property instead of `type` to specify the visualization mark. The mark can be a string shorthand or an object with additional properties (interpolation, point markers, etc.).\n\nThis is a discriminated union — the `mark` value determines which encoding channels are required. TypeScript enforces required channels at compile time, matching the runtime rules in `MARK_ENCODING_RULES`." }, - "MarkType": { - "type": "string", - "enum": [ - "bar", - "line", - "area", - "point", - "circle", - "arc", - "text", - "rule", - "tick", - "rect", - "lollipop", - "beeswarm", - "range", - "waffle", - "calendar", - "parliament", - "histogram", - "density" - ], - "description": "Supported mark types, following Vega-Lite conventions.\n\nMapping from previous OpenChart chart types:\n- 'bar' covers both horizontal bars (old 'bar') and vertical columns (old 'column')\n- 'arc' covers both pie (old 'pie') and donut (old 'donut')\n- 'point' replaces old 'scatter'\n- 'circle' replaces old 'dot'\n- 'line' and 'area' unchanged\n\nNew mark types not in previous OpenChart:\n- 'text': data-positioned text labels\n- 'rule': reference lines as data marks\n- 'tick': strip/rug plot marks\n- 'rect': heatmaps and 2D binned plots\n- 'range': dumbbell / arrow / range-bar plots spanning two values per category\n- 'waffle': unit grids (\"x of 100\") for part-to-whole counts\n- 'calendar': GitHub-style calendar heatmap (weeks x weekdays, daily dates)\n- 'parliament': hemicycle seat-dot chart for election/legislature results\n- 'histogram': binned bars over a continuous field; sugar for a bar with `x.bin` + `y: { aggregate: 'count' }`\n- 'density': kernel density estimate drawn as a filled curve; sugar for an area over a density transform" - }, "GradientDef": { "anyOf": [ { @@ -6410,29 +2239,6 @@ "additionalProperties": false, "description": "Radial gradient definition. Coordinates are in [0,1] normalized space relative to the mark's bounding box." }, - "TrendlineConfig": { - "type": "object", - "properties": { - "layer": { - "type": "string", - "enum": [ - "above", - "below" - ], - "description": "Stacking order relative to the point marks. `'below'` (default) draws the line behind the dots; `'above'` draws it on top so it stays visible over a dense cloud." - }, - "stroke": { - "type": "string", - "description": "Stroke color. Defaults to the theme's high-contrast text color so the line reads on both light and dark grounds. Set an explicit CSS color to override." - }, - "strokeWidth": { - "type": "number", - "description": "Stroke width in pixels. Defaults to 1.5. Over a dense cloud with `layer: 'above'`, a heavier line (e.g. 2.5) stays legible where the thin default disappears into the dots." - } - }, - "additionalProperties": false, - "description": "Configuration for the scatter regression trend line. Passed as the object form of `MarkDef.trendline`." - }, "ArcEncoding": { "type": "object", "properties": { @@ -9812,6 +5618,29 @@ "additionalProperties": false, "description": "Encoding for area marks.\n- `x`: required (temporal or ordinal)\n- `y`: required (quantitative)" }, + "TrendlineConfig": { + "type": "object", + "properties": { + "layer": { + "type": "string", + "enum": [ + "above", + "below" + ], + "description": "Stacking order relative to the point marks. `'below'` (default) draws the line behind the dots; `'above'` draws it on top so it stays visible over a dense cloud." + }, + "stroke": { + "type": "string", + "description": "Stroke color. Defaults to the theme's high-contrast text color so the line reads on both light and dark grounds. Set an explicit CSS color to override." + }, + "strokeWidth": { + "type": "number", + "description": "Stroke width in pixels. Defaults to 1.5. Over a dense cloud with `layer: 'above'`, a heavier line (e.g. 2.5) stays legible where the thin default disappears into the dots." + } + }, + "additionalProperties": false, + "description": "Configuration for the scatter regression trend line. Passed as the object form of `MarkDef.trendline`." + }, "PointEncoding": { "type": "object", "properties": { @@ -11586,77 +7415,27 @@ } }, "required": [ - "layer" - ], - "additionalProperties": false, - "description": "Layer specification: composites multiple chart layers into a single view.\n\nEach element in `layer` is either a ChartSpec or another LayerSpec (nested). Shared data, encoding, and transforms at the LayerSpec level are inherited by children that don't define their own." - }, - "ChartSpec": { - "anyOf": [ - { - "type": "object", - "additionalProperties": false, - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "arc" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, + "layer" + ], + "additionalProperties": false, + "description": "Layer specification: composites multiple chart layers into a single view.\n\nEach element in `layer` is either a ChartSpec or another LayerSpec (nested). Shared data, encoding, and transforms at the LayerSpec level are inherited by children that don't define their own." + }, + "ChartSpec": { + "anyOf": [ + { + "type": "object", + "additionalProperties": false, + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "arc" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { "innerRadius": { "type": "number", "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." @@ -11696,17 +7475,9 @@ ], "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "arc" }, "size": { "type": "number", @@ -11724,26 +7495,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -11774,47 +7525,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -11822,99 +7532,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -12148,10 +7765,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -12181,73 +7794,18 @@ "enum": [ "linear", "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } + "step", + "step-before", + "step-after", + "basis", + "cardinal", + "natural" ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + }, + "type": { + "type": "string", + "const": "line" }, "size": { "type": "number", @@ -12265,26 +7823,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -12315,47 +7853,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -12363,99 +7860,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -12683,100 +8087,19 @@ "anyOf": [ { "type": "string", - "const": "bar" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } + "const": "bar" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "orient": { + "type": "string", + "enum": [ + "horizontal", + "vertical" ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Explicit orientation override for bar marks." }, "cornerRadius": { "anyOf": [ @@ -12790,6 +8113,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "bar" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -12806,26 +8133,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -12856,47 +8163,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -12904,99 +8170,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -13230,10 +8403,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -13272,64 +8441,9 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "area" }, "size": { "type": "number", @@ -13339,33 +8453,13 @@ "type": "boolean", "description": "Whether the mark is filled (vs stroked only)." }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { + "opacity": { "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, "fill": { "anyOf": [ @@ -13397,47 +8491,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -13445,99 +8498,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -13771,106 +8731,20 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { + "trendline": { "anyOf": [ { "type": "boolean" }, { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" + "$ref": "#/definitions/TrendlineConfig" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." }, - "orient": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "point" }, "size": { "type": "number", @@ -13884,29 +8758,9 @@ "type": "number", "description": "Default opacity (0-1)." }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { + "fillOpacity": { "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, "fill": { "anyOf": [ @@ -13938,47 +8792,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -13986,99 +8799,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -14313,105 +9033,8 @@ "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "circle" }, "size": { "type": "number", @@ -14427,27 +9050,7 @@ }, "fillOpacity": { "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, "fill": { "anyOf": [ @@ -14479,47 +9082,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -14527,99 +9089,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -14854,141 +9323,24 @@ "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "lollipop" }, "size": { "type": "number", - "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." - }, - "filled": { - "type": "boolean", - "description": "Whether the mark is filled (vs stroked only)." - }, - "opacity": { - "type": "number", - "description": "Default opacity (0-1)." - }, - "fillOpacity": { - "type": "number", - "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." - }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." + "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." }, - "cumulative": { + "filled": { "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." + "description": "Whether the mark is filled (vs stroked only)." }, - "steps": { + "opacity": { "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." + "description": "Default opacity (0-1)." + }, + "fillOpacity": { + "type": "number", + "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, "fill": { "anyOf": [ @@ -15020,47 +9372,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -15068,99 +9379,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -15384,116 +9602,19 @@ "type": "object", "additionalProperties": false, "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "beeswarm" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "mark": { + "anyOf": [ + { + "type": "string", + "const": "beeswarm" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { + "type": "string", + "const": "beeswarm" }, "size": { "type": "number", @@ -15511,26 +9632,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -15561,47 +9662,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -15609,99 +9669,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -15930,111 +9897,27 @@ { "type": "string", "const": "range" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "style": { + "type": "string", + "enum": [ + "dumbbell", + "arrow", + "bar" ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "colorByDirection": { + "type": "boolean", + "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." + }, + "type": { + "type": "string", + "const": "range" }, "size": { "type": "number", @@ -16052,26 +9935,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -16102,47 +9965,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -16150,99 +9972,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -16476,94 +10205,36 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { + "units": { "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." }, - "endAngle": { + "columns": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." }, - "centerLabel": { + "other": { "anyOf": [ { - "type": "string" + "type": "number" }, { "type": "object", - "properties": { - "text": { - "type": "string" + "properties": { + "threshold": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, "required": [ - "text" + "threshold" ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." }, "cornerRadius": { "anyOf": [ @@ -16577,6 +10248,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "waffle" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -16593,26 +10268,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -16643,47 +10298,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -16691,99 +10305,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -17017,106 +10538,39 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." + "dx": { + "type": "number", + "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" + "dy": { + "type": "number", + "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." }, - "interpolate": { + "align": { "type": "string", "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" + "left", + "center", + "right" ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." + "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." }, - "orient": { + "baseline": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } + "enum": [ + "top", + "middle", + "bottom" ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "fontSize": { + "type": "number", + "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." + }, + "type": { + "type": "string", + "const": "text" }, "size": { "type": "number", @@ -17134,26 +10588,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -17184,47 +10618,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -17232,99 +10625,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -17521,143 +10821,46 @@ ], "description": "Searchable series highlight (\"find your country\"). Renders a compact search input above the chart with typeahead over the color channel's distinct values. Selecting a value adds it to the highlight set (multi-select chips, removable); clearing restores the authored `encoding.color.highlight` baseline. Requires a categorical color encoding. Mutually exclusive with edit mode (search wins)." }, - "youDrawIt": { - "$ref": "#/definitions/YouDrawItConfig", - "description": "\"You draw it\" interactive engagement format: the reader draws their guess of the trend before the real line reveals. Line marks only, single-series only. Mutually exclusive with edit mode (validation warning if both are enabled; youDrawIt is disabled)." - }, - "display": { - "$ref": "#/definitions/Display", - "description": "Display mode controlling how much chart chrome is rendered.\n\n- `'full'` (default): full publication chart with chrome, axes, legend, padding.\n- `'sparkline'`: inline mini-chart for dashboards/KPI cards. Strips chrome, axes, legend, watermark, animation, and crosshair. The mark fills the container edge-to-edge with a small safety margin. Best with `mark: 'line' | 'area' | 'bar' | 'point'`.\n\n**Override precedence:** explicit user fields always win, even in sparkline mode. Setting `chrome.title` on a sparkline still renders the title. Setting `legend: { show: true }` still renders the legend. This applies at top-level and per-breakpoint overrides." - }, - "resolve": { - "$ref": "#/definitions/ResolveConfig", - "description": "Resolution strategy for shared/independent scales and axes across facet panels. Only meaningful when `encoding.facet` is present. Shared scales (the default) let readers compare across panels; independent scales give each panel its own domain." - }, - "zIndex": { - "type": "number", - "description": "Render order within a LayerSpec. Higher values render on top. When omitted, layers render in array order (later layers paint on top). Axis assignment (left/right y) is always determined by array position, not zIndex." - } - }, - "required": [ - "data", - "encoding", - "mark" - ] - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "mark": { - "anyOf": [ - { - "type": "string", - "const": "tick" - }, - { - "type": "object", - "additionalProperties": false, - "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { + "youDrawIt": { + "$ref": "#/definitions/YouDrawItConfig", + "description": "\"You draw it\" interactive engagement format: the reader draws their guess of the trend before the real line reveals. Line marks only, single-series only. Mutually exclusive with edit mode (validation warning if both are enabled; youDrawIt is disabled)." + }, + "display": { + "$ref": "#/definitions/Display", + "description": "Display mode controlling how much chart chrome is rendered.\n\n- `'full'` (default): full publication chart with chrome, axes, legend, padding.\n- `'sparkline'`: inline mini-chart for dashboards/KPI cards. Strips chrome, axes, legend, watermark, animation, and crosshair. The mark fills the container edge-to-edge with a small safety margin. Best with `mark: 'line' | 'area' | 'bar' | 'point'`.\n\n**Override precedence:** explicit user fields always win, even in sparkline mode. Setting `chrome.title` on a sparkline still renders the title. Setting `legend: { show: true }` still renders the legend. This applies at top-level and per-breakpoint overrides." + }, + "resolve": { + "$ref": "#/definitions/ResolveConfig", + "description": "Resolution strategy for shared/independent scales and axes across facet panels. Only meaningful when `encoding.facet` is present. Shared scales (the default) let readers compare across panels; independent scales give each panel its own domain." + }, + "zIndex": { + "type": "number", + "description": "Render order within a LayerSpec. Higher values render on top. When omitted, layers render in array order (later layers paint on top). Axis assignment (left/right y) is always determined by array position, not zIndex." + } + }, + "required": [ + "data", + "encoding", + "mark" + ] + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "mark": { + "anyOf": [ + { + "type": "string", + "const": "tick" + }, + { + "type": "object", + "additionalProperties": false, + "properties": { + "type": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "tick" }, "size": { "type": "number", @@ -17675,26 +10878,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -17723,149 +10906,15 @@ }, "clip": { "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -18099,95 +11148,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, "cornerRadius": { "anyOf": [ { @@ -18200,6 +11160,10 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "type": { + "type": "string", + "const": "rect" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -18216,26 +11180,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -18266,147 +11210,13 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -18641,105 +11451,8 @@ "additionalProperties": false, "properties": { "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "const": "rule" }, "size": { "type": "number", @@ -18757,26 +11470,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -18807,147 +11500,13 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ "auto", "none" ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -19181,106 +11740,21 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { + "weekStart": { "type": "string", "enum": [ - "horizontal", - "vertical" + "monday", + "sunday" ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." }, - "endAngle": { + "cellRadius": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "calendar" }, "size": { "type": "number", @@ -19298,26 +11772,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -19348,47 +11802,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -19396,99 +11809,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ @@ -19722,106 +12042,46 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." + "shape": { + "type": "string", + "const": "hemicycle", + "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." }, - "point": { + "seatRadius": { "anyOf": [ { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" + "type": "number" }, { "type": "string", - "const": "first" + "const": "auto" } ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." + "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." }, - "centerLabel": { + "majorityLine": { "anyOf": [ { - "type": "string" + "type": "boolean" }, { "type": "object", "properties": { - "text": { - "type": "string" + "seats": { + "type": "number" }, - "subtitle": { + "label": { "type": "string" } }, - "required": [ - "text" - ], "additionalProperties": false } ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "parliament" }, "size": { "type": "number", @@ -19839,26 +12099,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -19884,152 +12124,18 @@ "null" ], "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + }, + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -20263,48 +12369,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, - "point": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "string", - "const": "transparent" - }, - { - "type": "string", - "const": "endpoints" - }, - { - "type": "string", - "const": "last" - }, - { - "type": "string", - "const": "first" - } - ], - "description": "Show point markers on line/area marks.\n- true: filled circles at each data point\n- 'transparent': invisible hover targets (legacy behavior)\n- 'endpoints': show only first and last point per series (hollow dots)\n- 'last' / 'first': show only the last (or first) point — filled dot, no white halo. Useful for highlighting the latest reading on a line chart and the default for sparkline mode.\n- false: no point marks (default; uses voronoi overlay for tooltips)" - }, - "interpolate": { - "type": "string", - "enum": [ - "linear", - "monotone", - "step", - "step-before", - "step-after", - "basis", - "cardinal", - "natural" - ], - "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." - }, "orient": { "type": "string", "enum": [ @@ -20313,45 +12377,6 @@ ], "description": "Explicit orientation override for bar marks." }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { - "type": "number", - "description": "Outer radius for arc marks." - }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." - }, - "endAngle": { - "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." - }, "cornerRadius": { "anyOf": [ { @@ -20364,6 +12389,18 @@ ], "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." }, + "binCount": { + "type": "number", + "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." + }, + "normalize": { + "type": "boolean", + "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." + }, + "type": { + "type": "string", + "const": "histogram" + }, "size": { "type": "number", "description": "Bar/column marks: fixed bar thickness in pixels. When set, bars are this height (horizontal) or width (vertical), centered within the band.\n\nPoint marks: the dot RADIUS in pixels (default 5, typical 2-12). This is NOT Vega-Lite's point `size`, which is an area in px^2 -- a VL size of 110 corresponds to `size: 6` here (r = sqrt(area / pi)). Validation rejects values above 50 with the conversion." @@ -20380,26 +12417,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -20416,161 +12433,27 @@ "description": "Default stroke color." }, "strokeWidth": { - "type": "number", - "description": "Default stroke width." - }, - "tooltip": { - "type": [ - "boolean", - "null" - ], - "description": "Tooltip behavior. null disables tooltips." - }, - "clip": { - "type": "boolean", - "description": "Clip marks to the chart area." - }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, - "fillPattern": { - "type": "string", - "enum": [ - "auto", - "none" - ], - "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } + "type": "number", + "description": "Default stroke width." + }, + "tooltip": { + "type": [ + "boolean", + "null" ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." + "description": "Tooltip behavior. null disables tooltips." }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } + "clip": { + "type": "boolean", + "description": "Clip marks to the chart area." + }, + "fillPattern": { + "type": "string", + "enum": [ + "auto", + "none" ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." + "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." } }, "required": [ @@ -20804,10 +12687,6 @@ "type": "object", "additionalProperties": false, "properties": { - "type": { - "$ref": "#/definitions/MarkType", - "description": "The mark type." - }, "point": { "anyOf": [ { @@ -20846,64 +12725,21 @@ ], "description": "Curve interpolation for line/area marks. Maps to d3-shape curve factories." }, - "orient": { - "type": "string", - "enum": [ - "horizontal", - "vertical" - ], - "description": "Explicit orientation override for bar marks." - }, - "innerRadius": { - "type": "number", - "description": "Inner radius for arc marks. >0 produces a donut, 0 or omitted produces a pie." - }, - "outerRadius": { + "bandwidth": { "type": "number", - "description": "Outer radius for arc marks." + "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." }, - "startAngle": { - "type": "number", - "description": "Start angle in radians for arc marks. Defaults to 0 (full pie/donut). Combined with `endAngle`, restricts the pie/donut to a partial sweep, e.g. `startAngle: -Math.PI / 2, endAngle: Math.PI / 2` for a half-donut (election-style results donut). Angles follow d3's convention: 0 is straight up, increasing clockwise. Only meaningful when `type` is `'arc'`." + "cumulative": { + "type": "boolean", + "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." }, - "endAngle": { + "steps": { "type": "number", - "description": "End angle in radians for arc marks. Defaults to 2*Math.PI (full pie/donut). See `startAngle`. Only meaningful when `type` is `'arc'`." - }, - "centerLabel": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "object", - "properties": { - "text": { - "type": "string" - }, - "subtitle": { - "type": "string" - } - }, - "required": [ - "text" - ], - "additionalProperties": false - } - ], - "description": "Donut center stat. A string renders one line; the object form adds a caption under it. Decorative (the number is already in the slices), and only meaningful when `type` is `'arc'` with an inner radius." + "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." }, - "cornerRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "pill" - } - ], - "description": "Corner radius for rect/bar marks. 'pill' sets rx to half the bar thickness." + "type": { + "type": "string", + "const": "density" }, "size": { "type": "number", @@ -20921,26 +12757,6 @@ "type": "number", "description": "Fill opacity (0-1), independent of stroke (Vega-Lite aligned).\n\nOn a binned bar with more than one color group this defaults to 0.55 so overlapping distributions stay readable; set it explicitly to override. On an area, setting it switches the fill from the default gradient to a flat translucent series color — gradients and overlapping fills don't mix." }, - "binCount": { - "type": "number", - "description": "Number of bins. Only meaningful when `type` is `'histogram'`; desugars to `x.bin.maxbins`. Defaults to 20. For a fixed bin width or a pinned extent, write `x.bin` directly — it is the canonical form and takes precedence over this shorthand." - }, - "normalize": { - "type": "boolean", - "description": "Plot each group's share of its own total instead of a raw count. Only meaningful when `type` is `'histogram'`. Makes two distributions of very different size directly comparable." - }, - "bandwidth": { - "type": "number", - "description": "Gaussian kernel bandwidth. Only meaningful when `type` is `'density'`. Omitted uses Silverman's rule of thumb." - }, - "cumulative": { - "type": "boolean", - "description": "Draw the cumulative distribution instead of the density. Only meaningful when `type` is `'density'`." - }, - "steps": { - "type": "number", - "description": "Number of points the density curve is evaluated at. Only meaningful when `type` is `'density'`. Defaults to 200." - }, "fill": { "anyOf": [ { @@ -20971,47 +12787,6 @@ "type": "boolean", "description": "Clip marks to the chart area." }, - "trendline": { - "anyOf": [ - { - "type": "boolean" - }, - { - "$ref": "#/definitions/TrendlineConfig" - } - ], - "description": "Overlay a least-squares regression line on scatter (point) marks. Defaults to true. Set `mark: { type: 'point', trendline: false }` to suppress it when the chart already carries its own reference line (e.g. a manual x=y diagonal in a separate layer), which would otherwise produce two competing diagonals.\n\nPass an object to control stacking order: the line renders `below` the points by default, but a dense scatter (thousands of overlapping dots) fully occludes a line drawn underneath, so `{ layer: 'above' }` lifts the reference line over the cloud." - }, - "dx": { - "type": "number", - "description": "Horizontal pixel offset from the data anchor. Only meaningful when `type` is `'text'`. Use it to lift a label clear of the mark it annotates instead of shifting the underlying data value." - }, - "dy": { - "type": "number", - "description": "Vertical pixel offset from the data anchor (negative = up). Only meaningful when `type` is `'text'`. `{ type: 'text', dy: -10 }` floats each label ten pixels above its point." - }, - "align": { - "type": "string", - "enum": [ - "left", - "center", - "right" - ], - "description": "Horizontal alignment of text marks relative to their anchor. Defaults to `'center'`." - }, - "baseline": { - "type": "string", - "enum": [ - "top", - "middle", - "bottom" - ], - "description": "Vertical alignment of text marks relative to their anchor. Defaults to `'middle'`." - }, - "fontSize": { - "type": "number", - "description": "Font size in pixels for text marks. Ignored when the `size` encoding channel is present (which scales font size from a data field). `size` is already taken by bar thickness, hence the distinct name." - }, "fillPattern": { "type": "string", "enum": [ @@ -21019,99 +12794,6 @@ "none" ], "description": "Pattern-fill reinforcement for filled marks (bar, area, arc).\n\n- `'auto'`: assign a per-series SVG pattern (diagonal hatch, dots, crosshatch, vertical lines) layered over each series color so series remain distinguishable without color vision. Marks too small to show a legible pattern keep their solid fill.\n- `'none'` (default): solid fills only." - }, - "style": { - "type": "string", - "enum": [ - "dumbbell", - "arrow", - "bar" - ], - "description": "Visual style for range marks. Only meaningful when `type` is `'range'`.\n- 'dumbbell' (default): a dot at each end joined by a connector line. The start dot is muted, the end dot carries the accent color.\n- 'arrow': a line with an arrowhead at the x2/y2 end, the strongest \"change between two points in time\" form.\n- 'bar': a plain floating range bar spanning start to end." - }, - "colorByDirection": { - "type": "boolean", - "description": "Color range marks by direction of change. Only meaningful when `type` is `'range'`. When true, increases (x2 > x, or y2 > y) use the theme's `positive` semantic color and decreases use `negative`. A field-based `encoding.color` takes precedence over direction coloring." - }, - "units": { - "type": "number", - "description": "Total number of cells in a waffle grid. Only meaningful when `type` is `'waffle'`. Category values normalize to this many cells via largest-remainder rounding so the grid always sums exactly. Default 100." - }, - "columns": { - "type": "number", - "description": "Number of columns in a waffle grid. Only meaningful when `type` is `'waffle'`. Rows derive from `units / columns`. Default 10." - }, - "other": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "object", - "properties": { - "threshold": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "required": [ - "threshold" - ], - "additionalProperties": false - } - ], - "description": "Opt-in \"Other\" bucketing for waffle grids. Only meaningful when `type` is `'waffle'`. Categories whose share of the total falls below `threshold` (a fraction, so `0.05` is 5%) merge into one trailing category. Off by default: bucketing is an editorial decision, not a layout default. Same shape as `SankeySpec.other`." - }, - "weekStart": { - "type": "string", - "enum": [ - "monday", - "sunday" - ], - "description": "First day of the week for calendar marks. Only meaningful when `type` is `'calendar'`. Controls which weekday occupies the top row of each band. Defaults to 'monday' (ISO week convention)." - }, - "cellRadius": { - "type": "number", - "description": "Corner radius in pixels for calendar day cells. Only meaningful when `type` is `'calendar'`. Defaults to 2." - }, - "shape": { - "type": "string", - "const": "hemicycle", - "description": "Seat layout shape for parliament marks. Only meaningful when `type` is `'parliament'`. `'hemicycle'` (default, and currently the only shape) packs seats into concentric semicircular arcs, party-grouped left to right." - }, - "seatRadius": { - "anyOf": [ - { - "type": "number" - }, - { - "type": "string", - "const": "auto" - } - ], - "description": "Seat dot radius in pixels for parliament marks. `'auto'` (default) sizes dots to fill the available hemicycle rings for the given seat count. Only meaningful when `type` is `'parliament'`." - }, - "majorityLine": { - "anyOf": [ - { - "type": "boolean" - }, - { - "type": "object", - "properties": { - "seats": { - "type": "number" - }, - "label": { - "type": "string" - } - }, - "additionalProperties": false - } - ], - "description": "The majority-threshold line and \"N to win\" label on parliament marks. Only meaningful when `type` is `'parliament'`. `true` (default) draws the line at a simple majority (half the seats plus one); `false` hides it. The object form overrides the threshold and label: `{ seats }` sets a custom seat count (e.g. a two-thirds supermajority) and `{ label }` overrides the default \"N to win\" text. A `seats` value outside `1..totalSeats` warns and falls back to the simple-majority default." } }, "required": [ diff --git a/packages/core/src/accessibility/alt-text.ts b/packages/core/src/accessibility/alt-text.ts index fa6b2e11..c4ce419a 100644 --- a/packages/core/src/accessibility/alt-text.ts +++ b/packages/core/src/accessibility/alt-text.ts @@ -27,7 +27,7 @@ export function generateAltText(spec: ChartSpec, data: DataRow[]): string { let chartName = MARK_DISPLAY_NAMES[markType] ?? `${markType} chart`; // Special case: donut detection - if (markType === 'arc' && markDef.innerRadius && markDef.innerRadius > 0) { + if (markDef.type === 'arc' && markDef.innerRadius && markDef.innerRadius > 0) { chartName = 'Donut chart'; } @@ -56,7 +56,7 @@ export function generateAltText(spec: ChartSpec, data: DataRow[]): string { // Waffle: name the unit framing. Per-category "x of N units" phrasing // lives on the mark aria labels; the chart-level text carries the total. - if (markType === 'waffle') { + if (markDef.type === 'waffle') { const units = markDef.units ?? 100; parts.push(`dividing ${units} units`); } diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index 398afd01..77d61263 100644 --- a/packages/core/src/types/spec.ts +++ b/packages/core/src/types/spec.ts @@ -179,7 +179,7 @@ export interface TrendlineConfig { * When it's an object, it can include properties like interpolation, * point markers, orientation, and more. */ -export interface MarkDef { +interface MarkOptions { /** The mark type. */ type: MarkType; /** @@ -410,6 +410,64 @@ export interface MarkDef { majorityLine?: boolean | { seats?: number; label?: string }; } +/** Shared visual options accepted by every authored mark. */ +type SharedMarkOptions = Pick< + MarkOptions, + | 'size' + | 'filled' + | 'opacity' + | 'fillOpacity' + | 'fill' + | 'stroke' + | 'strokeWidth' + | 'tooltip' + | 'clip' + | 'fillPattern' +>; + +/** + * Authored mark configuration. Each mark accepts only options it understands. + * String marks remain shorthand for `{ type: mark }`. + * Extend this union with `type CustomMark = MarkDef & { ... }`, not an interface. + */ +export type MarkDef = + | (SharedMarkOptions & { type: 'bar' } & Pick) + | (SharedMarkOptions & { type: 'line' } & Pick) + | (SharedMarkOptions & { type: 'area' } & Pick) + | (SharedMarkOptions & { type: 'point' } & Pick) + | (SharedMarkOptions & { type: 'circle' }) + | (SharedMarkOptions & { type: 'arc' } & Pick< + MarkOptions, + 'innerRadius' | 'outerRadius' | 'startAngle' | 'endAngle' | 'centerLabel' + >) + | (SharedMarkOptions & { type: 'text' } & Pick< + MarkOptions, + 'dx' | 'dy' | 'align' | 'baseline' | 'fontSize' + >) + | (SharedMarkOptions & { type: 'rule' }) + | (SharedMarkOptions & { type: 'tick' }) + | (SharedMarkOptions & { type: 'rect' } & Pick) + | (SharedMarkOptions & { type: 'lollipop' }) + | (SharedMarkOptions & { type: 'beeswarm' }) + | (SharedMarkOptions & { type: 'range' } & Pick) + | (SharedMarkOptions & { type: 'waffle' } & Pick< + MarkOptions, + 'units' | 'columns' | 'other' | 'cornerRadius' + >) + | (SharedMarkOptions & { type: 'calendar' } & Pick) + | (SharedMarkOptions & { type: 'parliament' } & Pick< + MarkOptions, + 'shape' | 'seatRadius' | 'majorityLine' + >) + | (SharedMarkOptions & { type: 'histogram' } & Pick< + MarkOptions, + 'orient' | 'cornerRadius' | 'binCount' | 'normalize' + >) + | (SharedMarkOptions & { type: 'density' } & Pick< + MarkOptions, + 'point' | 'interpolate' | 'bandwidth' | 'cumulative' | 'steps' + >); + // --------------------------------------------------------------------------- // Encoding // --------------------------------------------------------------------------- @@ -2213,75 +2271,75 @@ interface BaseChartSpec { */ export type ChartSpec = | (BaseChartSpec & { - mark: 'arc' | (MarkDef & { type: 'arc' }); + mark: 'arc' | Extract; encoding: ArcEncoding; }) | (BaseChartSpec & { - mark: 'line' | (MarkDef & { type: 'line' }); + mark: 'line' | Extract; encoding: LineEncoding; }) | (BaseChartSpec & { - mark: 'bar' | (MarkDef & { type: 'bar' }); + mark: 'bar' | Extract; encoding: BarEncoding; }) | (BaseChartSpec & { - mark: 'area' | (MarkDef & { type: 'area' }); + mark: 'area' | Extract; encoding: AreaEncoding; }) | (BaseChartSpec & { - mark: 'point' | (MarkDef & { type: 'point' }); + mark: 'point' | Extract; encoding: PointEncoding; }) | (BaseChartSpec & { - mark: 'circle' | (MarkDef & { type: 'circle' }); + mark: 'circle' | Extract; encoding: CircleEncoding; }) | (BaseChartSpec & { - mark: 'lollipop' | (MarkDef & { type: 'lollipop' }); + mark: 'lollipop' | Extract; encoding: LollipopEncoding; }) | (BaseChartSpec & { - mark: 'beeswarm' | (MarkDef & { type: 'beeswarm' }); + mark: 'beeswarm' | Extract; encoding: BeeswarmEncoding; }) | (BaseChartSpec & { - mark: 'range' | (MarkDef & { type: 'range' }); + mark: 'range' | Extract; encoding: RangeEncoding; }) | (BaseChartSpec & { - mark: 'waffle' | (MarkDef & { type: 'waffle' }); + mark: 'waffle' | Extract; encoding: WaffleEncoding; }) | (BaseChartSpec & { - mark: 'text' | (MarkDef & { type: 'text' }); + mark: 'text' | Extract; encoding: TextEncoding; }) | (BaseChartSpec & { - mark: 'tick' | (MarkDef & { type: 'tick' }); + mark: 'tick' | Extract; encoding: TickEncoding; }) | (BaseChartSpec & { - mark: 'rect' | (MarkDef & { type: 'rect' }); + mark: 'rect' | Extract; encoding: RectEncoding; }) | (BaseChartSpec & { - mark: 'rule' | (MarkDef & { type: 'rule' }); + mark: 'rule' | Extract; encoding: Encoding; }) | (BaseChartSpec & { - mark: 'calendar' | (MarkDef & { type: 'calendar' }); + mark: 'calendar' | Extract; encoding: CalendarEncoding; }) | (BaseChartSpec & { - mark: 'parliament' | (MarkDef & { type: 'parliament' }); + mark: 'parliament' | Extract; encoding: ParliamentEncoding; }) | (BaseChartSpec & { - mark: 'histogram' | (MarkDef & { type: 'histogram' }); + mark: 'histogram' | Extract; encoding: HistogramEncoding; }) | (BaseChartSpec & { - mark: 'density' | (MarkDef & { type: 'density' }); + mark: 'density' | Extract; encoding: DensityEncoding; }); diff --git a/packages/engine/src/__test-fixtures__/mark-options.ts b/packages/engine/src/__test-fixtures__/mark-options.ts new file mode 100644 index 00000000..5fe802f1 --- /dev/null +++ b/packages/engine/src/__test-fixtures__/mark-options.ts @@ -0,0 +1,88 @@ +import type { MarkDef, MarkType } from '@opendata-ai/openchart-core'; + +/** Independently specified authoring contract, checked by TypeScript and exercised + * against published JSON Schema and runtime diagnostics. */ +export const MARK_CONTRACT_TYPES = [ + 'bar', + 'line', + 'area', + 'point', + 'circle', + 'arc', + 'text', + 'rule', + 'tick', + 'rect', + 'lollipop', + 'beeswarm', + 'range', + 'waffle', + 'calendar', + 'parliament', + 'histogram', + 'density', +] as const satisfies readonly MarkType[]; + +export const MARK_OPTION_CASES = { + orient: { value: 'horizontal', marks: ['bar', 'histogram'] }, + cornerRadius: { value: 3, marks: ['bar', 'rect', 'histogram', 'waffle'] }, + point: { value: true, marks: ['line', 'area', 'density'] }, + interpolate: { value: 'linear', marks: ['line', 'area', 'density'] }, + innerRadius: { value: 20, marks: ['arc'] }, + outerRadius: { value: 80, marks: ['arc'] }, + startAngle: { value: 0, marks: ['arc'] }, + endAngle: { value: 3.14, marks: ['arc'] }, + centerLabel: { value: 'Total', marks: ['arc'] }, + binCount: { value: 8, marks: ['histogram'] }, + normalize: { value: true, marks: ['histogram'] }, + bandwidth: { value: 2, marks: ['density'] }, + cumulative: { value: true, marks: ['density'] }, + steps: { value: 25, marks: ['density'] }, + trendline: { value: true, marks: ['point'] }, + dx: { value: 10, marks: ['text'] }, + dy: { value: -10, marks: ['text'] }, + align: { value: 'left', marks: ['text'] }, + baseline: { value: 'top', marks: ['text'] }, + fontSize: { value: 12, marks: ['text'] }, + style: { value: 'arrow', marks: ['range'] }, + colorByDirection: { value: true, marks: ['range'] }, + units: { value: 100, marks: ['waffle'] }, + columns: { value: 10, marks: ['waffle'] }, + other: { value: 0.05, marks: ['waffle'] }, + weekStart: { value: 'monday', marks: ['calendar'] }, + cellRadius: { value: 2, marks: ['calendar'] }, + shape: { value: 'hemicycle', marks: ['parliament'] }, + seatRadius: { value: 3, marks: ['parliament'] }, + majorityLine: { value: true, marks: ['parliament'] }, + size: { value: 5, marks: MARK_CONTRACT_TYPES }, + filled: { value: true, marks: MARK_CONTRACT_TYPES }, + opacity: { value: 0.6, marks: MARK_CONTRACT_TYPES }, + fillOpacity: { value: 0.5, marks: MARK_CONTRACT_TYPES }, + fill: { value: '#ff0000', marks: MARK_CONTRACT_TYPES }, + stroke: { value: '#333333', marks: MARK_CONTRACT_TYPES }, + strokeWidth: { value: 2, marks: MARK_CONTRACT_TYPES }, + tooltip: { value: true, marks: MARK_CONTRACT_TYPES }, + clip: { value: true, marks: MARK_CONTRACT_TYPES }, + fillPattern: { value: 'none', marks: MARK_CONTRACT_TYPES }, +} as const; + +type UnionKeys = T extends unknown ? keyof T : never; +type AllowedMarks = T extends { type: infer M } + ? K extends keyof T + ? M + : never + : never; +type Equal = [A] extends [B] ? ([B] extends [A] ? true : false) : false; +type Assert = T; +type Cases = typeof MARK_OPTION_CASES; +/** These compile as part of engine typecheck, unlike Vitest-only type assertions. */ +export type MarkContractTypeChecks = [ + Assert>, + Assert, 'type'>>>, + Assert< + Equal< + { [K in keyof Cases]: Equal> }[keyof Cases], + true + > + >, +]; diff --git a/packages/engine/src/charts/line/curves.ts b/packages/engine/src/charts/line/curves.ts index 55a21dba..d1cd18cd 100644 --- a/packages/engine/src/charts/line/curves.ts +++ b/packages/engine/src/charts/line/curves.ts @@ -1,3 +1,4 @@ +import type { NormalizedMarkDef } from '../../compiler/types'; /** * Curve interpolation mapping and path builders. * @@ -9,7 +10,7 @@ * SVG paths from point arrays without pulling in chart-specific code. */ -import type { MarkDef, Point } from '@opendata-ai/openchart-core'; +import type { Point } from '@opendata-ai/openchart-core'; import type { CurveFactory } from 'd3-shape'; import { area, @@ -25,7 +26,7 @@ import { } from 'd3-shape'; /** Map of interpolation string names to d3 curve factories. */ -const CURVE_MAP: Record, CurveFactory> = { +const CURVE_MAP: Record, CurveFactory> = { linear: curveLinear, monotone: curveMonotoneX, step: curveStep, @@ -40,7 +41,7 @@ const CURVE_MAP: Record, CurveFactory> = { * Resolve an interpolation string to a d3 curve factory. * Defaults to `curveMonotoneX` when no interpolation is specified. */ -export function resolveCurve(interpolate?: MarkDef['interpolate']): CurveFactory { +export function resolveCurve(interpolate?: NormalizedMarkDef['interpolate']): CurveFactory { if (!interpolate) return curveMonotoneX; return CURVE_MAP[interpolate] ?? curveMonotoneX; } @@ -51,7 +52,7 @@ export function resolveCurve(interpolate?: MarkDef['interpolate']): CurveFactory */ export function buildLinePath(points: Point[], interpolate?: string): string { if (points.length === 0) return ''; - const curve = resolveCurve(interpolate as MarkDef['interpolate']); + const curve = resolveCurve(interpolate as NormalizedMarkDef['interpolate']); const generator = line() .x((d) => d.x) .y((d) => d.y) @@ -69,7 +70,7 @@ export function buildAreaPath( interpolate?: string, ): string { if (topPoints.length === 0) return ''; - const curve = resolveCurve(interpolate as MarkDef['interpolate']); + const curve = resolveCurve(interpolate as NormalizedMarkDef['interpolate']); // Pair top and bottom points for the area generator const paired = topPoints.map((tp, i) => ({ x: tp.x, diff --git a/packages/engine/src/charts/post-process.ts b/packages/engine/src/charts/post-process.ts index ec656a1c..4ae8c314 100644 --- a/packages/engine/src/charts/post-process.ts +++ b/packages/engine/src/charts/post-process.ts @@ -1,3 +1,4 @@ +import type { NormalizedMarkDef } from '../compiler/types'; /** * Mark post-processing: obstacle computation, renderer dispatch, and * animation index assignment. These are mark-type-aware operations that @@ -9,7 +10,6 @@ import type { Encoding, LineMark, Mark, - MarkDef, PointMark, RectMark, ResolvedAnimation, @@ -154,7 +154,7 @@ export function isBinnedBarEncoding(markType: string, encoding: Partial, - markDef: Partial, + markDef: Partial, ): string { if (markType === 'bar') { if (isBinnedBarEncoding(markType, encoding)) { diff --git a/packages/engine/src/compile.ts b/packages/engine/src/compile.ts index ddde00e3..503f9db9 100644 --- a/packages/engine/src/compile.ts +++ b/packages/engine/src/compile.ts @@ -60,7 +60,7 @@ import { import type { PlacementObstacle } from './annotations/placement'; import { referenceLabelReserve } from './annotations/reference-layout'; import { thinAnnotations } from './annotations/thinning'; -import { collectMarkOptionWarnings } from './compiler/mark-options'; +import { assertMarkOptions } from './compiler/mark-options'; import { computeEndpointLabels } from './endpoint-labels/compute'; import { ENDPOINT_COLUMN_GAP } from './endpoint-labels/constants'; // Side-effect import: registers all built-in chart renderers with the @@ -269,11 +269,11 @@ function resolveScaleInvert(resolved: ResolvedScale | undefined): ScaleInvert | * @throws Error if spec is invalid or not a chart type. */ export function compileChart(spec: unknown, optionsInput: CompileOptions): ChartLayout { - emitSpecWarnings(collectMarkOptionWarnings(spec), optionsInput?.onWarn); + assertMarkOptions(spec); return compileChartInternal(spec, optionsInput); } -/** Internal passes retain authored warnings from the public entry point. */ +/** Internal passes use options validated at the public authored boundary. */ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): ChartLayout { // Expand VL-idiom and encoding-level sugar (data {values}, top-level title, // sort-by-value, value defs, scheme names, theta, bin/timeUnit, ...) before diff --git a/packages/engine/src/compile/fill-patterns.ts b/packages/engine/src/compile/fill-patterns.ts index 51dc2ba4..9ab9622a 100644 --- a/packages/engine/src/compile/fill-patterns.ts +++ b/packages/engine/src/compile/fill-patterns.ts @@ -1,3 +1,4 @@ +import type { NormalizedMarkDef } from '../compiler/types'; /** * Per-series fill pattern assignment for `mark.fillPattern: 'auto'`. * @@ -15,7 +16,6 @@ import type { FillPatternType, GradientDef, Mark, - MarkDef, ResolvedFillPattern, ResolvedTheme, } from '@opendata-ai/openchart-core'; @@ -36,7 +36,11 @@ const MIN_PATTERN_EXTENT = 12; * in with `fillPattern: 'auto'`. Mutates marks in place (same convention as * assignAnimationIndices). No-op otherwise. */ -export function applyFillPatterns(marks: Mark[], markDef: MarkDef, theme: ResolvedTheme): void { +export function applyFillPatterns( + marks: Mark[], + markDef: NormalizedMarkDef, + theme: ResolvedTheme, +): void { if (markDef.fillPattern !== 'auto') return; const assignment = new Map(); diff --git a/packages/engine/src/compile/spec-sugar.ts b/packages/engine/src/compile/spec-sugar.ts index fc04f50f..4d3c311a 100644 --- a/packages/engine/src/compile/spec-sugar.ts +++ b/packages/engine/src/compile/spec-sugar.ts @@ -1,4 +1,4 @@ -import { collectMarkOptionWarnings } from '../compiler/mark-options'; +import { assertMarkOptions } from '../compiler/mark-options'; /** * Pre-validation spec sugar expansion. * @@ -1019,10 +1019,10 @@ function expandValueFormatSugar( export function expandSpecSugar( spec: Record, warnings: string[] = [], - markOptionWarnings = true, + checkMarkOptions = true, ): Record { if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return spec; - if (markOptionWarnings) warnings.push(...collectMarkOptionWarnings(spec)); + if (checkMarkOptions) assertMarkOptions(spec); if (Array.isArray(spec.layer) && typeof spec.type !== 'string') { return expandLayerSugar(spec, warnings); } diff --git a/packages/engine/src/compiler/__tests__/mark-options.test.ts b/packages/engine/src/compiler/__tests__/mark-options.test.ts index 7184b074..253abf66 100644 --- a/packages/engine/src/compiler/__tests__/mark-options.test.ts +++ b/packages/engine/src/compiler/__tests__/mark-options.test.ts @@ -1,117 +1,162 @@ +import { readFileSync } from 'node:fs'; +import Ajv from 'ajv'; import { describe, expect, it } from 'vitest'; +import { MARK_CONTRACT_TYPES, MARK_OPTION_CASES } from '../../__test-fixtures__/mark-options'; +import { compileChart, compileLayer } from '../../compile'; import { expandSpecSugar } from '../../compile/spec-sugar'; -import { collectMarkOptionWarnings } from '../mark-options'; +import { collectMarkOptionErrors } from '../mark-options'; +import { validateSpec } from '../validate'; -const types = [ - 'bar', - 'line', - 'area', - 'point', - 'circle', - 'arc', - 'text', - 'rule', - 'tick', - 'rect', - 'lollipop', - 'beeswarm', - 'range', - 'waffle', - 'calendar', - 'parliament', - 'histogram', - 'density', -]; -const cases: Array<[string, string[]]> = [ - ['orient', ['bar', 'histogram']], - ['cornerRadius', ['bar', 'rect', 'histogram', 'waffle']], - ['point', ['line', 'area', 'density']], - ['interpolate', ['line', 'area', 'density']], - ['innerRadius', ['arc']], - ['outerRadius', ['arc']], - ['startAngle', ['arc']], - ['endAngle', ['arc']], - ['centerLabel', ['arc']], - ['binCount', ['histogram']], - ['normalize', ['histogram']], - ['bandwidth', ['density']], - ['cumulative', ['density']], - ['steps', ['density']], - ['trendline', ['point']], - ['dx', ['text']], - ['dy', ['text']], - ['align', ['text']], - ['baseline', ['text']], - ['fontSize', ['text']], - ['style', ['range']], - ['colorByDirection', ['range']], - ['units', ['waffle']], - ['columns', ['waffle']], - ['other', ['waffle']], - ['weekStart', ['calendar']], - ['cellRadius', ['calendar']], - ['shape', ['parliament']], - ['seatRadius', ['parliament']], - ['majorityLine', ['parliament']], -]; -describe('mark option applicability (8.x warnings)', () => { - it.each(types)('checks every specialized option on %s', (type) => { - for (const [option, allowed] of cases) { - const warnings = collectMarkOptionWarnings({ mark: { type, [option]: true } }); - expect(warnings.length, `${type}.${option}`).toBe(allowed.includes(type) ? 0 : 1); - if (warnings.length) { - expect(warnings[0]).toContain(`/mark/${option}`); - expect(warnings[0]).toContain('Remove'); +describe('mark option applicability (v9 errors)', () => { + it.each(MARK_CONTRACT_TYPES)('checks every specialized option on %s', (type) => { + for (const [option, { value, marks: allowed }] of Object.entries(MARK_OPTION_CASES)) { + const errors = collectMarkOptionErrors({ mark: { type, [option]: value } }); + expect(errors.length, `${type}.${option}`).toBe( + (allowed as readonly string[]).includes(type) ? 0 : 1, + ); + if (errors.length) { + expect(errors[0].path).toBe(`/mark/${option}`); + expect(errors[0].suggestion).toContain('Remove'); } } }); - it('reports nested authored paths before density sugar strips options', () => { - const warnings: string[] = []; - expandSpecSugar( - { - layer: [ - { - layer: [ - { - mark: { type: 'density', binCount: 8, bandwidth: 2 }, - data: [{ v: 1 }], - encoding: { x: { field: 'v', type: 'quantitative' } }, - }, - ], - }, - ], - }, - warnings, - ); - expect(warnings.some((w) => w.includes('/layer/0/layer/0/mark/binCount'))).toBe(true); - expect(warnings.some((w) => w.includes('/mark/bandwidth'))).toBe(false); - }); -}); - -it('emits only authored paths and mark names through nested layer compilation', async () => { - const { compileLayer } = await import('../../compile'); - const warnings: string[] = []; - compileLayer( - { + it('rejects nested authored paths before density sugar strips options', () => { + const spec = { layer: [ { layer: [ { - mark: { type: 'density', binCount: 8 }, + mark: { type: 'density', binCount: 8, bandwidth: 2 }, data: [{ x: 1 }, { x: 2 }, { x: 3 }], encoding: { x: { field: 'x', type: 'quantitative' } }, }, ], }, ], - }, - { width: 600, height: 400, onWarn: (w) => warnings.push(w) }, - ); - expect(warnings.filter((w) => w.includes('binCount'))).toEqual([ - '[openchart] /layer/0/layer/0/mark/binCount has no effect on mark "density". Remove binCount, or use a supported mark: histogram.', - ]); + }; + const result = validateSpec(spec); + expect(result.valid).toBe(false); + expect(result.errors).toContainEqual( + expect.objectContaining({ + path: '/layer/0/layer/0/mark/binCount', + code: 'INVALID_VALUE', + }), + ); + expect(result.errors.some((e) => e.path?.endsWith('/bandwidth'))).toBe(false); + expect(() => expandSpecSugar(spec)).toThrow('/layer/0/layer/0/mark/binCount'); + expect(() => compileLayer(spec as never, { width: 600, height: 400 })).toThrow( + '/layer/0/layer/0/mark/binCount', + ); + }); + + it('rejects an unsupported option at the public chart boundary', () => { + expect(() => + compileChart( + { + mark: { type: 'bar', bandwidth: 2 }, + data: [{ x: 1 }], + encoding: { x: { field: 'x', type: 'quantitative' } }, + }, + { width: 600, height: 400 }, + ), + ).toThrow('/mark/bandwidth'); + }); + + it.each(['histogram', 'density'])('accepts valid authored %s sugar', (type) => { + const mark = + type === 'histogram' + ? { type, binCount: 5, normalize: true } + : { type, bandwidth: 2, cumulative: true, steps: 25 }; + const layout = compileChart( + { + mark, + data: [{ x: 1 }, { x: 2 }, { x: 4 }], + encoding: { x: { field: 'x', type: 'quantitative' } }, + }, + { width: 600, height: 400 }, + ); + expect(layout.marks.length).toBeGreaterThan(0); + }); }); -it('accepts waffle corner radius, which the renderer consumes', () => { - expect(collectMarkOptionWarnings({ mark: { type: 'waffle', cornerRadius: 3 } })).toEqual([]); +// Validate the mark schemas actually shipped within ChartSpec. Retain definitions +// so gradient and trendline refs resolve through the same artifact as consumers. +interface Schema { + [key: string]: unknown; + anyOf?: Schema[]; + oneOf?: Schema[]; + allOf?: Schema[]; + definitions?: Record; + properties?: Record; + required?: string[]; + items?: Schema; +} +const chartSchema: Schema = JSON.parse( + readFileSync(new URL('../../../../core/schema/chart.schema.json', import.meta.url), 'utf8'), +); +const authoredMark = { + anyOf: chartSchema.definitions!.ChartSpec.anyOf!.map( + (branch) => branch.allOf![1].properties!.mark, + ), +}; +const ajv = new Ajv({ strict: false, allowUnionTypes: true }); +const validateMark = ajv.compile({ ...authoredMark, definitions: chartSchema.definitions }); + +/** The documented mechanical transform, applied recursively inside every anyOf. + * No branch merging: that would erase the mark-specific contract. */ +function strictTransform(schema: Schema): Schema { + const result = structuredClone(schema); + for (const key of ['anyOf', 'oneOf', 'allOf'] as const) { + if (Array.isArray(result[key])) result[key] = result[key].map(strictTransform); + } + if (result.definitions) + result.definitions = Object.fromEntries( + Object.entries(result.definitions).map(([key, value]) => [ + key, + strictTransform(value as Schema), + ]), + ); + if (result.properties) { + const required = new Set(result.required ?? []); + result.properties = Object.fromEntries( + Object.entries(result.properties).map(([key, value]) => { + const property = strictTransform(value as Schema); + return [key, required.has(key) ? property : { anyOf: [property, { type: 'null' }] }]; + }), + ); + result.required = Object.keys(result.properties); + result.additionalProperties = false; + } + if (result.items && typeof result.items === 'object') + result.items = strictTransform(result.items); + return result; +} +const strictMark = strictTransform({ ...authoredMark, definitions: chartSchema.definitions }); +const validateStrictMark = ajv.compile(strictMark); + +function withNullOptionals(type: string, option: string, value: unknown): Record { + const branch = authoredMark.anyOf.find((entry) => entry.anyOf![0].const === type)!.anyOf![1]; + const mark: Record = Object.fromEntries( + Object.keys(branch.properties!).map((key) => [key, null]), + ); + return { ...mark, type, [option]: value }; +} + +describe('published mark contract parity', () => { + it.each( + MARK_CONTRACT_TYPES, + )('agrees across schema and runtime for every option on %s', (type) => { + for (const [option, { value, marks }] of Object.entries(MARK_OPTION_CASES)) { + const expected = (marks as readonly string[]).includes(type); + const mark = { type, [option]: value }; + expect(validateMark(mark), `${type}.${option}: published schema`).toBe(expected); + expect( + validateStrictMark(withNullOptionals(type, option, value)), + `${type}.${option}: strict transform`, + ).toBe(expected); + expect(collectMarkOptionErrors({ mark }).length === 0, `${type}.${option}: runtime`).toBe( + expected, + ); + } + }); }); diff --git a/packages/engine/src/compiler/mark-options.ts b/packages/engine/src/compiler/mark-options.ts index b4999f5c..25405789 100644 --- a/packages/engine/src/compiler/mark-options.ts +++ b/packages/engine/src/compiler/mark-options.ts @@ -1,7 +1,9 @@ -import type { MarkDef, MarkType } from '@opendata-ai/openchart-core'; +import type { MarkType } from '@opendata-ai/openchart-core'; -/** Authored options with a mark-specific meaning; shared visual properties stay wide in 8.x. */ -const APPLIES_TO: Partial> = { +import type { NormalizedMarkDef, ValidationError } from './types'; + +/** Authored options with a mark-specific meaning; shared visual properties remain common. */ +const APPLIES_TO: Partial> = { point: ['line', 'area', 'density'], interpolate: ['line', 'area', 'density'], orient: ['bar', 'histogram'], @@ -35,23 +37,36 @@ const APPLIES_TO: Partial> = { }; /** Run before sugar: diagnostics point to authored fields, including stripped histogram/density options. */ -export function collectMarkOptionWarnings(input: unknown, path = ''): string[] { +export function collectMarkOptionErrors(input: unknown, path = ''): ValidationError[] { if (!input || typeof input !== 'object' || Array.isArray(input)) return []; const spec = input as Record; - const warnings: string[] = []; + const errors: ValidationError[] = []; if (spec.mark && typeof spec.mark === 'object' && !Array.isArray(spec.mark)) { - const mark = spec.mark as MarkDef; + const mark = spec.mark as NormalizedMarkDef; for (const [key, allowed] of Object.entries(APPLIES_TO)) { - if (mark[key as keyof MarkDef] !== undefined && !allowed.includes(mark.type)) { - warnings.push( - `[openchart] ${path}/mark/${key} has no effect on mark "${mark.type}". Remove ${key}, or use a supported mark: ${allowed.join(', ')}.`, - ); + if (mark[key as keyof NormalizedMarkDef] !== undefined && !allowed.includes(mark.type)) { + errors.push({ + code: 'INVALID_VALUE', + path: `${path}/mark/${key}`, + message: `Option "${key}" is not supported by mark "${mark.type}".`, + suggestion: `Remove ${key}, or use a supported mark: ${allowed.join(', ')}.`, + }); } } } if (Array.isArray(spec.layer)) spec.layer.forEach((child, i) => { - warnings.push(...collectMarkOptionWarnings(child, `${path}/layer/${i}`)); + errors.push(...collectMarkOptionErrors(child, `${path}/layer/${i}`)); }); - return warnings; + return errors; +} + +/** Reject authored options before sugar can erase or reinterpret them. */ +export function assertMarkOptions(input: unknown): void { + const errors = collectMarkOptionErrors(input); + if (errors.length) { + throw new Error( + `Invalid spec:\n${errors.map((e) => `${e.path}: ${e.message} ${e.suggestion}`).join('\n')}`, + ); + } } diff --git a/packages/engine/src/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index b5d0c67e..7c92df4c 100644 --- a/packages/engine/src/compiler/normalize.ts +++ b/packages/engine/src/compiler/normalize.ts @@ -1,3 +1,4 @@ +import type { NormalizedMarkDef } from './types'; /** * Spec normalization: fill in defaults and infer types. * @@ -22,7 +23,6 @@ import type { GraphSpec, LabelSpec, LayerSpec, - MarkDef, SankeySpec, TableDensity, TableSpec, @@ -351,7 +351,7 @@ const BEESWARM_POINT_BUDGET = 2000; * computation. Only the object form of majorityLine carries a seat count. */ function warnParliamentMajorityRange( - markDef: MarkDef, + markDef: NormalizedMarkDef, encoding: Encoding, data: DataRow[], warnings: string[], @@ -418,7 +418,7 @@ function resolveOtherOption( function applyWaffleOther( spec: ChartSpec, encoding: Encoding, - markDef: MarkDef, + markDef: NormalizedMarkDef, warnings: string[], ): DataRow[] { const option = resolveOtherOption(markDef.other); diff --git a/packages/engine/src/compiler/sparkline-defaults.ts b/packages/engine/src/compiler/sparkline-defaults.ts index 081ce413..71bd9bf9 100644 --- a/packages/engine/src/compiler/sparkline-defaults.ts +++ b/packages/engine/src/compiler/sparkline-defaults.ts @@ -1,3 +1,4 @@ +import type { NormalizedMarkDef } from './types'; /** * Sparkline default resolution. * @@ -16,7 +17,7 @@ * theme override > sparkline trend default. */ -import type { DataRow, GradientDef, MarkDef, ResolvedTheme } from '@opendata-ai/openchart-core'; +import type { DataRow, GradientDef, ResolvedTheme } from '@opendata-ai/openchart-core'; /** Trend classification for a single value series. */ export type Trend = 'up' | 'down' | 'neutral'; @@ -105,7 +106,7 @@ export function computeTrendFromData(data: readonly DataRow[], yField: string | * 3. Sparkline trend default (returned when both flags are false) */ export function hasExplicitColor( - markDef: MarkDef | undefined, + markDef: NormalizedMarkDef | undefined, encodingHasColor: boolean, ): { fill: boolean; stroke: boolean } { if (encodingHasColor) return { fill: true, stroke: true }; diff --git a/packages/engine/src/compiler/types.ts b/packages/engine/src/compiler/types.ts index 8fd1b539..83c0eb0d 100644 --- a/packages/engine/src/compiler/types.ts +++ b/packages/engine/src/compiler/types.ts @@ -101,12 +101,24 @@ export interface UserExplicit { crosshair: boolean; } +/** Internal renderer configuration: sugar can change the mark type while + * retaining visual options. Keep this wide shape outside the authored contract. */ +type UnionKeys = T extends unknown ? keyof T : never; +type UnionValue = T extends unknown + ? K extends keyof T + ? T[K] + : never + : never; +export type NormalizedMarkDef = { type: MarkType } & { + [K in Exclude, 'type'>]?: UnionValue; +}; + /** A ChartSpec with all optional fields filled with sensible defaults. */ export interface NormalizedChartSpec { /** Resolved mark type string (extracted from spec.mark). */ markType: MarkType; /** Resolved mark definition with defaults filled in. */ - markDef: MarkDef; + markDef: NormalizedMarkDef; data: DataRow[]; encoding: Encoding; chrome: NormalizedChrome; diff --git a/packages/engine/src/compiler/validate.ts b/packages/engine/src/compiler/validate.ts index 423a9e7c..eefe9d38 100644 --- a/packages/engine/src/compiler/validate.ts +++ b/packages/engine/src/compiler/validate.ts @@ -1,4 +1,4 @@ -import { collectMarkOptionWarnings } from './mark-options'; +import { collectMarkOptionErrors } from './mark-options'; /** * Runtime spec validation. * @@ -2609,9 +2609,11 @@ function validateSpecShape(spec: unknown): ValidationResult { }; } -/** Validation warnings remain advisory throughout 8.x. */ +/** Validate authored mark contracts before any sugar transformation. */ export function validateSpec(spec: unknown): ValidationResult { const result = validateSpecShape(spec); - const warnings = collectMarkOptionWarnings(spec); - return warnings.length ? { ...result, warnings } : result; + const errors = collectMarkOptionErrors(spec); + return errors.length + ? { ...result, valid: false, errors: [...result.errors, ...errors], normalized: null } + : result; } diff --git a/scripts/llm-eval/fixtures.json b/scripts/llm-eval/fixtures.json index 89b5ee40..95222f3f 100644 --- a/scripts/llm-eval/fixtures.json +++ b/scripts/llm-eval/fixtures.json @@ -1,5 +1,5 @@ { - "$comment": "Eval fixtures for plan 20. Each task pairs a natural-language charting request with inline data. The runner asks a reference model to produce an openchart ChartSpec (via the published chart.schema.json as a tool input_schema), then measures (a) schema-valid rate and (b) compile-success rate. `expect.marks` lists mark types that are a reasonable answer for render-correctness scoring; `expect.field` names a data column the spec should reference. Seeded from the VL-guessability idioms in packages/engine/src/compiler/__tests__/fixtures/vl-parity plus a spread across all 16 mark types.", + "$comment": "Eval fixtures for plan 20. Each task pairs a natural-language charting request with inline data. The runner asks a reference model to produce an openchart ChartSpec (via the published chart.schema.json as a tool input_schema), then measures (a) schema-valid rate and (b) compile-success rate. `expect.marks` lists mark types that are a reasonable answer for render-correctness scoring; `expect.field` names a data column the spec should reference. Seeded from the VL-guessability idioms in packages/engine/src/compiler/__tests__/fixtures/vl-parity plus a spread across all 18 mark types. `expect.markOptions` pins specialized mark options.", "tasks": [ { "id": "bar-popularity", @@ -300,6 +300,24 @@ "prompt": "Bar chart of pageviews per article, top articles only.", "data": [{ "article": "Guide", "views": 5000 }, { "article": "News", "views": 3200 }, { "article": "FAQ", "views": 1800 }], "expect": { "marks": ["bar", "barlist"], "field": "views" } + }, + { + "id": "histogram-normalized-contract", + "prompt": "Use a histogram mark with 8 bins and normalize each group's own total. Do not use density bandwidth on a histogram.", + "data": [{ "amount": 5 }, { "amount": 12 }, { "amount": 45 }, { "amount": 8 }, { "amount": 33 }], + "expect": { "marks": ["histogram"], "field": "amount", "markOptions": { "binCount": 8, "normalize": true } } + }, + { + "id": "density-cumulative-contract", + "prompt": "Use a density mark with Gaussian bandwidth 2, cumulative true, and 50 evaluation steps for the response times. Density marks have no binCount option.", + "data": [{ "ms": 10 }, { "ms": 15 }, { "ms": 20 }, { "ms": 30 }], + "expect": { "marks": ["density"], "field": "ms", "markOptions": { "bandwidth": 2, "cumulative": true, "steps": 50 } } + }, + { + "id": "text-offset-contract", + "prompt": "Use text marks at each product's price and rating, with label text from name, horizontal offset dx 8, vertical offset dy -10, and left alignment. Text offsets belong to text marks, not points.", + "data": [{ "price": 10, "rating": 4.2, "name": "A" }, { "price": 25, "rating": 3.8, "name": "B" }], + "expect": { "marks": ["text"], "field": "rating", "markOptions": { "dx": 8, "dy": -10, "align": "left" } } } ] } diff --git a/scripts/llm-eval/run.mjs b/scripts/llm-eval/run.mjs index 81bc8d6d..5db26a99 100644 --- a/scripts/llm-eval/run.mjs +++ b/scripts/llm-eval/run.mjs @@ -97,6 +97,12 @@ function matchesExpectation(spec, expect) { const mark = markOf(spec); if (!mark || !expect.marks.includes(mark)) return false; } + if (expect.markOptions) { + if (!spec.mark || typeof spec.mark !== 'object') return false; + for (const [key, value] of Object.entries(expect.markOptions)) { + if (JSON.stringify(spec.mark[key]) !== JSON.stringify(value)) return false; + } + } if (expect.field) { if (!encodedFields(spec).has(expect.field)) return false; } From cf651c206df7d28a8add02071226e3b13e6b2229 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 15:31:40 -0500 Subject: [PATCH 02/17] feat!: consolidate v9 theme layering and authored CSS tokens --- docs/theming-tokens.md | 39 +++++-- .../src/gallery/features-theming.stories.tsx | 6 +- examples/src/gallery/playground.stories.tsx | 4 +- packages/core/src/index.ts | 1 - .../__tests__/token-theme-parity.test.ts | 10 +- packages/core/src/styles/dark.css | 20 +++- packages/core/src/styles/token-definitions.ts | 10 +- packages/core/src/styles/tokens.css | 13 +-- .../src/theme/__tests__/config-parity.test.ts | 11 +- .../src/theme/__tests__/dark-mode.test.ts | 4 +- .../core/src/theme/__tests__/defaults.test.ts | 4 +- .../core/src/theme/__tests__/resolve.test.ts | 2 +- .../src/theme/__tests__/v9-contracts.test.ts | 81 +++++++++++++ packages/core/src/theme/dark-mode.ts | 44 ++++--- packages/core/src/theme/defaults.ts | 38 +++--- packages/core/src/theme/index.ts | 2 +- packages/core/src/theme/merge.ts | 25 +++- packages/core/src/theme/presets.ts | 13 +-- packages/core/src/theme/resolve.ts | 108 ++++++++---------- packages/core/src/types/index.ts | 1 - packages/core/src/types/spec.ts | 31 +++-- packages/core/src/types/theme.ts | 5 +- .../src/__tests__/compile-chart.test.ts | 4 +- .../src/__tests__/theme-precedence.test.ts | 46 ++++++++ .../engine/src/barlist/compile-barlist.ts | 3 +- packages/engine/src/compile.ts | 17 +-- packages/engine/src/compile/layer.ts | 9 +- packages/engine/src/compile/spec-sugar.ts | 11 -- packages/engine/src/geo/compile-geo-map.ts | 3 +- packages/engine/src/graphs/compile-graph.ts | 3 +- packages/engine/src/sankey/compile-sankey.ts | 3 +- .../engine/src/tilemap/compile-tilemap.ts | 3 +- packages/react/src/BarList.tsx | 4 +- packages/react/src/Chart.tsx | 4 +- packages/react/src/DataTable.tsx | 5 +- packages/react/src/GeoMap.tsx | 4 +- packages/react/src/Graph.tsx | 4 +- packages/react/src/Sankey.tsx | 4 +- packages/react/src/ThemeContext.tsx | 4 +- packages/react/src/TileMap.tsx | 4 +- packages/react/src/__tests__/Chart.test.tsx | 17 +++ packages/svelte/src/BarList.svelte | 3 +- packages/svelte/src/Chart.svelte | 3 +- packages/svelte/src/DataTable.svelte | 3 +- packages/svelte/src/GeoMap.svelte | 3 +- packages/svelte/src/Graph.svelte | 3 +- packages/svelte/src/Sankey.svelte | 3 +- packages/svelte/src/TileMap.svelte | 3 +- .../svelte/src/__tests__/ThemeContext.test.ts | 19 +++ .../src/__tests__/table-design.test.ts | 13 ++- .../src/__tests__/theme-style-block.test.ts | 19 ++- .../src/__tests__/theme-tokens.test.ts | 36 ++++++ packages/vanilla/src/barlist-renderer.ts | 2 + packages/vanilla/src/export.ts | 4 +- packages/vanilla/src/graph/shell.ts | 16 +-- packages/vanilla/src/map-renderer.ts | 3 +- packages/vanilla/src/sankey-renderer.ts | 2 + packages/vanilla/src/table-renderer.ts | 37 +----- packages/vanilla/src/theme-style-block.ts | 12 +- packages/vanilla/src/theme-tokens.ts | 107 +++++++++++++++-- packages/vanilla/src/tilemap-renderer.ts | 2 + packages/vue/src/BarList.ts | 3 +- packages/vue/src/Chart.ts | 3 +- packages/vue/src/DataTable.ts | 3 +- packages/vue/src/GeoMap.ts | 3 +- packages/vue/src/Graph.ts | 3 +- packages/vue/src/Sankey.ts | 3 +- packages/vue/src/ThemeProvider.ts | 4 +- packages/vue/src/TileMap.ts | 3 +- packages/vue/src/__tests__/Chart.test.ts | 18 +++ scripts/generate-css-tokens.mjs | 19 +-- 71 files changed, 674 insertions(+), 305 deletions(-) create mode 100644 packages/core/src/theme/__tests__/v9-contracts.test.ts create mode 100644 packages/engine/src/__tests__/theme-precedence.test.ts create mode 100644 packages/vanilla/src/__tests__/theme-tokens.test.ts diff --git a/docs/theming-tokens.md b/docs/theming-tokens.md index 25538cdc..3b189efc 100644 --- a/docs/theming-tokens.md +++ b/docs/theming-tokens.md @@ -93,11 +93,10 @@ The neutral grays are surface-aware: on light backgrounds the second series gets ### Named presets -Five presets exported from `@opendata-ai/openchart-core`: +Four primary presets exported from `@opendata-ai/openchart-core`: | Preset | Feel | |--------|------| -| `editorial` | Current default (empty config, zero drift) | | `essay` | Serif titles, warm background, generous spacing | | `wire` | Monospace, dense, tight chrome, dashboard feel | | `broadsheet` | Newspaper editorial: warm paper, red masthead `rule` and eyebrow, 24/700 title, square corners | @@ -169,9 +168,9 @@ never disagree. | `--oc-map-hover-dim` | 0.75 | map-local override; dimming 3000 county paths at 0.3 reads as a blackout, so maps use a lighter dim and only on legend-bin hover, never on feature hover | | `--oc-positive-tint` / `--oc-negative-tint` | 10% color-mix toward `--oc-bg` | delta chip backgrounds | -`--oc-border-radius` (8px) drives containers and tooltips. In v8, mounts stamp -resolved theme tokens inline, so ordinary stylesheet overrides of these tokens -do not win. Set `theme.borderRadius` to change this value. Mark geometry uses the fixed `--oc-radius-sm` +`--oc-border-radius` (8px) drives containers and tooltips. In v9, CSS overrides +work when `theme.borderRadius` is omitted. An authored `theme.borderRadius` +takes precedence. Mark geometry uses the fixed `--oc-radius-sm` regardless, so a theme with `borderRadius: 0` still draws a 2px bar-end radius. @@ -200,16 +199,30 @@ as `chrome.*.color` and needs no dark-mode branch. `computeChrome` reserves ### CSS custom properties -`--oc-*` custom properties are now stamped on each `.oc-root` container from the resolved JS theme at mount time. This means: +Defaults remain in `tokens.css` and `dark.css`. The shared renderer helper stamps +only authored theme leaves and values derived from them. A host stylesheet can +override omitted tokens, including `--oc-border-radius`; an authored theme value +wins via its inline style. Removing a theme override restores the host value. +SVG and raster exports retain the live CSS token values before leaving the page. -- **Two charts with different themes on the same page** each get correct `--oc-*` values (per-container scoping). -- **`tokens.css` and `dark.css` are fallback defaults**, not the source of truth. They still work for SSR, static HTML, and pre-mount styling. -- **If you were reading `--oc-*` values from CSS** to style surrounding UI, those values now reflect the actual resolved theme rather than the hardcoded defaults. This is almost certainly what you wanted, but worth noting. +Theme layers deep-merge in this order: spec theme, provider theme, component prop +(or mount/compile option). For layered charts, a leaf theme overrides its parent. +Objects merge by leaf; arrays and `{ light, dark }` pairs replace atomically. +`colors: string[]` remains shorthand for `colors.categorical`. `null` replaces: +use `rule: null` to remove a preset rule. Partial rules inherit unspecified +preset values; without a preset, width is 40px, thickness is 3px, and color is +theme text ink. Authored `chrome..fontSize` overrides the general +`fonts.sizes` scale. Chrome color shorthand is now `{ color: '...' }`. -No action required. If you import `tokens.css`/`dark.css`, keep doing so. The JS-stamped properties take precedence via inline style specificity. +The default small/axis tick/source/byline/footer size is 12px; chrome-to-chart +spacing is 16px and chart-to-footer spacing is 12px. The background remains +transparent. The empty `editorial` preset was removed (omit `theme` instead). +`clarity` is deprecated and retains only its background, axis, and eyebrow +colors. Use `ThemeConfig` for input and `ResolvedTheme` for output; the `Theme` +alias was removed. -### Annotation tokens (8.9.1) +### Annotation tokens `theme.colors` accepts `annotationLabel`, `annotationMuted`, `annotationLeader`, `annotationRule`, `annotationRangeFill`, `annotationMarkerFill`, and @@ -218,4 +231,6 @@ are `--oc-annotation-label`, `--oc-annotation-muted`, `--oc-annotation-leader`, `--oc-annotation-rule`, `--oc-annotation-range-fill`, `--oc-annotation-marker-fill`, and `--oc-annotation-plate`. Explicit annotation colors override the tokens. Arrow connectors match label ink. Existing `annotationText`/`annotationFill` -uses are unchanged. Omitted annotation tokens preserve current 8.x output. +uses are unchanged. Default rule ink is #888888 in light mode and #9ca3af in +dark mode; range fill is #f0c040. Marker and plate fills follow the resolved +neutral surface unless explicitly authored. diff --git a/examples/src/gallery/features-theming.stories.tsx b/examples/src/gallery/features-theming.stories.tsx index 769d1933..3e535459 100644 --- a/examples/src/gallery/features-theming.stories.tsx +++ b/examples/src/gallery/features-theming.stories.tsx @@ -15,7 +15,7 @@ */ import type { ChartSpec, ThemeConfig } from '@opendata-ai/openchart-core'; -import { broadsheet, editorial, essay, terminal, wire } from '@opendata-ai/openchart-core'; +import { broadsheet, essay, terminal, wire } from '@opendata-ai/openchart-core'; import { Chart, VizThemeProvider } from '@opendata-ai/openchart-react'; import type { CSSProperties } from 'react'; import { useState } from 'react'; @@ -151,7 +151,7 @@ const compactBarSpec: ChartSpec = { const PRESETS: { name: string; theme: ThemeConfig; note: string }[] = [ { name: 'editorial', - theme: editorial, + theme: {}, note: 'The default look. Clean sans, restrained neutrals.', }, { name: 'essay', theme: essay, note: 'Serif display titles, warm canvas, generous spacing.' }, @@ -514,7 +514,7 @@ export const Theming = () => (
diff --git a/examples/src/gallery/playground.stories.tsx b/examples/src/gallery/playground.stories.tsx index b68f778b..37b80b35 100644 --- a/examples/src/gallery/playground.stories.tsx +++ b/examples/src/gallery/playground.stories.tsx @@ -16,7 +16,7 @@ import type { Story } from '@ladle/react'; import type { ChartSpec, ThemeConfig } from '@opendata-ai/openchart-core'; -import { editorial, essay, wire } from '@opendata-ai/openchart-core'; +import { essay, wire } from '@opendata-ai/openchart-core'; import { Demo, GalleryPage } from '../components'; import { bigTechRevenue } from '../data'; @@ -54,7 +54,7 @@ const DONUT_DATA = SERIES.filter((d) => d.year === '2024-01-01').map((d) => ({ revenue: d.revenue, })); -const THEMES: Record = { editorial, essay, wire }; +const THEMES: Record = { editorial: {}, essay, wire }; /** Presets that support stacking (parts-of-a-whole marks). */ const STACKABLE: ReadonlySet = new Set(['bar', 'column', 'area']); diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 66032c68..7a491a68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -61,7 +61,6 @@ export { broadsheet, clarity, DEFAULT_THEME, - editorial, essay, legacyEditorial, mergeThemeConfig, diff --git a/packages/core/src/styles/__tests__/token-theme-parity.test.ts b/packages/core/src/styles/__tests__/token-theme-parity.test.ts index eb3b06ae..3fd53adf 100644 --- a/packages/core/src/styles/__tests__/token-theme-parity.test.ts +++ b/packages/core/src/styles/__tests__/token-theme-parity.test.ts @@ -8,11 +8,12 @@ * counterpart must equal it in both modes. */ +import { readFileSync } from 'node:fs'; import { describe, expect, it } from 'vitest'; import { adaptTheme } from '../../theme/dark-mode'; import { DEFAULT_THEME } from '../../theme/defaults'; import { resolveTheme } from '../../theme/resolve'; -import { cssTokenDefault } from '../token-definitions'; +import { CSS_TOKENS, cssTokenDefault } from '../token-definitions'; /** `rgba(0, 0, 0, 0.08)` and `rgba(0,0,0,0.08)` are the same color. */ const norm = (v: string) => v.replace(/\s+/g, '').toLowerCase(); @@ -118,3 +119,10 @@ it('annotation tokens preserve both legacy modes and authored pairs', () => { expect(adaptTheme(custom).colors[name]).toBe('#abcdef'); } }); + +it('emits every registered dark token in the generated stylesheet', () => { + const css = readFileSync(new URL('../dark.css', import.meta.url), 'utf8'); + for (const token of CSS_TOKENS) { + if (token.dark !== undefined) expect(css).toContain(`${token.name}: ${token.dark};`); + } +}); diff --git a/packages/core/src/styles/dark.css b/packages/core/src/styles/dark.css index 7a60a0f5..797b1143 100644 --- a/packages/core/src/styles/dark.css +++ b/packages/core/src/styles/dark.css @@ -4,9 +4,8 @@ /* --------------------------------------------------------------------------- * Dark mode overrides (fallback defaults) * - * At runtime, the JS theme engine stamps dark-adapted --oc-* custom - * properties on each .oc-root container. These CSS values serve as - * fallbacks for contexts where JS hasn't mounted yet. + * Dark defaults stay in CSS; authored theme overrides are resolved and + * stamped by the JS theme engine. * --------------------------------------------------------------------------- */ .oc-dark { @@ -40,5 +39,20 @@ --oc-tooltip-shadow: 0 2px 8px rgba(0, 0, 0, 0.3), 0 0 1px rgba(0, 0, 0, 0.4); --oc-tooltip-text: #f7f8f8; --oc-legend-text: #d0d6e0; + + /* Annotation and neutral colors */ + --oc-annotation-label: #d1d5db; + --oc-annotation-muted: #9ca3af; + --oc-annotation-leader: #9aa0a6; + --oc-annotation-rule: #9ca3af; + --oc-annotation-range-fill: #f0c040; + --oc-annotation-marker-fill: #09090b; + --oc-annotation-plate: #09090b; + --oc-gray-100: #171719; + --oc-gray-200: #262627; + --oc-gray-300: #3d3e3f; + --oc-gray-400: #68696a; + --oc-gray-600: #8c8c8d; + --oc-gray-800: #bcbcbd; } } diff --git a/packages/core/src/styles/token-definitions.ts b/packages/core/src/styles/token-definitions.ts index 28502b16..612f0bfa 100644 --- a/packages/core/src/styles/token-definitions.ts +++ b/packages/core/src/styles/token-definitions.ts @@ -70,16 +70,16 @@ export const CSS_TOKENS: readonly CssTokenDef[] = [ { name: '--oc-title-tracking', light: '-0.022em' }, { name: '--oc-subtitle-size', light: '14px' }, { name: '--oc-subtitle-weight', light: '400' }, - { name: '--oc-source-size', light: '11px' }, + { name: '--oc-source-size', light: '12px' }, { name: '--oc-source-weight', light: '400' }, { name: '--oc-body-size', light: '13px' }, { name: '--oc-annotation-label', light: '#333333', dark: '#d1d5db' }, { name: '--oc-annotation-muted', light: '#6b7280', dark: '#9ca3af' }, { name: '--oc-annotation-leader', light: '#8f8f8f', dark: '#9aa0a6' }, - { name: '--oc-annotation-rule', light: '#666666', dark: '#666666' }, - { name: '--oc-annotation-range-fill', light: '#000000', dark: '#000000' }, - { name: '--oc-annotation-marker-fill', light: '#ffffff', dark: '#0a0a0a' }, - { name: '--oc-annotation-plate', light: '#ffffff', dark: '#0a0a0a' }, + { name: '--oc-annotation-rule', light: '#888888', dark: '#9ca3af' }, + { name: '--oc-annotation-range-fill', light: '#f0c040', dark: '#f0c040' }, + { name: '--oc-annotation-marker-fill', light: '#ffffff', dark: '#09090b' }, + { name: '--oc-annotation-plate', light: '#ffffff', dark: '#09090b' }, { name: '--oc-eyebrow-size', light: '11px' }, { name: '--oc-eyebrow-weight', light: '500' }, { name: '--oc-eyebrow-tracking', light: '0.08em' }, diff --git a/packages/core/src/styles/tokens.css b/packages/core/src/styles/tokens.css index bba32d60..61b37bce 100644 --- a/packages/core/src/styles/tokens.css +++ b/packages/core/src/styles/tokens.css @@ -4,11 +4,8 @@ /* --------------------------------------------------------------------------- * Custom properties (light mode defaults) * - * These are FALLBACK defaults. At runtime, the JS theme engine stamps the - * resolved theme as inline --oc-* custom properties on each .oc-root - * container (see packages/vanilla/src/theme-tokens.ts). These CSS values - * serve as fallbacks for contexts where JS hasn't mounted yet (SSR, static - * HTML examples). + * Defaults remain in CSS so host styles can override them. The JS theme + * engine stamps only authored theme overrides (theme-tokens.ts). * --------------------------------------------------------------------------- */ .oc-root, @@ -95,14 +92,14 @@ --oc-title-tracking: -0.022em; --oc-subtitle-size: 14px; --oc-subtitle-weight: 400; - --oc-source-size: 11px; + --oc-source-size: 12px; --oc-source-weight: 400; --oc-body-size: 13px; --oc-annotation-label: #333333; --oc-annotation-muted: #6b7280; --oc-annotation-leader: #8f8f8f; - --oc-annotation-rule: #666666; - --oc-annotation-range-fill: #000000; + --oc-annotation-rule: #888888; + --oc-annotation-range-fill: #f0c040; --oc-annotation-marker-fill: #ffffff; --oc-annotation-plate: #ffffff; --oc-eyebrow-size: 11px; diff --git a/packages/core/src/theme/__tests__/config-parity.test.ts b/packages/core/src/theme/__tests__/config-parity.test.ts index c1aadb3a..49fb3747 100644 --- a/packages/core/src/theme/__tests__/config-parity.test.ts +++ b/packages/core/src/theme/__tests__/config-parity.test.ts @@ -5,13 +5,18 @@ import { mergeThemeConfig } from '../merge'; describe('authored theme contract', () => { it('covers every resolved theme key except runtime metadata', () => { - type Missing = Exclude; + type Missing = Exclude< + keyof ResolvedTheme, + keyof ThemeConfig | 'isDark' | '_tokenPairs' | '_authored' + >; expectTypeOf().toEqualTypeOf(); }); - it('keeps v8 shallow precedence and does not mutate either layer', () => { + it('keeps nested precedence and does not mutate either layer', () => { const base = { fonts: { family: 'Base', sizes: { title: 30 } } }; const override = { fonts: { family: 'Override' } }; - expect(mergeThemeConfig(base, override)).toEqual(override); + expect(mergeThemeConfig(base, override)).toEqual({ + fonts: { family: 'Override', sizes: { title: 30 } }, + }); expect(base.fonts.sizes.title).toBe(30); expect(mergeThemeConfig(base, undefined)).toBe(base); }); diff --git a/packages/core/src/theme/__tests__/dark-mode.test.ts b/packages/core/src/theme/__tests__/dark-mode.test.ts index f7d85049..676c36c9 100644 --- a/packages/core/src/theme/__tests__/dark-mode.test.ts +++ b/packages/core/src/theme/__tests__/dark-mode.test.ts @@ -142,7 +142,7 @@ describe('adaptTheme', () => { it('preserves explicit chrome color overrides in dark mode', () => { const light = resolveTheme({ colors: { background: '#ffffff' }, - chrome: { subtitle: '#d0d6e0', source: '#d0d6e0' }, + chrome: { subtitle: { color: '#d0d6e0' }, source: { color: '#d0d6e0' } }, }); const dark = adaptTheme(light); expect(dark.chrome.subtitle.color).toBe('#d0d6e0'); @@ -222,7 +222,7 @@ describe('adaptTheme', () => { expect(dark.chrome.eyebrow.color).toBe('#ff6b64'); // Without a pair, the eyebrow keeps its accent tint across modes. - const noPair = adaptTheme(resolveTheme({ chrome: { eyebrow: '#e3120b' } })); + const noPair = adaptTheme(resolveTheme({ chrome: { eyebrow: { color: '#e3120b' } } })); expect(noPair.chrome.eyebrow.color).toBe('#e3120b'); }); diff --git a/packages/core/src/theme/__tests__/defaults.test.ts b/packages/core/src/theme/__tests__/defaults.test.ts index cb731ab2..ac14323f 100644 --- a/packages/core/src/theme/__tests__/defaults.test.ts +++ b/packages/core/src/theme/__tests__/defaults.test.ts @@ -27,8 +27,8 @@ describe('DEFAULT_THEME', () => { expect(DEFAULT_THEME.chrome.subtitle.fontWeight).toBe(400); }); - it('source is 11px normal weight', () => { - expect(DEFAULT_THEME.chrome.source.fontSize).toBe(11); + it('source is 12px normal weight', () => { + expect(DEFAULT_THEME.chrome.source.fontSize).toBe(12); expect(DEFAULT_THEME.chrome.source.fontWeight).toBe(400); }); diff --git a/packages/core/src/theme/__tests__/resolve.test.ts b/packages/core/src/theme/__tests__/resolve.test.ts index 0e531443..d8914dbf 100644 --- a/packages/core/src/theme/__tests__/resolve.test.ts +++ b/packages/core/src/theme/__tests__/resolve.test.ts @@ -43,7 +43,7 @@ describe('resolveTheme', () => { }); it('overrides a chrome element color, preserving its size and weight', () => { - const resolved = resolveTheme({ chrome: { subtitle: '#d0d6e0' } }); + const resolved = resolveTheme({ chrome: { subtitle: { color: '#d0d6e0' } } }); expect(resolved.chrome.subtitle.color).toBe('#d0d6e0'); // Non-color chrome fields come from the typography scale, untouched. expect(resolved.chrome.subtitle.fontSize).toBe(14); diff --git a/packages/core/src/theme/__tests__/v9-contracts.test.ts b/packages/core/src/theme/__tests__/v9-contracts.test.ts new file mode 100644 index 00000000..a9dec5cb --- /dev/null +++ b/packages/core/src/theme/__tests__/v9-contracts.test.ts @@ -0,0 +1,81 @@ +import { describe, expect, it } from 'vitest'; +import { adaptTheme } from '../dark-mode'; +import { mergeThemeConfig } from '../merge'; +import { broadsheet, clarity, terminal } from '../presets'; +import { resolveTheme } from '../resolve'; + +describe('v9 authored theme contract', () => { + it('merges nested authored layers without mutating either input', () => { + const base = { + colors: { background: { light: '#fff', dark: '#000' }, categorical: ['red'] }, + fonts: { sizes: { small: 15, title: 30 } }, + }; + const override = { colors: { background: 'beige' }, fonts: { sizes: { title: 24 } } }; + expect(mergeThemeConfig(base, override)).toEqual({ + colors: { background: 'beige', categorical: ['red'] }, + fonts: { sizes: { small: 15, title: 24 } }, + }); + expect(base.fonts.sizes.title).toBe(30); + expect(override.fonts.sizes).toEqual({ title: 24 }); + }); + it('normalizes palette shorthand, replaces arrays and clears rules with null', () => { + expect( + mergeThemeConfig( + { colors: { axis: 'red', categorical: ['red', 'blue'] } }, + { colors: ['green'] }, + )?.colors, + ).toEqual({ axis: 'red', categorical: ['green'] }); + expect(resolveTheme(mergeThemeConfig(broadsheet, { rule: null })).rule).toBeNull(); + expect(resolveTheme(mergeThemeConfig(broadsheet, { rule: { width: 80 } })).rule).toMatchObject({ + width: 80, + thickness: 3, + color: '#e3120b', + }); + }); + it('gives authored chrome size priority over general font sizes in every layer', () => { + const theme = resolveTheme( + mergeThemeConfig( + { chrome: { title: { fontSize: 33 }, source: { fontSize: 16 } } }, + { fonts: { sizes: { title: 20, small: 10 } } }, + ), + ); + expect(theme.chrome.title.fontSize).toBe(33); + expect(theme.chrome.source.fontSize).toBe(16); + expect(theme.chrome.footer.fontSize).toBe(10); + }); + it('uses D12 typography and D19 ink without changing default background or axis', () => { + const theme = resolveTheme(); + expect(theme.fonts.sizes).toMatchObject({ small: 12, axisTick: 12 }); + expect(theme.spacing).toMatchObject({ chromeToChart: 16, chartToFooter: 12 }); + expect(theme.chrome.byline.fontSize).toBe(12); + expect(theme.colors).toMatchObject({ + background: 'transparent', + axis: '#71717a', + annotationRule: '#888888', + annotationRangeFill: '#f0c040', + }); + expect(adaptTheme(theme).colors.annotationRule).toBe('#9ca3af'); + expect(clarity.fonts).toBeUndefined(); + expect(clarity.spacing).toBeUndefined(); + }); + it('derives marker and plate colors from the actual surface and preserves explicit colors', () => { + for (const theme of [ + resolveTheme({ colors: { background: '#faf8f5' } }), + resolveTheme(terminal), + adaptTheme(resolveTheme()), + ]) { + expect(theme.colors.annotationMarkerFill).toBe(theme.colors.neutral.surface); + expect(theme.colors.annotationPlate).toBe(theme.colors.neutral.surface); + } + const explicit = adaptTheme( + resolveTheme({ + colors: { + annotationMarkerFill: '#ffffff', + annotationPlate: { light: '#ffffff', dark: '#eeeeee' }, + }, + }), + ); + expect(explicit.colors.annotationMarkerFill).toBe('#ffffff'); + expect(explicit.colors.annotationPlate).toBe('#eeeeee'); + }); +}); diff --git a/packages/core/src/theme/dark-mode.ts b/packages/core/src/theme/dark-mode.ts index f06b6f09..df7426b6 100644 --- a/packages/core/src/theme/dark-mode.ts +++ b/packages/core/src/theme/dark-mode.ts @@ -164,6 +164,20 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme { ...CATEGORICAL_FILL_PALETTE_DARK, ]); + const neutral = deriveNeutralRamp(darkText, darkBg, true); + const authoredColors = + theme._authored?.colors && !Array.isArray(theme._authored.colors) ? theme._authored.colors : {}; + const annotationColor = ( + key: + | 'annotationLabel' + | 'annotationMuted' + | 'annotationLeader' + | 'annotationRule' + | 'annotationRangeFill', + fallback: string, + ): string => + pairs?.[`colors.${key}`]?.dark ?? + (authoredColors[key] !== undefined ? theme.colors[key] : fallback); return { ...theme, isDark: true, @@ -179,27 +193,21 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme { annotationText: pairs?.['colors.annotationText'] ? pairs['colors.annotationText'].dark : overridden(theme.colors.annotationText, light.annotationText, darkMuted), - annotationLabel: - pairs?.['colors.annotationLabel']?.dark ?? - overridden(theme.colors.annotationLabel, light.annotationLabel, '#d1d5db'), - annotationMuted: - pairs?.['colors.annotationMuted']?.dark ?? - overridden(theme.colors.annotationMuted, light.annotationMuted, '#9ca3af'), - annotationLeader: - pairs?.['colors.annotationLeader']?.dark ?? - overridden(theme.colors.annotationLeader, light.annotationLeader, '#9aa0a6'), - annotationRule: - pairs?.['colors.annotationRule']?.dark ?? - overridden(theme.colors.annotationRule, light.annotationRule, '#666666'), - annotationRangeFill: - pairs?.['colors.annotationRangeFill']?.dark ?? - overridden(theme.colors.annotationRangeFill, light.annotationRangeFill, '#000000'), + annotationLabel: annotationColor('annotationLabel', '#d1d5db'), + annotationMuted: annotationColor('annotationMuted', '#9ca3af'), + annotationLeader: annotationColor('annotationLeader', '#9aa0a6'), + annotationRule: annotationColor('annotationRule', '#9ca3af'), + annotationRangeFill: annotationColor('annotationRangeFill', '#f0c040'), annotationMarkerFill: pairs?.['colors.annotationMarkerFill']?.dark ?? - overridden(theme.colors.annotationMarkerFill, light.annotationMarkerFill, '#0a0a0a'), + (authoredColors.annotationMarkerFill !== undefined + ? theme.colors.annotationMarkerFill + : neutral.surface), annotationPlate: pairs?.['colors.annotationPlate']?.dark ?? - overridden(theme.colors.annotationPlate, light.annotationPlate, '#0a0a0a'), + (authoredColors.annotationPlate !== undefined + ? theme.colors.annotationPlate + : neutral.surface), categorical, categoricalFill, hairline: darkHairline, @@ -209,7 +217,7 @@ export function adaptTheme(theme: ResolvedTheme): ResolvedTheme { negative: pairs?.['colors.negative'] ? pairs['colors.negative'].dark : overridden(theme.colors.negative, light.negative, '#f87171'), - neutral: deriveNeutralRamp(darkText, darkBg, true), + neutral, }, chrome: { // Eyebrow keeps its accent tint (cyan in both modes) unless the user diff --git a/packages/core/src/theme/defaults.ts b/packages/core/src/theme/defaults.ts index 8810ff17..b6d9dae4 100644 --- a/packages/core/src/theme/defaults.ts +++ b/packages/core/src/theme/defaults.ts @@ -18,6 +18,16 @@ import { import { X_AXIS_BAND_HEIGHT } from '../responsive/metrics'; import type { ThemeBase } from '../types/theme'; +const FONT_SIZES = { + title: 26, + subtitle: 14, + body: 13, + small: 12, + axisTick: 12, + metricLabel: 11, + metricValue: 22, +}; + /** * The default theme. All fields are required and fully specified. */ @@ -42,8 +52,8 @@ export const DEFAULT_THEME: ThemeBase = { annotationLabel: '#333333', annotationMuted: '#6b7280', annotationLeader: '#8f8f8f', - annotationRule: '#666666', - annotationRangeFill: '#000000', + annotationRule: '#888888', + annotationRangeFill: '#f0c040', annotationMarkerFill: '#ffffff', annotationPlate: '#ffffff', positive: '#15803d', @@ -53,15 +63,7 @@ export const DEFAULT_THEME: ThemeBase = { family: '"Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif', mono: '"JetBrains Mono", "Fira Code", "Cascadia Code", monospace', - sizes: { - title: 26, - subtitle: 14, - body: 13, - small: 11, - axisTick: 11, - metricLabel: 11, - metricValue: 22, - }, + sizes: FONT_SIZES, // One ladder everywhere: 400 regular / 500 medium (eyebrow, labels, UI, // values) / 600 semibold (title, series names, annotation lede) / 700 bold. weights: { @@ -74,8 +76,8 @@ export const DEFAULT_THEME: ThemeBase = { spacing: { padding: 20, chromeGap: 6, - chromeToChart: 8, - chartToFooter: 8, + chromeToChart: 16, + chartToFooter: 12, axisMargin: 6, xAxisHeight: X_AXIS_BAND_HEIGHT, // Gap (px) between the x-axis line and the TOP of the tick labels. The @@ -95,31 +97,31 @@ export const DEFAULT_THEME: ThemeBase = { lineHeight: 1.4, }, title: { - fontSize: 26, + fontSize: FONT_SIZES.title, fontWeight: 600, color: '#09090b', lineHeight: 1.15, }, subtitle: { - fontSize: 14, + fontSize: FONT_SIZES.subtitle, fontWeight: 400, color: '#71717a', lineHeight: 1.45, }, source: { - fontSize: 11, + fontSize: FONT_SIZES.small, fontWeight: 400, color: '#71717a', lineHeight: 1.4, }, byline: { - fontSize: 11, + fontSize: FONT_SIZES.small, fontWeight: 400, color: '#71717a', lineHeight: 1.4, }, footer: { - fontSize: 11, + fontSize: FONT_SIZES.small, fontWeight: 400, color: '#71717a', lineHeight: 1.4, diff --git a/packages/core/src/theme/index.ts b/packages/core/src/theme/index.ts index d9256d44..489df80a 100644 --- a/packages/core/src/theme/index.ts +++ b/packages/core/src/theme/index.ts @@ -5,5 +5,5 @@ export { adaptColorForDarkMode, adaptForLightLineStroke, adaptTheme } from './dark-mode'; export { DEFAULT_THEME } from './defaults'; export { mergeThemeConfig } from './merge'; -export { broadsheet, clarity, editorial, essay, legacyEditorial, terminal, wire } from './presets'; +export { broadsheet, clarity, essay, legacyEditorial, terminal, wire } from './presets'; export { resolveTheme } from './resolve'; diff --git a/packages/core/src/theme/merge.ts b/packages/core/src/theme/merge.ts index f6f8057c..8dba345d 100644 --- a/packages/core/src/theme/merge.ts +++ b/packages/core/src/theme/merge.ts @@ -1,9 +1,30 @@ import type { ThemeConfig } from '../types/spec'; -/** Merge authored theme layers with v8 shallow precedence (later layers win). */ +function isRecord(value: unknown): value is Record { + return value !== null && typeof value === 'object' && !Array.isArray(value); +} + +/** Merge configuration objects; color pairs, arrays and null are atomic leaves. */ +export function mergeThemeObjects(base: T, override: unknown): T { + if (!isRecord(override) || 'light' in override || 'dark' in override) return override as T; + const result: Record = isRecord(base) ? { ...base } : {}; + for (const [key, value] of Object.entries(override)) { + if (value !== undefined) result[key] = mergeThemeObjects(result[key], value); + } + return result as T; +} + +function normalize(config: ThemeConfig): ThemeConfig { + return Array.isArray(config.colors) + ? { ...config, colors: { categorical: config.colors } } + : config; +} + +/** Deep-merge authored theme layers. Later layers win at each authored leaf. */ export function mergeThemeConfig( base: ThemeConfig | undefined, override: ThemeConfig | undefined, ): ThemeConfig | undefined { - return override ? { ...base, ...override } : base; + if (!override) return base; + return mergeThemeObjects(base ? normalize(base) : {}, normalize(override)); } diff --git a/packages/core/src/theme/presets.ts b/packages/core/src/theme/presets.ts index 708c4160..626890a6 100644 --- a/packages/core/src/theme/presets.ts +++ b/packages/core/src/theme/presets.ts @@ -9,9 +9,6 @@ import { CATEGORICAL_FILL_PALETTE_DARK, CATEGORICAL_PALETTE_DARK } from '../colors/palettes'; import type { ThemeConfig } from '../types/spec'; -/** Editorial preset: the current default look. Exported for explicit selection. */ -export const editorial: ThemeConfig = {}; - /** Essay preset: serif display titles, warmer background, generous spacing. */ export const essay: ThemeConfig = { colors: { @@ -103,7 +100,7 @@ export const broadsheet: ThemeConfig = { }, chrome: { eyebrow: { color: { light: '#e3120b', dark: '#f4463f' }, fontSize: 11, fontWeight: 700 }, - title: { fontSize: 24, fontWeight: 700, lineHeight: 1.18 }, + title: { fontWeight: 700, lineHeight: 1.18 }, subtitle: { fontWeight: 400, lineHeight: 1.45 }, }, /** The masthead device: a short red bar above the eyebrow. */ @@ -149,26 +146,22 @@ export const terminal: ThemeConfig = { }, chrome: { eyebrow: { color: { light: '#22d3ee', dark: '#22d3ee' }, fontSize: 10, fontWeight: 500 }, - title: { fontSize: 15, fontWeight: 600, lineHeight: 1.3 }, + title: { fontWeight: 600, lineHeight: 1.3 }, subtitle: { fontWeight: 400, lineHeight: 1.4 }, }, borderRadius: 4, seriesStrategy: 'accent-neutral', }; -/** Next-generation editorial composition; opt in during v8. */ +/** @deprecated v9 adopts clarity typography by default. Set its remaining colors explicitly; removed in v10. */ export const clarity: ThemeConfig = { colors: { background: { light: '#ffffff', dark: '#09090b' }, axis: { light: '#52525b', dark: '#a1a1aa' }, }, - fonts: { sizes: { title: 26, subtitle: 14, body: 13, small: 12, axisTick: 12 } }, chrome: { eyebrow: { color: { light: '#0f766e', dark: '#5eead4' } }, - source: { fontSize: 12 }, - footer: { fontSize: 12 }, }, - spacing: { chromeToChart: 16, chartToFooter: 12 }, }; /** Documented v8 typography. This does not freeze the layout engine. */ diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index cb9c1e54..05950827 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -18,35 +18,7 @@ import type { ChromeDefaults, ResolvedTheme, ThemeBase, TokenValue } from '../ty import { DEFAULT_THEME } from './defaults'; -/** - * Deep merge source into target, creating a new object. - * Only merges plain objects; arrays and primitives replace directly. - */ -// biome-ignore lint/suspicious/noExplicitAny: recursive object merge requires dynamic typing -function deepMerge(target: any, source: any): any { - const result = { ...target }; - - for (const key of Object.keys(source)) { - const sourceVal = source[key]; - const targetVal = target[key]; - - if ( - sourceVal !== undefined && - sourceVal !== null && - typeof sourceVal === 'object' && - !Array.isArray(sourceVal) && - typeof targetVal === 'object' && - targetVal !== null && - !Array.isArray(targetVal) - ) { - result[key] = deepMerge(targetVal, sourceVal); - } else if (sourceVal !== undefined) { - result[key] = sourceVal; - } - } - - return result; -} +import { mergeThemeObjects } from './merge'; /** Extract the light-mode string from a TokenValue. */ function resolveTokenLight(v: TokenValue): string { @@ -57,7 +29,7 @@ function resolveTokenLight(v: TokenValue): string { * Convert a user-facing ThemeConfig (partial) into the full ThemeBase shape * that deepMerge can work with. */ -function themeConfigToPartial(config: ThemeConfig): Partial { +function themeConfigToPartial(config: ThemeConfig, base: ThemeBase): Partial { const partial: Partial = {}; if (config.colors) { @@ -137,9 +109,9 @@ function themeConfigToPartial(config: ThemeConfig): Partial { const chrome: Record> = {}; for (const [element, value] of Object.entries(config.chrome)) { if (value === undefined) continue; - if (typeof value === 'string') { - chrome[element] = { color: value }; - } else { + if (typeof value === 'string') + throw new Error('theme.chrome string colors were removed in v9; use { color }.'); + { const override: Partial = {}; const obj = value as ChromeThemeOverride; if (obj.color !== undefined) override.color = resolveTokenLight(obj.color); @@ -154,12 +126,19 @@ function themeConfigToPartial(config: ThemeConfig): Partial { if (config.rule) { partial.rule = { - color: resolveTokenLight(config.rule.color), - width: config.rule.width, - thickness: config.rule.thickness, + color: resolveTokenLight( + config.rule.color ?? + base.rule?.color ?? + (config.colors && !Array.isArray(config.colors) ? config.colors.text : undefined) ?? + DEFAULT_THEME.colors.text, + ), + width: config.rule.width ?? base.rule?.width ?? 40, + thickness: config.rule.thickness ?? base.rule?.thickness ?? 3, }; } + if (config.rule === null) partial.rule = null; + if (config.seriesStrategy) { partial.seriesStrategy = config.seriesStrategy; } @@ -322,37 +301,32 @@ export function resolveTheme( base: ThemeBase = DEFAULT_THEME, ): ResolvedTheme { let merged: ThemeBase = userTheme - ? deepMerge(base, themeConfigToPartial(userTheme)) + ? mergeThemeObjects(base, themeConfigToPartial(userTheme, base)) : { ...base }; - // Propagate fonts.sizes overrides onto the chrome element fontSizes. - // The chrome defaults hard-code fontSize independently from fonts.sizes, - // so we sync them here after merging so a single sizes override applies everywhere. - if (userTheme?.fonts?.sizes) { - const s = userTheme.fonts.sizes; - merged = { - ...merged, - chrome: { - ...merged.chrome, - ...(s.title !== undefined && { - title: { ...merged.chrome.title, fontSize: s.title }, - }), - ...(s.subtitle !== undefined && { - subtitle: { ...merged.chrome.subtitle, fontSize: s.subtitle }, - }), - ...(s.small !== undefined && { - source: { ...merged.chrome.source, fontSize: s.small }, - byline: { ...merged.chrome.byline, fontSize: s.small }, - footer: { ...merged.chrome.footer, fontSize: s.small }, - }), - }, - }; + // General font sizes supply defaults; authored chrome leaves are more specific. + for (const [element, size] of Object.entries({ + title: 'title', + subtitle: 'subtitle', + source: 'small', + byline: 'small', + footer: 'small', + }) as [keyof ThemeBase['chrome'], 'title' | 'subtitle' | 'small'][]) { + if (userTheme?.chrome?.[element]?.fontSize === undefined) { + merged = { + ...merged, + chrome: { + ...merged.chrome, + [element]: { ...merged.chrome[element], fontSize: merged.fonts.sizes[size] }, + }, + }; + } } // Auto-adapt chrome for dark backgrounds const dark = isDarkBackground(merged.colors.background); if (dark) { - merged = adaptChromeForDarkBg(merged, merged.colors.text); + merged = adaptChromeForDarkBg({ ...merged, colors: { ...merged.colors } }, merged.colors.text); // These defaults historically followed a dark canvas even without darkMode. const authoredColors = userTheme?.colors && !Array.isArray(userTheme.colors) ? userTheme.colors : {}; @@ -373,11 +347,23 @@ export function resolveTheme( } } + const neutral = deriveNeutralRamp(merged.colors.text, merged.colors.background, dark); + const authoredColors = + userTheme?.colors && !Array.isArray(userTheme.colors) ? userTheme.colors : {}; return { ...merged, + _authored: userTheme, colors: { ...merged.colors, - neutral: deriveNeutralRamp(merged.colors.text, merged.colors.background, dark), + neutral, + annotationMarkerFill: + authoredColors.annotationMarkerFill === undefined + ? neutral.surface + : merged.colors.annotationMarkerFill, + annotationPlate: + authoredColors.annotationPlate === undefined + ? neutral.surface + : merged.colors.annotationPlate, }, isDark: dark, _tokenPairs: userTheme ? collectTokenPairs(userTheme) : undefined, diff --git a/packages/core/src/types/index.ts b/packages/core/src/types/index.ts index 83becb22..6e73c6f6 100644 --- a/packages/core/src/types/index.ts +++ b/packages/core/src/types/index.ts @@ -309,7 +309,6 @@ export type { ResolvedTheme, ResolvedThemeColors, SeriesStrategy, - Theme, ThemeChromeDefaults, ThemeColors, ThemeFontSizes, diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index 77d61263..88fc07d1 100644 --- a/packages/core/src/types/spec.ts +++ b/packages/core/src/types/spec.ts @@ -1314,8 +1314,7 @@ export type Annotation = TextAnnotation | RangeAnnotation | RefLineAnnotation; export type DarkMode = 'auto' | 'force' | 'off'; /** - * Per-element chrome theme override. Pass a plain string (color only, - * backward-compatible) or an object with full typography control. + * Per-element chrome theme typography controls. */ export interface ChromeThemeOverride { /** Text color. Accepts a TokenValue for light/dark pairs. */ @@ -1399,9 +1398,9 @@ export interface ThemeConfig { subtitle?: number; /** Body text (tooltips, legend labels). Default: 13. */ body?: number; - /** Small text (source line, footer). Default: 11. */ + /** Small text (source line, footer). Default: 12. */ small?: number; - /** Axis tick labels. Default: 11. */ + /** Axis tick labels. Default: 12. */ axisTick?: number; /** KPI metric uppercase label. Default: 10. */ metricLabel?: number; @@ -1440,23 +1439,21 @@ export interface ThemeConfig { /** Border radius for chart container and tooltips. */ borderRadius?: number; /** - * Per-element chrome style overrides. Pass a plain string for color-only - * (backward-compatible), or a ChromeThemeOverride object for full - * typography control (color, fontSize, fontWeight, lineHeight). + * Per-element chrome typography control (color, fontSize, fontWeight, lineHeight). */ chrome?: { /** Eyebrow (kicker) style override. */ - eyebrow?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + eyebrow?: ChromeThemeOverride; /** Title style override. */ - title?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + title?: ChromeThemeOverride; /** Subtitle style override. */ - subtitle?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + subtitle?: ChromeThemeOverride; /** Source/attribution style override. */ - source?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + source?: ChromeThemeOverride; /** Byline style override. */ - byline?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + byline?: ChromeThemeOverride; /** Footer style override. */ - footer?: ChromeThemeOverride | /** @deprecated Use { color }. */ string; + footer?: ChromeThemeOverride; }; /** * Editorial rule above the chrome block: a short colored bar (the @@ -1464,12 +1461,12 @@ export interface ThemeConfig { */ rule?: { /** Rule color. Accepts a TokenValue for light/dark pairs. */ - color: TokenValue; + color?: TokenValue; /** Rule length in pixels. */ - width: number; + width?: number; /** Rule height in pixels. */ - thickness: number; - }; + thickness?: number; + } | null; /** * Series color assignment strategy. * Default: 'palette' (full categorical palette always, zero visual drift). diff --git a/packages/core/src/types/theme.ts b/packages/core/src/types/theme.ts index ac288fc4..296a4186 100644 --- a/packages/core/src/types/theme.ts +++ b/packages/core/src/types/theme.ts @@ -243,15 +243,14 @@ export interface ThemeBase { seriesStrategy: SeriesStrategy; } -/** @deprecated Use ThemeConfig for input or ResolvedTheme for resolved output. */ -export type Theme = ThemeBase; - /** Fully resolved runtime theme, including color adaptation and token metadata. */ export interface ResolvedTheme extends ThemeBase { /** Colors plus the neutral ramp derived from `text` and `background`. */ colors: ResolvedThemeColors; /** Whether dark mode adaptations have been applied to this theme. */ isDark: boolean; + /** Authored theme leaves, retained for CSS overrides and mode adaptation. @internal */ + _authored?: import('./spec').ThemeConfig; /** * Original light/dark token pairs from the user's ThemeConfig. Used * by adaptTheme() to skip algorithmic adaptation for tokens with diff --git a/packages/engine/src/__tests__/compile-chart.test.ts b/packages/engine/src/__tests__/compile-chart.test.ts index f718ab34..c85bc506 100644 --- a/packages/engine/src/__tests__/compile-chart.test.ts +++ b/packages/engine/src/__tests__/compile-chart.test.ts @@ -667,9 +667,9 @@ describe('compileChart', () => { expect(layout.watermark).toBe(true); }); - it('cramped height ignores the compile-option watermark fallback', () => { + it('cramped height respects the compile-option watermark override', () => { const layout = compileChart(lineSpec, { width: 600, height: 180, watermark: true }); - expect(layout.watermark).toBe(false); + expect(layout.watermark).toBe(true); }); it('explicit watermark: true at minimal height reserves the brand band', () => { diff --git a/packages/engine/src/__tests__/theme-precedence.test.ts b/packages/engine/src/__tests__/theme-precedence.test.ts new file mode 100644 index 00000000..efbfac5c --- /dev/null +++ b/packages/engine/src/__tests__/theme-precedence.test.ts @@ -0,0 +1,46 @@ +import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart, compileLayer } from '../compile'; + +const leaf: ChartSpec = { + mark: 'line', + data: [ + { x: 0, y: 1 }, + { x: 1, y: 3 }, + ], + encoding: { x: { field: 'x', type: 'ordinal' }, y: { field: 'y', type: 'quantitative' } }, + chrome: { title: 'Title' }, +}; +describe('theme layer precedence', () => { + for (const independent of [false, true]) { + it(`merges parent, leaf, and host themes with ${independent ? 'independent' : 'shared'} axes`, () => { + const spec: LayerSpec = { + layer: [ + { ...leaf, theme: { fonts: { sizes: { title: 31 } }, colors: { text: '#123456' } } }, + leaf, + ], + theme: { fonts: { sizes: { title: 20, small: 14 } }, colors: { background: '#faf8f5' } }, + ...(independent ? { resolve: { scale: { y: 'independent' as const } } } : {}), + }; + const result = compileLayer(spec, { + width: 600, + height: 400, + theme: { fonts: { sizes: { axisTick: 17 } } }, + }); + expect(result.theme.fonts.sizes).toMatchObject({ title: 31, small: 14, axisTick: 17 }); + expect(result.theme.colors).toMatchObject({ text: '#123456', background: '#faf8f5' }); + }); + } + it('honors host watermark overrides even for sparkline and breakpoint defaults', () => { + expect( + compileChart({ ...leaf, watermark: true }, { width: 600, height: 400, watermark: false }) + .watermark, + ).toBe(false); + expect( + compileChart( + { ...leaf, watermark: false, display: 'sparkline' }, + { width: 600, height: 180, watermark: true }, + ).watermark, + ).toBe(true); + }); +}); diff --git a/packages/engine/src/barlist/compile-barlist.ts b/packages/engine/src/barlist/compile-barlist.ts index c5ddd6c4..28835900 100644 --- a/packages/engine/src/barlist/compile-barlist.ts +++ b/packages/engine/src/barlist/compile-barlist.ts @@ -29,7 +29,7 @@ import { estimateTextWidth, resolveTheme, } from '@opendata-ai/openchart-core'; -import { emitSpecWarnings, emitThemeWarnings, expandSpecSugar } from '../compile/spec-sugar'; +import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar'; import { resolveAnimation } from '../compiler/animation'; import { compile as compileSpec } from '../compiler/index'; import { finiteNumber } from '../data/finite-number'; @@ -82,7 +82,6 @@ export function compileBarList(spec: unknown, options: CompileOptions): BarListL rawWatermark !== undefined ? barlistSpec.watermark : (options.watermark ?? true); // Resolve theme - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(barlistSpec.theme, options.theme); const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig); let theme: ResolvedTheme = lightTheme; diff --git a/packages/engine/src/compile.ts b/packages/engine/src/compile.ts index 503f9db9..84e61cf8 100644 --- a/packages/engine/src/compile.ts +++ b/packages/engine/src/compile.ts @@ -80,7 +80,7 @@ import { applyColorScaleRange, defaultSeriesColor } from './compile/color-scale- import { filterClippedDomains } from './compile/data-clip'; import { applyFillPatterns } from './compile/fill-patterns'; import { compileLayer as compileLayerImpl } from './compile/layer'; -import { emitSpecWarnings, emitThemeWarnings, expandSpecSugar } from './compile/spec-sugar'; +import { emitSpecWarnings, expandSpecSugar } from './compile/spec-sugar'; import { computeWatermarkObstacle } from './compile/watermark-obstacle'; import { resolveYouDrawIt } from './compile/you-draw-it'; import { collectContrastWarnings } from './compiler/a11y-warnings'; @@ -316,9 +316,9 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char let chartSpec = normalized as NormalizedChartSpec; - // Resolve watermark: explicit spec value wins, then options fallback, then default true. + // Host mount/compile options override authored spec values. const rawWatermark = (expandedSpec as Record).watermark; - let watermark = rawWatermark !== undefined ? chartSpec.watermark : (options.watermark ?? true); + let watermark = options.watermark ?? (rawWatermark !== undefined ? chartSpec.watermark : true); // Run data transforms (filter, bin, calculate, timeUnit) before any other data processing. // Transforms are defined on the expanded spec (which includes any auto-generated @@ -495,7 +495,7 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char encoding: mergedEncoding as unknown as NormalizedChartSpec['encoding'], }; } - if (typeof bp.watermark === 'boolean') { + if (options.watermark === undefined && typeof bp.watermark === 'boolean') { // Update the resolved watermark value used downstream. ChartSpec carries // this in its normalized shape; the local `watermark` variable controls // chrome computation and rendering. @@ -573,7 +573,11 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char const crosshair = chartSpec.userExplicit.crosshair ? rawCrosshair === true : crosshairDefault; // Watermark default-off in sparkline mode unless user-explicit. - if (chartSpec.display === 'sparkline' && !chartSpec.userExplicit.watermark) { + if ( + chartSpec.display === 'sparkline' && + options.watermark === undefined && + !chartSpec.userExplicit.watermark + ) { watermark = false; chartSpec = { ...chartSpec, watermark: false }; } @@ -584,6 +588,7 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char // watermark at any level wins. if ( (heightClass === 'minimal' || heightClass === 'cramped') && + options.watermark === undefined && !chartSpec.userExplicit.watermark ) { watermark = false; @@ -591,7 +596,6 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char } // Resolve theme: merge spec-level theme with options-level overrides - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(chartSpec.theme, options.theme); let theme: ResolvedTheme = resolveTheme(mergedThemeConfig); if (options.darkMode) { @@ -1772,7 +1776,6 @@ export function compileTable(spec: unknown, options: CompileTableOptions): Table const tableSpec = normalized as NormalizedTableSpec; // Resolve theme: merge spec-level theme with options-level overrides - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(tableSpec.theme, options.theme); let theme: ResolvedTheme = resolveTheme(mergedThemeConfig); if (options.darkMode) { diff --git a/packages/engine/src/compile/layer.ts b/packages/engine/src/compile/layer.ts index 891cb22e..424f2b1e 100644 --- a/packages/engine/src/compile/layer.ts +++ b/packages/engine/src/compile/layer.ts @@ -21,6 +21,7 @@ import { getBreakpoint, getHeightClass, getLayoutStrategy, + mergeThemeConfig, resolveNumberFormatter, resolveTheme, TICK_LABEL_OFFSET, @@ -144,7 +145,7 @@ export function compileLayer( for (const { leaf, leafIndex } of indexedLeaves) { const themedLeaf = { ...withSharedDomains(leaf as ChartSpec, sharedDomains), - theme: (leaf as ChartSpec).theme ?? spec.theme, + theme: mergeThemeConfig(spec.theme, (leaf as ChartSpec).theme), darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode, }; const leafLayout = compileChart(themedLeaf as unknown, leafOptions); @@ -263,7 +264,9 @@ function compileLayerIndependent( ); } - const theme = resolveTheme(layerSpec.theme ?? leaf1.theme); + const theme = resolveTheme( + mergeThemeConfig(mergeThemeConfig(layerSpec.theme, leaf1.theme), options.theme), + ); const axisFontSize = theme.fonts?.sizes?.axisTick ?? 11; const measureFn = createMeasureFn(options.measureText); const rightAxisWidth = estimateYAxisLabelWidth( @@ -1053,7 +1056,7 @@ export function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): Cha labels: layerSpec.labels ?? leaves[0].labels, legend: layerSpec.legend ?? leaves[0].legend, responsive: layerSpec.responsive ?? leaves[0].responsive, - theme: layerSpec.theme ?? leaves[0].theme, + theme: mergeThemeConfig(layerSpec.theme, leaves[0].theme), darkMode: layerSpec.darkMode ?? leaves[0].darkMode, watermark: layerSpec.watermark ?? leaves[0].watermark, hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries, diff --git a/packages/engine/src/compile/spec-sugar.ts b/packages/engine/src/compile/spec-sugar.ts index 4d3c311a..0540aad5 100644 --- a/packages/engine/src/compile/spec-sugar.ts +++ b/packages/engine/src/compile/spec-sugar.ts @@ -38,7 +38,6 @@ import type { BinTransform, DataRow, EncodingChannel, - ThemeConfig, TimeUnit, TimeUnitTransform, Transform, @@ -1068,13 +1067,3 @@ export function emitSpecWarnings(warnings: string[], sink: WarnSink = defaultWar sink(warning); } } - -/** Warn for compile/mount/provider theme inputs, beyond spec.theme. */ -export function emitThemeWarnings(theme: ThemeConfig | undefined, sink?: WarnSink): void { - if (theme?.chrome && Object.values(theme.chrome).some((value) => typeof value === 'string')) { - emitSpecWarnings( - ['[openchart] theme.chrome string colors are deprecated; use { color } objects.'], - sink, - ); - } -} diff --git a/packages/engine/src/geo/compile-geo-map.ts b/packages/engine/src/geo/compile-geo-map.ts index bad8346b..1e8d83b9 100644 --- a/packages/engine/src/geo/compile-geo-map.ts +++ b/packages/engine/src/geo/compile-geo-map.ts @@ -37,7 +37,7 @@ import { feature as topoFeature, mesh as topoMesh } from 'topojson-client'; import type { GeometryCollection, Topology } from 'topojson-specification'; import { resolveKnockoutColor } from '../charts/utils'; import { buildSizeScale, SIZE_SCALE_DEFAULTS } from '../compile/size-scale'; -import { emitSpecWarnings, emitThemeWarnings, expandSpecSugar } from '../compile/spec-sugar'; +import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar'; import { resolveAnimation } from '../compiler/animation'; import { compile as compileSpec } from '../compiler/index'; import { finiteNumber } from '../data/finite-number'; @@ -234,7 +234,6 @@ export function compileGeoMap(spec: unknown, options: CompileOptions): GeoMapLay const watermark = rawWatermark !== undefined ? mapSpec.watermark : (options.watermark ?? true); // 3. Resolve theme - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(mapSpec.theme, options.theme); const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig); let theme: ResolvedTheme = lightTheme; diff --git a/packages/engine/src/graphs/compile-graph.ts b/packages/engine/src/graphs/compile-graph.ts index e027b3c6..6982dbba 100644 --- a/packages/engine/src/graphs/compile-graph.ts +++ b/packages/engine/src/graphs/compile-graph.ts @@ -31,7 +31,7 @@ import { defaultNumberFormatter, resolveTheme, } from '@opendata-ai/openchart-core'; -import { emitSpecWarnings, emitThemeWarnings } from '../compile/spec-sugar'; +import { emitSpecWarnings } from '../compile/spec-sugar'; import { compile as compileSpec } from '../compiler/index'; import type { NormalizedGraphSpec } from '../compiler/types'; import { resolveGraphAnimation } from './animation'; @@ -349,7 +349,6 @@ export function compileGraph(spec: unknown, options: CompileOptions): GraphCompi const watermark = rawWatermark !== undefined ? graphSpec.watermark : (options.watermark ?? true); // 2. Resolve theme - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(graphSpec.theme, options.theme); let theme: ResolvedTheme = resolveTheme(mergedThemeConfig); if (options.darkMode) { diff --git a/packages/engine/src/sankey/compile-sankey.ts b/packages/engine/src/sankey/compile-sankey.ts index 328eeaab..f1fbd788 100644 --- a/packages/engine/src/sankey/compile-sankey.ts +++ b/packages/engine/src/sankey/compile-sankey.ts @@ -36,7 +36,7 @@ import { estimateTextWidth, resolveTheme, } from '@opendata-ai/openchart-core'; -import { emitSpecWarnings, emitThemeWarnings, expandSpecSugar } from '../compile/spec-sugar'; +import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar'; import { resolveAnimation } from '../compiler/animation'; import { compile as compileSpec } from '../compiler/index'; import { finiteNumber } from '../data/finite-number'; @@ -339,7 +339,6 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay const watermark = rawWatermark !== undefined ? sankeySpec.watermark : (options.watermark ?? true); // 2. Resolve theme - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(sankeySpec.theme, options.theme); const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig); let theme: ResolvedTheme = lightTheme; diff --git a/packages/engine/src/tilemap/compile-tilemap.ts b/packages/engine/src/tilemap/compile-tilemap.ts index 1b04615b..6c89e178 100644 --- a/packages/engine/src/tilemap/compile-tilemap.ts +++ b/packages/engine/src/tilemap/compile-tilemap.ts @@ -46,7 +46,7 @@ import { } from '@opendata-ai/openchart-core'; import { interpolateRgb } from 'd3-interpolate'; import { scaleLinear } from 'd3-scale'; -import { emitSpecWarnings, emitThemeWarnings, expandSpecSugar } from '../compile/spec-sugar'; +import { emitSpecWarnings, expandSpecSugar } from '../compile/spec-sugar'; import { resolveAnimation } from '../compiler/animation'; import { compile as compileSpec } from '../compiler/index'; import { finiteNumber } from '../data/finite-number'; @@ -159,7 +159,6 @@ export function compileTileMap(spec: unknown, options: CompileOptions): TileMapL rawWatermark !== undefined ? tilemapSpec.watermark : (options.watermark ?? true); // 2. Resolve theme - emitThemeWarnings(options.theme, options.onWarn); const mergedThemeConfig = mergeThemeConfig(tilemapSpec.theme, options.theme); const lightTheme: ResolvedTheme = resolveTheme(mergedThemeConfig); let theme: ResolvedTheme = lightTheme; diff --git a/packages/react/src/BarList.tsx b/packages/react/src/BarList.tsx index b87cdd64..c0fe6b99 100644 --- a/packages/react/src/BarList.tsx +++ b/packages/react/src/BarList.tsx @@ -7,6 +7,7 @@ */ import type { BarListSpec, DarkMode, ThemeConfig } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { type BarListInstance, type BarListMountOptions, @@ -18,6 +19,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; @@ -48,7 +50,7 @@ export const BarList = forwardRef(function BarList( ) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const instanceRef = useRef(null); diff --git a/packages/react/src/Chart.tsx b/packages/react/src/Chart.tsx index 749996e3..eff5cdb5 100644 --- a/packages/react/src/Chart.tsx +++ b/packages/react/src/Chart.tsx @@ -17,6 +17,7 @@ import type { LayerSpec, ThemeConfig, } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { type ChartInstance, createChart, type MountOptions } from '@opendata-ai/openchart-vanilla'; import { type CSSProperties, @@ -24,6 +25,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; @@ -124,7 +126,7 @@ function ChartInner( const hasTransactionListener = Boolean(onEditTransaction); const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const chartRef = useRef(null); diff --git a/packages/react/src/DataTable.tsx b/packages/react/src/DataTable.tsx index 253fbcd9..ae212fe8 100644 --- a/packages/react/src/DataTable.tsx +++ b/packages/react/src/DataTable.tsx @@ -7,12 +7,13 @@ */ import type { DarkMode, SortState, TableSpec, ThemeConfig } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { createTable, type TableInstance, type TableMountOptions, } from '@opendata-ai/openchart-vanilla'; -import { type CSSProperties, useCallback, useEffect, useRef } from 'react'; +import { type CSSProperties, useCallback, useEffect, useMemo, useRef } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; export interface DataTableProps { @@ -64,7 +65,7 @@ export function DataTable({ }: DataTableProps) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const tableRef = useRef(null); diff --git a/packages/react/src/GeoMap.tsx b/packages/react/src/GeoMap.tsx index 00974203..1dccc0ef 100644 --- a/packages/react/src/GeoMap.tsx +++ b/packages/react/src/GeoMap.tsx @@ -9,6 +9,7 @@ */ import type { DarkMode, GeoMapSpec, ThemeConfig } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { type Camera, createGeoMap, @@ -22,6 +23,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; @@ -56,7 +58,7 @@ export const GeoMap = forwardRef(function GeoMap( ) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const instanceRef = useRef(null); diff --git a/packages/react/src/Graph.tsx b/packages/react/src/Graph.tsx index 4b024796..a716bf37 100644 --- a/packages/react/src/Graph.tsx +++ b/packages/react/src/Graph.tsx @@ -9,6 +9,7 @@ */ import type { DarkMode, GraphSpec, ThemeConfig } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { createGraph, type GraphCamera, @@ -22,6 +23,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import type { GraphHandle } from './hooks/useGraph'; @@ -130,7 +132,7 @@ export const Graph = forwardRef(function Graph( ) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const graphRef = useRef(null); diff --git a/packages/react/src/Sankey.tsx b/packages/react/src/Sankey.tsx index c70daa74..b601b88c 100644 --- a/packages/react/src/Sankey.tsx +++ b/packages/react/src/Sankey.tsx @@ -9,6 +9,7 @@ */ import type { DarkMode, SankeySpec, ThemeConfig } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { createSankey, type SankeyInstance, @@ -20,6 +21,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; @@ -73,7 +75,7 @@ export const Sankey = forwardRef(function Sankey( ) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const instanceRef = useRef(null); diff --git a/packages/react/src/ThemeContext.tsx b/packages/react/src/ThemeContext.tsx index 977da6cb..fdad3139 100644 --- a/packages/react/src/ThemeContext.tsx +++ b/packages/react/src/ThemeContext.tsx @@ -2,8 +2,8 @@ * Theme context: provides a theme and dark mode preference to all * descendant Chart, DataTable, and Graph components without prop drilling. * - * Components use the context values as fallbacks when no explicit - * `theme` or `darkMode` prop is passed. + * Components deep-merge the provider theme with their theme prop. + * An explicit `darkMode` prop wins over the provider preference. */ import type { DarkMode, ThemeConfig } from '@opendata-ai/openchart-core'; diff --git a/packages/react/src/TileMap.tsx b/packages/react/src/TileMap.tsx index bf998ad7..22bade22 100644 --- a/packages/react/src/TileMap.tsx +++ b/packages/react/src/TileMap.tsx @@ -9,6 +9,7 @@ */ import type { DarkMode, ThemeConfig, TileMapSpec } from '@opendata-ai/openchart-core'; +import { mergeThemeConfig } from '@opendata-ai/openchart-core'; import { createTileMap, type TileMapInstance, @@ -20,6 +21,7 @@ import { useCallback, useEffect, useImperativeHandle, + useMemo, useRef, } from 'react'; import { useVizDarkMode, useVizTheme } from './ThemeContext'; @@ -71,7 +73,7 @@ export const TileMap = forwardRef(function TileMap( ) { const contextTheme = useVizTheme(); const contextDarkMode = useVizDarkMode(); - const theme = themeProp ?? contextTheme; + const theme = useMemo(() => mergeThemeConfig(contextTheme, themeProp), [contextTheme, themeProp]); const resolvedDarkMode = darkMode ?? contextDarkMode; const containerRef = useRef(null); const instanceRef = useRef(null); diff --git a/packages/react/src/__tests__/Chart.test.tsx b/packages/react/src/__tests__/Chart.test.tsx index 5d39030c..cd9a88c2 100644 --- a/packages/react/src/__tests__/Chart.test.tsx +++ b/packages/react/src/__tests__/Chart.test.tsx @@ -2,6 +2,7 @@ import type { ChartSpec } from '@opendata-ai/openchart-core'; import { cleanup, render, waitFor } from '@testing-library/react'; import { afterEach, describe, expect, it } from 'vitest'; import { Chart } from '../Chart'; +import { VizThemeProvider } from '../ThemeContext'; // --------------------------------------------------------------------------- // Test data @@ -194,3 +195,19 @@ it.each([ unmount(); expect(container.querySelector('svg')).toBeNull(); }); + +it('merges spec, provider, and prop themes at authored leaves', async () => { + const { container } = render( + + + , + ); + await waitFor(() => expect(container.querySelector('.oc-title')).not.toBeNull()); + const title = container.querySelector('.oc-title')!; + expect(title.style.fontSize).toBe('33px'); + expect(title.style.fontFamily).toBe('Georgia'); + expect(container.querySelector('.oc-source')!.style.fontSize).toBe('15px'); +}); diff --git a/packages/svelte/src/BarList.svelte b/packages/svelte/src/BarList.svelte index f3e8ca65..7692e642 100644 --- a/packages/svelte/src/BarList.svelte +++ b/packages/svelte/src/BarList.svelte @@ -3,6 +3,7 @@ -->
{ editable: true, autoResize: false, revision: 'one', - onedittransaction: (tx: unknown) => transactions.push(tx), + onedit: (tx: unknown) => transactions.push(tx), }); await rerender({ spec, editable: true, autoResize: false, revision: 'two', - onedittransaction: (tx: unknown) => transactions.push(tx), + onedit: (tx: unknown) => transactions.push(tx), }); container .querySelector('[data-annotation-index="0"]')! @@ -197,7 +197,7 @@ it('reacts to transaction listener presence without enabling all charts for edit const cursor = () => (container.querySelector('[data-annotation-index="0"]') as SVGElement).style.cursor; expect(cursor()).not.toBe('grab'); - await rerender({ spec, onedittransaction: (tx: unknown) => transactions.push(tx) }); + await rerender({ spec, onedit: (tx: unknown) => transactions.push(tx) }); expect(cursor()).toBe('grab'); container .querySelector('[data-annotation-index="0"]')! @@ -206,7 +206,7 @@ it('reacts to transaction listener presence without enabling all charts for edit .querySelector('svg')! .dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); expect(transactions).toHaveLength(1); - await rerender({ spec, onedittransaction: undefined }); + await rerender({ spec, onedit: undefined }); expect(cursor()).not.toBe('grab'); }); diff --git a/packages/svelte/src/__tests__/Graph.dimensions.test.ts b/packages/svelte/src/__tests__/Graph.dimensions.test.ts index 7b7feea8..da037e37 100644 --- a/packages/svelte/src/__tests__/Graph.dimensions.test.ts +++ b/packages/svelte/src/__tests__/Graph.dimensions.test.ts @@ -12,7 +12,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const instanceStub = { update: vi.fn(), - updateVisuals: vi.fn(), search: vi.fn(), clearSearch: vi.fn(), getSearchMatches: vi.fn(() => []), diff --git a/packages/svelte/src/__tests__/Graph.test.ts b/packages/svelte/src/__tests__/Graph.test.ts index 2a6dc0c1..9bedb32e 100644 --- a/packages/svelte/src/__tests__/Graph.test.ts +++ b/packages/svelte/src/__tests__/Graph.test.ts @@ -148,7 +148,6 @@ describe('', () => { 'clearHighlight', 'getHighlight', 'getLegend', - 'updateVisuals', ]) { expect(typeof api[name]).toBe('function'); } diff --git a/packages/svelte/src/composables/useChart.svelte.ts b/packages/svelte/src/composables/useChart.svelte.ts index 30d1bc9b..dbf91175 100644 --- a/packages/svelte/src/composables/useChart.svelte.ts +++ b/packages/svelte/src/composables/useChart.svelte.ts @@ -25,10 +25,8 @@ export interface UseChartOptions { theme?: MountOptions['theme']; /** Dark mode setting. */ darkMode?: MountOptions['darkMode']; - /** Data point click handler. */ - onDataPointClick?: MountOptions['onDataPointClick']; - /** Enable responsive resizing. Defaults to true. */ - responsive?: boolean; + /** Observe container resizes. Defaults to true. */ + autoResize?: boolean; /** Rendering backend for point marks; see the vanilla `MountOptions.renderer`. */ renderer?: MountOptions['renderer']; } @@ -59,8 +57,7 @@ export function useChart( const mountOpts: MountOptions = { theme: opts?.theme, darkMode: opts?.darkMode, - onDataPointClick: opts?.onDataPointClick, - responsive: opts?.responsive ?? true, + autoResize: opts?.autoResize ?? true, renderer: opts?.renderer, }; diff --git a/packages/svelte/src/types.ts b/packages/svelte/src/types.ts index b0a15055..bdb1450a 100644 --- a/packages/svelte/src/types.ts +++ b/packages/svelte/src/types.ts @@ -7,11 +7,11 @@ import type { Annotation, - AnnotationOffset, BarListSpec, ChartSpec, DarkMode, - ElementEdit, + EditTransaction, + ElementRef, GeoMapSpec, GraphSpec, LayerSpec, @@ -19,7 +19,6 @@ import type { SankeySpec, SortState, TableSpec, - TextAnnotation, ThemeConfig, TileMapSpec, VizSpec, @@ -36,10 +35,15 @@ export interface ChartProps { onmarkhover?: (event: MarkEvent) => void; onmarkleave?: () => void; onlegendtoggle?: (series: string, visible: boolean) => void; - onannotationclick?: (annotation: Annotation, event: MouseEvent) => void; - onannotationedit?: (annotation: TextAnnotation, offset: AnnotationOffset) => void; - onedit?: (edit: ElementEdit) => void; - ondatapointclick?: (data: Record) => void; + autoResize?: boolean; + revision?: string | number; + editable?: boolean; + selectedElement?: ElementRef; + highlight?: string[] | null; + onedit?: (transaction: EditTransaction) => void; + onselect?: (element: ElementRef) => void; + ondeselect?: (element: ElementRef) => void; + onannotationclick?: (annotation: Annotation, event: MouseEvent, element: ElementRef) => void; class?: string; style?: string; } diff --git a/packages/vanilla/src/__tests__/annotation-gestures.test.ts b/packages/vanilla/src/__tests__/annotation-gestures.test.ts index e1fc8cc6..f9b71bba 100644 --- a/packages/vanilla/src/__tests__/annotation-gestures.test.ts +++ b/packages/vanilla/src/__tests__/annotation-gestures.test.ts @@ -30,6 +30,7 @@ afterEach(() => { for (const fn of cleanup.splice(0)) fn(); document.body.replaceChildren(); vi.restoreAllMocks(); + vi.useRealTimers(); }); function mount(controlled = false) { let authored = spec; @@ -52,7 +53,7 @@ function mount(controlled = false) { const chart = createChart(container, spec, { autoResize: false, editable: true, - onEditTransaction: (tx) => { + onEdit: (tx) => { transactions.push(tx); if (controlled) { const result = applyEditTransaction(authored, tx); @@ -128,14 +129,17 @@ describe('annotation gestures', () => { expect(m.transactions).toHaveLength(1); }); it('a no-op host leaves authored geometry after the keyboard batch commits', () => { + vi.useFakeTimers(); const m = mount(); m.group().dispatchEvent(createMouseEvent('click')); const svg = m.container.querySelector('svg')!; const initial = m.group().querySelector('.oc-annotation-label')!.getAttribute('x'); - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowLeft', altKey: true, bubbles: true }), - ); + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); svg.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })); + expect(m.transactions).toHaveLength(0); + vi.advanceTimersByTime(499); + expect(m.transactions).toHaveLength(0); + vi.advanceTimersByTime(1); expect(m.transactions).toHaveLength(1); expect(m.group().querySelector('.oc-annotation-label')!.getAttribute('x')).toBe(initial); }); @@ -145,7 +149,7 @@ describe('annotation gestures', () => { m.container.querySelector('svg')!.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft', - altKey: true, + bubbles: true, }), ); @@ -160,7 +164,7 @@ describe('annotation gestures', () => { m.container.querySelector('svg')!.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowLeft', - altKey: true, + bubbles: true, }), ); @@ -168,17 +172,15 @@ describe('annotation gestures', () => { expect(m.transactions).toHaveLength(1); expect(m.chart.layout.annotations[0].label!.x).toBeCloseTo(initial - 1); }); - it('Alt arrows nudge and flush before Escape without changing plain-arrow selection', () => { + it('arrows nudge and flush before Escape', () => { const m = mount(); m.group().dispatchEvent(createMouseEvent('click')); const svg = m.container.querySelector('svg')!; - svg.dispatchEvent( - new KeyboardEvent('keydown', { key: 'ArrowRight', altKey: true, bubbles: true }), - ); + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowRight', bubbles: true })); svg.dispatchEvent( new KeyboardEvent('keydown', { key: 'ArrowDown', - altKey: true, + shiftKey: true, bubbles: true, }), @@ -190,3 +192,56 @@ describe('annotation gestures', () => { }); }); }); + +it('nudges by one pixel or ten with Shift, while Tab cycles selection', () => { + const m = mount(true); + m.group().dispatchEvent(createMouseEvent('click')); + const original = m.chart.layout.annotations[0].label!; + const before = { x: original.x, y: original.y }; + const svg = m.container.querySelector('svg')!; + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + svg.dispatchEvent( + new KeyboardEvent('keydown', { key: 'ArrowDown', shiftKey: true, bubbles: true }), + ); + expect(m.transactions).toHaveLength(0); + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Tab', bubbles: true })); + expect(m.transactions).toHaveLength(1); + expect(m.chart.layout.annotations[0].label!.x).toBeCloseTo(before.x - 1); + expect(m.chart.layout.annotations[0].label!.y).toBeCloseTo(before.y + 10); +}); + +it('batches successive key releases for 500ms and leaves modified/native key combinations alone', () => { + vi.useFakeTimers(); + const m = mount(); + m.group().dispatchEvent(createMouseEvent('click')); + const svg = m.container.querySelector('svg')!; + for (const modifier of ['altKey', 'metaKey', 'ctrlKey']) { + const event = new KeyboardEvent('keydown', { + key: 'ArrowLeft', + [modifier]: true, + bubbles: true, + cancelable: true, + }); + svg.dispatchEvent(event); + expect(event.defaultPrevented).toBe(false); + } + expect(m.transactions).toHaveLength(0); + for (let index = 0; index < 2; index++) { + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + svg.dispatchEvent(new KeyboardEvent('keyup', { key: 'ArrowLeft', bubbles: true })); + vi.advanceTimersByTime(400); + expect(m.transactions).toHaveLength(0); + } + vi.advanceTimersByTime(100); + expect(m.transactions).toHaveLength(1); +}); + +it('flushes a pending nudge before opening the text editor', () => { + const m = mount(true); + m.group().dispatchEvent(createMouseEvent('click')); + const svg = m.container.querySelector('svg')!; + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowLeft', bubbles: true })); + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); + expect(m.transactions).toHaveLength(1); + expect(m.container.querySelector('textarea, input, [contenteditable]')).not.toBeNull(); +}); diff --git a/packages/vanilla/src/__tests__/annotation-identity.test.ts b/packages/vanilla/src/__tests__/annotation-identity.test.ts index 93704ce2..a18646e7 100644 --- a/packages/vanilla/src/__tests__/annotation-identity.test.ts +++ b/packages/vanilla/src/__tests__/annotation-identity.test.ts @@ -78,7 +78,7 @@ describe('annotation source and instance identity', () => { const container = createContainer(); const chart = createChart(container, spec, { revision: 1, - onEditTransaction: (tx) => transactions.push(tx), + onEdit: (tx) => transactions.push(tx), }); const el = container.querySelector('[data-annotation-index="0"]')!; el.dispatchEvent(new MouseEvent('click', { bubbles: true })); diff --git a/packages/vanilla/src/__tests__/deprecations.test.ts b/packages/vanilla/src/__tests__/deprecations.test.ts deleted file mode 100644 index f0e43f9f..00000000 --- a/packages/vanilla/src/__tests__/deprecations.test.ts +++ /dev/null @@ -1,27 +0,0 @@ -import { afterEach, describe, expect, it, vi } from 'vitest'; -import { createContainer } from '../__test-fixtures__/dom'; -import { barSpec } from '../__test-fixtures__/specs'; -import { createChart } from '../mount'; - -afterEach(() => { - vi.restoreAllMocks(); - document.body.innerHTML = ''; -}); -describe('8.x compatibility warnings', () => { - it('warns once across mounts and keeps autoResize separate from responsive spec', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); - for (let i = 0; i < 2; i++) { - const chart = createChart(createContainer(), barSpec, { - responsive: false, - autoResize: false, - onDataPointClick: () => {}, - onAnnotationEdit: () => {}, - onTextEdit: () => {}, - }); - chart.destroy(); - } - for (const name of ['onDataPointClick', 'onAnnotationEdit', 'onTextEdit', 'mount responsive']) { - expect(warn.mock.calls.filter(([message]) => String(message).includes(name))).toHaveLength(1); - } - }); -}); diff --git a/packages/vanilla/src/__tests__/edit-events.test.ts b/packages/vanilla/src/__tests__/edit-events.test.ts index f03ecd82..73e36f56 100644 --- a/packages/vanilla/src/__tests__/edit-events.test.ts +++ b/packages/vanilla/src/__tests__/edit-events.test.ts @@ -1,4 +1,4 @@ -import type { ChartSpec, ElementEdit } from '@opendata-ai/openchart-engine'; +import type { ChartSpec, EditTransaction } from '@opendata-ai/openchart-engine'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createContainer, createMouseEvent } from '../__test-fixtures__/dom'; import { barSpec, lineSpec } from '../__test-fixtures__/specs'; @@ -166,17 +166,18 @@ describe('edit events', () => { simulateDrag(annotation, 100, 100, 150, 130); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - // Canonical placement converts the implicit diagonal anchor into explicit right. - expect(edit.offset.dx).toBe(60); - expect(edit.offset.dy).toBe(-18); - expect(edit.anchor).toBe('right'); - expect(edit.responsive).toBe(false); - // RFC 6901 path to the offset property - expect(edit.element.path).toBe('/annotations/0/offset'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); + + // Canonical placement converts the implicit diagonal anchor into explicit right. + expect(valueAt<{ dx: number; dy: number }>(edit, '/offset').dx).toBe(60); + expect(valueAt<{ dx: number; dy: number }>(edit, '/offset').dy).toBe(-18); + expect(valueAt(edit, '/anchor')).toBe('right'); + expect(valueAt(edit, '/responsive')).toBe(false); + // RFC 6901 path to the offset property + expect(edit.ops.find((op) => op.path.endsWith('/offset'))?.path).toBe( + '/annotations/0/offset', + ); chart.destroy(); }); @@ -305,49 +306,6 @@ describe('edit events', () => { expect(legendG.style.cursor).toBe('grab'); } - // Series labels should have grab cursor (if any rendered with data-series) - const seriesLabels = container.querySelectorAll('.oc-mark-label[data-series]'); - for (const el of seriesLabels) { - expect((el as HTMLElement).style.cursor).toBe('grab'); - } - - chart.destroy(); - }); - - it('dragging a text annotation fires BOTH onAnnotationEdit and onEdit when both are provided', () => { - const onAnnotationEdit = vi.fn(); - const onEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit, onEdit }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - simulateDrag(annotation, 100, 100, 150, 130); - - expect(onAnnotationEdit).toHaveBeenCalledTimes(1); - expect(onEdit).toHaveBeenCalledTimes(1); - - // Verify onAnnotationEdit received the text annotation and offset - const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0]; - expect(annotationArg.type).toBe('text'); - expect(annotationArg.text).toBe('Peak'); - expect(offsetArg.dx).toBe(60); - expect(offsetArg.dy).toBe(10); - - // Verify onEdit received an annotation edit - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - expect(edit.annotation.text).toBe('Peak'); - expect(edit.offset.dx).toBe(60); - expect(edit.offset.dy).toBe(-18); - expect(edit.anchor).toBe('right'); - expect(edit.responsive).toBe(false); - } - chart.destroy(); }); @@ -364,8 +322,8 @@ describe('edit events', () => { simulateDrag(annotation, 100, 100, 150, 130); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); chart.destroy(); }); @@ -407,15 +365,16 @@ describe('edit events', () => { simulateDrag(rangeLabel, 100, 100, 140, 120); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('range-label'); - if (edit.type === 'range-label') { - expect(edit.annotation.type).toBe('range'); - // Original labelOffset (5, 3) + drag delta (40, 20) = (45, 23) - expect(edit.labelOffset.dx).toBe(45); - expect(edit.labelOffset.dy).toBe(23); - expect(edit.element.path).toBe('/annotations/0/labelOffset'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/labelOffset'))).toBe(true); + + expect(edit.ops[0].expect?.type).toBe('range'); + // Original labelOffset (5, 3) + drag delta (40, 20) = (45, 23) + expect(valueAt<{ dx: number; dy: number }>(edit, '/labelOffset').dx).toBe(45); + expect(valueAt<{ dx: number; dy: number }>(edit, '/labelOffset').dy).toBe(23); + expect(edit.ops.find((op) => op.path.endsWith('/labelOffset'))?.path).toBe( + '/annotations/0/labelOffset', + ); chart.destroy(); }); @@ -451,15 +410,16 @@ describe('edit events', () => { simulateDrag(reflineLabel, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('refline-label'); - if (edit.type === 'refline-label') { - expect(edit.annotation.type).toBe('refline'); - // Original labelOffset (2, -4) + drag delta (30, 15) = (32, 11) - expect(edit.labelOffset.dx).toBe(32); - expect(edit.labelOffset.dy).toBe(11); - expect(edit.element.path).toBe('/annotations/0/labelOffset'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/labelOffset'))).toBe(true); + + expect(edit.ops[0].expect?.type).toBe('refline'); + // Original labelOffset (2, -4) + drag delta (30, 15) = (32, 11) + expect(valueAt<{ dx: number; dy: number }>(edit, '/labelOffset').dx).toBe(32); + expect(valueAt<{ dx: number; dy: number }>(edit, '/labelOffset').dy).toBe(11); + expect(edit.ops.find((op) => op.path.endsWith('/labelOffset'))?.path).toBe( + '/annotations/0/labelOffset', + ); chart.destroy(); }); @@ -498,15 +458,20 @@ describe('edit events', () => { simulateDrag(titleEl, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('chrome'); - if (edit.type === 'chrome') { - expect(edit.key).toBe('title'); - expect(edit.text).toBeTruthy(); - // No existing offset, so offset = delta (30, 15) - expect(edit.offset.dx).toBe(30); - expect(edit.offset.dy).toBe(15); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/chrome/title'))).toBe(true); + + expect(edit.ops[0].path).toBe('/chrome/title'); + expect(valueAt<{ text: string }>(edit, '/chrome/title').text).toBeTruthy(); + // No existing offset, so offset = delta (30, 15) + expect( + valueAt<{ offset: { dx: number; dy: number }; text: string }>(edit, '/chrome/title').offset + .dx, + ).toBe(30); + expect( + valueAt<{ offset: { dx: number; dy: number }; text: string }>(edit, '/chrome/title').offset + .dy, + ).toBe(15); chart.destroy(); }); @@ -543,12 +508,11 @@ describe('edit events', () => { simulateDrag(legendG, 200, 50, 250, 70); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('legend'); - if (edit.type === 'legend') { - expect(edit.offset.dx).toBe(50); - expect(edit.offset.dy).toBe(20); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/legend'))).toBe(true); + + expect(valueAt<{ offset: { dx: number; dy: number } }>(edit, '/legend').offset.dx).toBe(50); + expect(valueAt<{ offset: { dx: number; dy: number } }>(edit, '/legend').offset.dy).toBe(20); chart.destroy(); }); @@ -690,16 +654,23 @@ describe('edit events', () => { simulateDrag(fromHandle, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation-connector'); - if (edit.type === 'annotation-connector') { - expect(edit.endpoint).toBe('from'); - expect(edit.annotation.type).toBe('text'); - // No existing connectorOffset, so offset = delta (30, 15) - expect(edit.offset.dx).toBe(30); - expect(edit.offset.dy).toBe(15); - expect(edit.element.path).toBe('/annotations/0/connectorOffset/from'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/connectorOffset'))).toBe(true); + + expect( + valueAt>(edit, '/connectorOffset').from, + ).toBeDefined(); + expect(edit.ops[0].expect?.type).toBe('text'); + // No existing connectorOffset, so offset = delta (30, 15) + expect( + valueAt>(edit, '/connectorOffset').from.dx, + ).toBe(30); + expect( + valueAt>(edit, '/connectorOffset').from.dy, + ).toBe(15); + expect(edit.ops.find((op) => op.path.endsWith('/connectorOffset'))?.path).toBe( + '/annotations/0/connectorOffset', + ); chart.destroy(); }); @@ -730,16 +701,23 @@ describe('edit events', () => { simulateDrag(fromHandle!, 100, 100, 130, 118); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation-connector'); - if (edit.type !== 'annotation-connector') return; - expect(edit.endpoint).toBe('from'); + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/connectorOffset'))).toBe(true); + expect( + valueAt>(edit, '/connectorOffset').from, + ).toBeDefined(); // Host writes the edit back to the spec and re-renders. chart.update({ ...autoAnnotatedSpec, annotations: [ - { ...autoAnnotatedSpec.annotations![0], connectorOffset: { from: edit.offset } }, + { + ...autoAnnotatedSpec.annotations![0], + connectorOffset: { + from: valueAt>(edit, '/connectorOffset') + .from, + }, + }, ], } as ChartSpec); @@ -774,12 +752,15 @@ describe('edit events', () => { simulateDrag(toHandle, 100, 100, 140, 125); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation-connector'); - if (edit.type === 'annotation-connector') { - expect(edit.endpoint).toBe('to'); - expect(edit.element.path).toBe('/annotations/0/connectorOffset/to'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/connectorOffset'))).toBe(true); + + expect( + valueAt>(edit, '/connectorOffset').to, + ).toBeDefined(); + expect(edit.ops.find((op) => op.path.endsWith('/connectorOffset'))?.path).toBe( + '/annotations/0/connectorOffset', + ); chart.destroy(); }); @@ -802,13 +783,12 @@ describe('edit events', () => { simulateDrag(secondG, 100, 100, 120, 110); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - // The edit must carry the annotation whose label was actually dragged. - // Pre-fix this returned the *other* one (zIndex had swapped their slots). - expect(edit.annotation.id).toBe('second'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); + + // The edit must carry the annotation whose label was actually dragged. + // Pre-fix this returned the *other* one (zIndex had swapped their slots). + expect(edit.ops[0].expect?.id).toBe('second'); chart.destroy(); }); @@ -890,13 +870,18 @@ describe('edit events', () => { simulateDrag(toHandle, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation-connector'); - if (edit.type === 'annotation-connector') { - expect(edit.endpoint).toBe('to'); - expect(edit.offset.dx).toBe(30); - expect(edit.offset.dy).toBe(15); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/connectorOffset'))).toBe(true); + + expect( + valueAt>(edit, '/connectorOffset').to, + ).toBeDefined(); + expect( + valueAt>(edit, '/connectorOffset').to.dx, + ).toBe(30); + expect( + valueAt>(edit, '/connectorOffset').to.dy, + ).toBe(15); chart.destroy(); }); @@ -906,109 +891,27 @@ describe('edit events', () => { // ========================================================================= // 7. legend-toggle onEdit payload // ========================================================================= - describe('legend-toggle onEdit payload', () => { - /** Multi-series line chart with explicit top legend so entries always render. */ - const legendToggleSpec: ChartSpec = { - ...lineSpec, - legend: { show: true, position: 'top' }, - }; - - it('legend click fires onEdit with correct payload', () => { + describe('transient legend visibility', () => { + it('reports visibility through onLegendToggle without a durable transaction', () => { const onEdit = vi.fn(); const onLegendToggle = vi.fn(); - const chart = createChart(container, legendToggleSpec, { onEdit, onLegendToggle }); - - const entry = container.querySelector( - '.oc-legend:not(.oc-legend--size) [data-legend-index]', - ) as SVGGElement | null; - if (!entry) { - chart.destroy(); - return; - } - - const label = entry.getAttribute('data-legend-label')!; - entry.dispatchEvent(new MouseEvent('click', { bubbles: true })); - - expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('legend-toggle'); - if (edit.type === 'legend-toggle') { - expect(edit.series).toBe(label); - expect(edit.hidden).toBe(true); - } - - chart.destroy(); - }); - - it('second click re-shows (hidden: false)', () => { - const onEdit = vi.fn(); - const chart = createChart(container, legendToggleSpec, { onEdit }); - - const entry = container.querySelector( - '.oc-legend:not(.oc-legend--size) [data-legend-index]', - ) as SVGGElement | null; - if (!entry) { - chart.destroy(); - return; - } - const label = entry.getAttribute('data-legend-label')!; - - // First click hides (triggers re-render, so DOM reference goes stale) - entry.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(onEdit).toHaveBeenCalledTimes(1); - expect((onEdit.mock.calls[0][0] as ElementEdit & { type: 'legend-toggle' }).hidden).toBe( - true, + const chart = createChart( + container, + { ...lineSpec, legend: { show: true, position: 'top' } }, + { onEdit, onLegendToggle }, ); - - // Re-query after re-render for the same series - const entry2 = container.querySelector( - `.oc-legend:not(.oc-legend--size) [data-legend-label="${label}"]`, - ) as SVGGElement | null; - expect(entry2).toBeTruthy(); - - // Second click re-shows - entry2!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(onEdit).toHaveBeenCalledTimes(2); - const edit: ElementEdit = onEdit.mock.calls[1][0]; - expect(edit.type).toBe('legend-toggle'); - if (edit.type === 'legend-toggle') { - expect(edit.hidden).toBe(false); - } - - chart.destroy(); - }); - - it('onLegendToggle polarity is opposite of hidden', () => { - const onEdit = vi.fn(); - const onLegendToggle = vi.fn(); - const chart = createChart(container, legendToggleSpec, { onEdit, onLegendToggle }); - const entry = container.querySelector( '.oc-legend:not(.oc-legend--size) [data-legend-index]', - ) as SVGGElement | null; - if (!entry) { - chart.destroy(); - return; - } - - const label = entry.getAttribute('data-legend-label')!; - - // First click: hidden=true, visible=false (triggers re-render) + )!; + expect(entry).not.toBeNull(); + const series = entry.getAttribute('data-legend-label')!; entry.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(onLegendToggle).toHaveBeenCalledTimes(1); - expect(onLegendToggle).toHaveBeenCalledWith(label, false); - - // Re-query after re-render for the same series - const entry2 = container.querySelector( - `.oc-legend:not(.oc-legend--size) [data-legend-label="${label}"]`, - ) as SVGGElement | null; - expect(entry2).toBeTruthy(); - - // Second click: hidden=false, visible=true - entry2!.dispatchEvent(new MouseEvent('click', { bubbles: true })); - expect(onLegendToggle).toHaveBeenCalledTimes(2); - expect(onLegendToggle).toHaveBeenCalledWith(label, true); - + expect(onLegendToggle).toHaveBeenLastCalledWith(series, false); + container + .querySelector(`[data-legend-label="${series}"]`)! + .dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(onLegendToggle).toHaveBeenLastCalledWith(series, true); + expect(onEdit).not.toHaveBeenCalled(); chart.destroy(); }); }); @@ -1034,13 +937,16 @@ describe('edit events', () => { const svg = container.querySelector('svg')!; svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Delete', bubbles: true })); - const deleteCall = onEdit.mock.calls.find((call: [ElementEdit]) => call[0].type === 'delete'); + const deleteCall = onEdit.mock.calls.find((call: [EditTransaction]) => + call[0].ops.some((op) => op.op === 'remove'), + ); expect(deleteCall).toBeDefined(); - const edit: ElementEdit = deleteCall![0]; - expect(edit.type).toBe('delete'); - if (edit.type === 'delete') { - expect(edit.element.path).toBe('/annotations/0'); - } + const edit: EditTransaction = deleteCall![0]; + expect(edit.ops.some((op) => op.op === 'remove')).toBe(true); + + expect(edit.ops.find((op) => op.path.endsWith('/annotations/0'))?.path).toBe( + '/annotations/0', + ); chart.destroy(); }); @@ -1129,10 +1035,11 @@ describe('edit events', () => { simulateDrag(toHandle, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - if (edit.type === 'annotation-connector') { - expect(edit.element.path).toBe('/annotations/0/connectorOffset/to'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + + expect(edit.ops.find((op) => op.path.endsWith('/connectorOffset'))?.path).toBe( + '/annotations/0/connectorOffset', + ); chart.destroy(); }); @@ -1153,82 +1060,22 @@ describe('edit events', () => { // ========================================================================= // 10. wireSeriesLabelDrag // ========================================================================= - describe('wireSeriesLabelDrag', () => { - it('series labels get cursor:grab when onEdit is provided', () => { - const onEdit = vi.fn(); - const chart = createChart(container, fullEditSpec, { onEdit }); - - const seriesLabels = container.querySelectorAll('.oc-mark-label[data-series]'); - if (seriesLabels.length === 0) { - // Labels may not render in the test env depending on layout - chart.destroy(); - return; - } - - for (const el of seriesLabels) { - expect((el as HTMLElement).style.cursor).toBe('grab'); - } - - chart.destroy(); - }); - - it('dragging fires onEdit with type series-label, correct series name, and accumulated offset', () => { - const onEdit = vi.fn(); - const chart = createChart(container, fullEditSpec, { onEdit }); - - const seriesLabel = container.querySelector( - '.oc-mark-label[data-series]', - ) as SVGTextElement | null; - if (!seriesLabel) { - chart.destroy(); - return; - } - - const seriesName = seriesLabel.getAttribute('data-series')!; - simulateDrag(seriesLabel, 100, 100, 135, 118); - - // Find the onEdit call for a series-label (could have other edits from other elements) - const seriesLabelCall = onEdit.mock.calls.find( - (call: [ElementEdit]) => call[0].type === 'series-label', - ); - expect(seriesLabelCall).toBeDefined(); - - const edit: ElementEdit = seriesLabelCall![0]; - expect(edit.type).toBe('series-label'); - if (edit.type === 'series-label') { - expect(edit.series).toBe(seriesName); - // No existing offset, so offset = delta (35, 18) - expect(edit.offset.dx).toBe(35); - expect(edit.offset.dy).toBe(18); - } - - chart.destroy(); - }); - }); // ========================================================================= // 8. element field on annotation edit payloads // ========================================================================= describe('element field on annotation edit payloads', () => { - it('keeps first-leaf drag pointers canonical in both callbacks', () => { + it('keeps first-leaf drag pointers canonical', () => { const onEdit = vi.fn(); - const onEditTransaction = vi.fn(); - const chart = createChart( - container, - { layer: [textAnnotatedSpec] }, - { onEdit, onEditTransaction }, - ); + const chart = createChart(container, { layer: [textAnnotatedSpec] }, { onEdit }); const annotation = container.querySelector('.oc-annotation-text'); expect(annotation).not.toBeNull(); simulateDrag(annotation!, 100, 100, 150, 130); - expect(onEdit.mock.calls[0][0].element.path).toBe('/layer/0/annotations/0/offset'); - expect(onEditTransaction.mock.calls[0][0].ops.map((op: { path: string }) => op.path)).toEqual( - [ - '/layer/0/annotations/0/anchor', - '/layer/0/annotations/0/offset', - '/layer/0/annotations/0/responsive', - ], - ); + expect(onEdit.mock.calls[0][0].ops.map((op: { path: string }) => op.path)).toEqual([ + '/layer/0/annotations/0/anchor', + '/layer/0/annotations/0/offset', + '/layer/0/annotations/0/responsive', + ]); chart.destroy(); }); @@ -1245,12 +1092,13 @@ describe('edit events', () => { simulateDrag(annotation, 100, 100, 150, 130); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - expect(edit.element).toMatchObject({ type: 'annotation', index: 0 }); - expect(edit.element.path).toBe('/annotations/0/offset'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); + + expect(edit.ops[0].path).toMatch(/^\/annotations\/0\//); + expect(edit.ops.find((op) => op.path.endsWith('/offset'))?.path).toBe( + '/annotations/0/offset', + ); chart.destroy(); }); @@ -1282,11 +1130,10 @@ describe('edit events', () => { simulateDrag(annotation, 100, 100, 150, 130); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - expect(edit.element.id).toBe('test-id'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); + + expect(edit.ops[0].expect?.id).toBe('test-id'); chart.destroy(); }); @@ -1304,11 +1151,10 @@ describe('edit events', () => { simulateDrag(annotation, 100, 100, 150, 130); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation'); - if (edit.type === 'annotation') { - expect(edit.element.id).toBeUndefined(); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/offset'))).toBe(true); + + expect(edit.ops[0].expect?.id).toBeUndefined(); chart.destroy(); }); @@ -1349,12 +1195,14 @@ describe('edit events', () => { simulateDrag(fromHandle, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('annotation-connector'); - if (edit.type === 'annotation-connector') { - expect(edit.element).toMatchObject({ type: 'annotation', index: 0, id: 'conn-id' }); - expect(edit.element.path).toBe('/annotations/0/connectorOffset/from'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/connectorOffset'))).toBe(true); + + expect(edit.ops[0].path).toMatch(/^\/annotations\/0\//); + expect(edit.ops[0].expect?.id).toBe('conn-id'); + expect(edit.ops.find((op) => op.path.endsWith('/connectorOffset'))?.path).toBe( + '/annotations/0/connectorOffset', + ); chart.destroy(); }); @@ -1374,14 +1222,12 @@ describe('edit events', () => { simulateDrag(rangeLabel, 100, 100, 140, 120); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('range-label'); - if (edit.type === 'range-label') { - expect(edit.element.type).toBe('annotation'); - if (edit.element.type === 'annotation') { - expect(edit.element.index).toBe(0); - } - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/labelOffset'))).toBe(true); + + expect(edit.ops[0].path).toMatch(/^\/annotations\//); + + expect(edit.ops[0].path).toMatch(/^\/annotations\/0\//); chart.destroy(); }); @@ -1401,14 +1247,12 @@ describe('edit events', () => { simulateDrag(reflineLabel, 100, 100, 130, 115); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('refline-label'); - if (edit.type === 'refline-label') { - expect(edit.element.type).toBe('annotation'); - if (edit.element.type === 'annotation') { - expect(edit.element.index).toBe(0); - } - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/labelOffset'))).toBe(true); + + expect(edit.ops[0].path).toMatch(/^\/annotations\//); + + expect(edit.ops[0].path).toMatch(/^\/annotations\/0\//); chart.destroy(); }); @@ -1484,20 +1328,25 @@ describe('edit events', () => { const cy = Number(handle.getAttribute('cy')); simulateDrag(handle, cx, cy, cx + 30, cy + 20); - const anchorCall = onEdit.mock.calls.find( - (call: [ElementEdit]) => call[0].type === 'annotation-anchor', + const anchorCall = onEdit.mock.calls.find((call: [EditTransaction]) => + call[0].ops.some((op) => op.path.endsWith('/x')), ); expect(anchorCall).toBeDefined(); - const edit: ElementEdit = anchorCall![0]; - expect(edit.type).toBe('annotation-anchor'); - if (edit.type === 'annotation-anchor') { - expect(edit.annotation).toBeDefined(); - expect(edit.element.type).toBe('annotation'); - expect(edit.x).toBeDefined(); - expect(edit.y).toBeDefined(); - } + const edit: EditTransaction = anchorCall![0]; + expect(edit.ops.some((op) => op.path.endsWith('/x'))).toBe(true); + + expect(edit.ops[0].expect).toBeDefined(); + expect(edit.ops[0].path).toMatch(/^\/annotations\//); + expect(valueAt(edit, '/x')).toBeDefined(); + expect(valueAt(edit, '/y')).toBeDefined(); chart.destroy(); }); }); }); + +function valueAt(tx: EditTransaction, suffix: string): T { + const op = tx.ops.find((op) => op.path.endsWith(suffix)); + expect(op, `transaction operation ending in ${suffix}`).toBeDefined(); + return op!.value as T; +} diff --git a/packages/vanilla/src/__tests__/edit-transactions.test.ts b/packages/vanilla/src/__tests__/edit-transactions.test.ts index 755eaca9..65cc058f 100644 --- a/packages/vanilla/src/__tests__/edit-transactions.test.ts +++ b/packages/vanilla/src/__tests__/edit-transactions.test.ts @@ -1,10 +1,7 @@ -import { - applyEditTransaction, - type ChartSpec, - type ElementEdit, -} from '@opendata-ai/openchart-core'; +import { applyEditTransaction, type ChartSpec } from '@opendata-ai/openchart-core'; import { describe, expect, it } from 'vitest'; import { annotationSource, editToTransaction } from '../interactions/edit-transactions'; +import type { GestureEdit } from '../interactions/gesture-edit'; const annotation = { type: 'text' as const, x: 1, y: 2, text: 'Note', id: 'note' }; const spec = { mark: 'point', data: [], encoding: {}, annotations: [annotation] } as ChartSpec; @@ -86,12 +83,6 @@ describe('durable edit bridge', () => { } }); - it('keeps transient visibility outside transactions', () => { - expect( - editToTransaction(spec, { type: 'legend-toggle', series: 'A', hidden: true }), - ).toBeNull(); - }); - it('adds annotations to an absent array and removes the container on undo', () => { const source = { title: 'Chart' }; const tx = editToTransaction(source, { type: 'add', annotation }); @@ -113,7 +104,7 @@ describe('durable edit bridge', () => { const tx = editToTransaction(spec, { ...edit, element: { type: 'annotation', index: 0 }, - } as ElementEdit); + } as GestureEdit); expect(tx?.ops.length).toBeGreaterThan(0); expect(tx?.ops.every((op) => op.expect?.text === 'Note')).toBe(true); expect(applyEditTransaction(spec, tx!).ok).toBe(true); diff --git a/packages/vanilla/src/__tests__/editable-prop.test.ts b/packages/vanilla/src/__tests__/editable-prop.test.ts index f540cd13..1dfa9567 100644 --- a/packages/vanilla/src/__tests__/editable-prop.test.ts +++ b/packages/vanilla/src/__tests__/editable-prop.test.ts @@ -1,4 +1,4 @@ -import type { ChartSpec, ElementEdit } from '@opendata-ai/openchart-engine'; +import type { ChartSpec, EditTransaction } from '@opendata-ai/openchart-engine'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createContainer, createMouseEvent } from '../__test-fixtures__/dom'; import { barSpec, lineSpec } from '../__test-fixtures__/specs'; @@ -116,8 +116,8 @@ describe('editable prop', () => { svg?.dispatchEvent(deleteEvent); // No delete edit should fire - const deleteEdits = onEdit.mock.calls.filter( - (call: [ElementEdit]) => call[0].type === 'delete', + const deleteEdits = onEdit.mock.calls.filter((call: [EditTransaction]) => + call[0].ops.some((op) => op.op === 'remove'), ); expect(deleteEdits).toHaveLength(0); diff --git a/packages/vanilla/src/__tests__/events.test.ts b/packages/vanilla/src/__tests__/events.test.ts index 7ea1780a..a9b6b618 100644 --- a/packages/vanilla/src/__tests__/events.test.ts +++ b/packages/vanilla/src/__tests__/events.test.ts @@ -13,11 +13,6 @@ const annotatedSpec: ChartSpec = { annotations: [{ type: 'refline', y: 0, label: 'Zero' }], }; -const textAnnotatedSpec: ChartSpec = { - ...barSpec, - annotations: [{ type: 'text', x: 10, y: 'A', text: 'Peak', offset: { dx: 10, dy: -20 } }], -}; - // --------------------------------------------------------------------------- // Tests // --------------------------------------------------------------------------- @@ -212,148 +207,6 @@ describe('chart event handlers', () => { }); }); - describe('onAnnotationEdit', () => { - it('sets cursor:grab on text annotations when onAnnotationEdit is provided', () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - expect(annotation.style.cursor).toBe('grab'); - chart.destroy(); - }); - - it('does not set cursor:grab on non-text annotations', () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, annotatedSpec, { onAnnotationEdit }); - - const annotation = container.querySelector('.oc-annotation-refline') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - expect(annotation.style.cursor).not.toBe('grab'); - chart.destroy(); - }); - - it('fires with original annotation and updated offset after drag', () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - // Simulate drag: mousedown -> mousemove -> mouseup - annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100)); - document.dispatchEvent(createMouseEvent('mousemove', 150, 130)); - document.dispatchEvent(createMouseEvent('mouseup', 150, 130)); - - expect(onAnnotationEdit).toHaveBeenCalledTimes(1); - const [annotationArg, offsetArg] = onAnnotationEdit.mock.calls[0]; - expect(annotationArg.type).toBe('text'); - expect(annotationArg.text).toBe('Peak'); - // Original offset (10, -20) + drag delta (50, 30) = (60, 10) - expect(offsetArg.dx).toBe(60); - expect(offsetArg.dy).toBe(10); - - chart.destroy(); - }); - - it('does not fire when drag movement is below threshold', () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - // Simulate drag with minimal movement (< 3px) - annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100)); - document.dispatchEvent(createMouseEvent('mousemove', 101, 101)); - document.dispatchEvent(createMouseEvent('mouseup', 101, 101)); - - expect(onAnnotationEdit).not.toHaveBeenCalled(); - chart.destroy(); - }); - - it('suppresses click after drag', () => { - const onAnnotationClick = vi.fn(); - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { - onAnnotationClick, - onAnnotationEdit, - }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - // Drag > 3px - annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100)); - document.dispatchEvent(createMouseEvent('mousemove', 150, 130)); - document.dispatchEvent(createMouseEvent('mouseup', 150, 130)); - - // Fire a click after the drag - annotation.dispatchEvent(createMouseEvent('click', 150, 130)); - - expect(onAnnotationEdit).toHaveBeenCalledTimes(1); - // Click should have been suppressed by the one-time capture handler - expect(onAnnotationClick).not.toHaveBeenCalled(); - - chart.destroy(); - }); - - it('moves label geometry without translating the data anchor during drag', async () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); - - const annotation = container.querySelector('.oc-annotation-text') as SVGGElement | null; - if (!annotation) { - chart.destroy(); - return; - } - - const startX = Number(annotation.querySelector('.oc-annotation-label')!.getAttribute('x')); - annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100)); - document.dispatchEvent(createMouseEvent('mousemove', 140, 120)); - - await new Promise(requestAnimationFrame); - expect(annotation.hasAttribute('transform')).toBe(false); - expect(Number(annotation.querySelector('.oc-annotation-label')!.getAttribute('x'))).not.toBe( - startX, - ); - - // Complete the drag to clean up - document.dispatchEvent(createMouseEvent('mouseup', 140, 120)); - chart.destroy(); - }); - - it('cleans up listeners on destroy', () => { - const onAnnotationEdit = vi.fn(); - const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); - - chart.destroy(); - - // After destroy, dispatching events should not cause errors - document.dispatchEvent(createMouseEvent('mousemove', 200, 200)); - document.dispatchEvent(createMouseEvent('mouseup', 200, 200)); - - expect(onAnnotationEdit).not.toHaveBeenCalled(); - }); - }); - describe('cleanup', () => { it('removes event listeners on destroy', () => { const onMarkClick = vi.fn(); diff --git a/packages/vanilla/src/__tests__/public-edit-api.test.ts b/packages/vanilla/src/__tests__/public-edit-api.test.ts new file mode 100644 index 00000000..684d70f9 --- /dev/null +++ b/packages/vanilla/src/__tests__/public-edit-api.test.ts @@ -0,0 +1,19 @@ +import type { ChartEventHandlers, EditTransaction } from '@opendata-ai/openchart-core'; +import { describe, expectTypeOf, it } from 'vitest'; +import type { GraphInstance } from '../graph-mount'; +import type { MountOptions } from '../mount'; + +describe('v9 editing surface', () => { + it('exposes one transaction callback and no retired callbacks or mount aliases', () => { + expectTypeOf< + Parameters>[0] + >().toEqualTypeOf(); + expectTypeOf< + Extract< + keyof MountOptions, + 'onEditTransaction' | 'onAnnotationEdit' | 'onTextEdit' | 'onDataPointClick' | 'responsive' + > + >().toEqualTypeOf(); + expectTypeOf>().toEqualTypeOf(); + }); +}); diff --git a/packages/vanilla/src/__tests__/selection-events.test.ts b/packages/vanilla/src/__tests__/selection-events.test.ts index f7fa2c05..f225b003 100644 --- a/packages/vanilla/src/__tests__/selection-events.test.ts +++ b/packages/vanilla/src/__tests__/selection-events.test.ts @@ -1,4 +1,4 @@ -import type { ChartSpec, ElementEdit, ElementRef } from '@opendata-ai/openchart-core'; +import type { ChartSpec, EditTransaction, ElementRef } from '@opendata-ai/openchart-core'; import { elementRef } from '@opendata-ai/openchart-core'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createContainer } from '../__test-fixtures__/dom'; @@ -384,11 +384,10 @@ describe('selection events', () => { simulateKeyDown(getSvg(container), 'Delete'); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('delete'); - if (edit.type === 'delete') { - expect(edit.element.type).toBe('chrome'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.op === 'remove')).toBe(true); + + expect(edit.ops[0].path).toMatch(/^\/chrome\//); chart.destroy(); }); @@ -408,8 +407,8 @@ describe('selection events', () => { simulateKeyDown(getSvg(container), 'Backspace'); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('delete'); + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.op === 'remove')).toBe(true); chart.destroy(); }); @@ -443,8 +442,8 @@ describe('selection events', () => { simulateKeyDown(getSvg(container), 'Delete'); // onEdit should not have been called with type 'delete' - const deleteCalls = onEdit.mock.calls.filter( - (call: [ElementEdit]) => call[0].type === 'delete', + const deleteCalls = onEdit.mock.calls.filter((call: [EditTransaction]) => + call[0].ops.some((op) => op.op === 'remove'), ); expect(deleteCalls).toHaveLength(0); @@ -489,7 +488,7 @@ describe('selection events', () => { chart.destroy(); }); - it('Arrow keys cycle through editable elements when one is selected', () => { + it('Tab and Shift+Tab cycle through editable elements when one is selected', () => { const onSelect = vi.fn(); const onDeselect = vi.fn(); const chart = createChart(container, chromeOnlySpec, { onSelect, onDeselect }); @@ -508,8 +507,8 @@ describe('selection events', () => { expect(firstRef.key).toBe('title'); } - // ArrowDown should cycle to the next element (subtitle), deselecting title - simulateKeyDown(getSvg(container), 'ArrowDown'); + // Tab should cycle to the next element (subtitle), deselecting title + simulateKeyDown(getSvg(container), 'Tab'); expect(onDeselect).toHaveBeenCalledTimes(1); expect(onSelect).toHaveBeenCalledTimes(2); @@ -518,6 +517,10 @@ describe('selection events', () => { if (secondRef.type === 'chrome') { expect(secondRef.key).toBe('subtitle'); } + getSvg(container).dispatchEvent( + new KeyboardEvent('keydown', { key: 'Tab', shiftKey: true, bubbles: true }), + ); + expect(chart.getSelectedElement()).toMatchObject({ type: 'chrome', key: 'title' }); chart.destroy(); }); @@ -697,11 +700,10 @@ describe('selection events', () => { chart.destroy(); }); - it('pressing Enter in the overlay fires onEdit and onTextEdit when text changed', () => { + it('pressing Enter in the overlay fires onEdit when text changed', () => { const onSelect = vi.fn(); const onEdit = vi.fn(); - const onTextEdit = vi.fn(); - const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit }); + const chart = createChart(container, selectionSpec, { onSelect, onEdit }); const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null; if (!titleEl) { @@ -723,17 +725,12 @@ describe('selection events', () => { // Press Enter to commit textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); - expect(onTextEdit).toHaveBeenCalledTimes(1); - expect(onTextEdit.mock.calls[0][1]).toBe('GDP Growth'); // oldText - expect(onTextEdit.mock.calls[0][2]).toBe('New Title'); // newText - expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('text-edit'); - if (edit.type === 'text-edit') { - expect(edit.oldText).toBe('GDP Growth'); - expect(edit.newText).toBe('New Title'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.path.endsWith('/chrome/title'))).toBe(true); + + expect(edit.ops[0].path).toBe('/chrome/title'); + expect((edit.ops[0].value as { text: string }).text).toBe('New Title'); chart.destroy(); }); @@ -741,8 +738,7 @@ describe('selection events', () => { it('pressing Escape in the overlay cancels without firing callbacks', () => { const onSelect = vi.fn(); const onEdit = vi.fn(); - const onTextEdit = vi.fn(); - const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit }); + const chart = createChart(container, selectionSpec, { onSelect, onEdit }); const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null; if (!titleEl) { @@ -764,10 +760,8 @@ describe('selection events', () => { // Press Escape to cancel textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); - // Neither onTextEdit nor onEdit should be called for text-edit type - expect(onTextEdit).not.toHaveBeenCalled(); - const textEditCalls = onEdit.mock.calls.filter( - (call: [ElementEdit]) => call[0].type === 'text-edit', + const textEditCalls = onEdit.mock.calls.filter((call: [EditTransaction]) => + call[0].ops.some((op) => op.path.endsWith('/text') || op.path.startsWith('/chrome/')), ); expect(textEditCalls).toHaveLength(0); @@ -783,8 +777,7 @@ describe('selection events', () => { it('text edit does not fire when text has not changed', () => { const onSelect = vi.fn(); const onEdit = vi.fn(); - const onTextEdit = vi.fn(); - const chart = createChart(container, selectionSpec, { onSelect, onEdit, onTextEdit }); + const chart = createChart(container, selectionSpec, { onSelect, onEdit }); const titleEl = container.querySelector('[data-chrome-key="title"]') as SVGElement | null; if (!titleEl) { @@ -804,9 +797,8 @@ describe('selection events', () => { textarea.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); // Neither callback should fire since text is unchanged - expect(onTextEdit).not.toHaveBeenCalled(); - const textEditCalls = onEdit.mock.calls.filter( - (call: [ElementEdit]) => call[0].type === 'text-edit', + const textEditCalls = onEdit.mock.calls.filter((call: [EditTransaction]) => + call[0].ops.some((op) => op.path.endsWith('/text') || op.path.startsWith('/chrome/')), ); expect(textEditCalls).toHaveLength(0); @@ -920,14 +912,13 @@ describe('selection events', () => { textarea!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('add'); - if (edit.type === 'add') { - expect(edit.annotation.type).toBe('text'); - expect(edit.annotation.text).toBe('New annotation'); - expect(typeof edit.annotation.x).toBe('number'); - expect(typeof edit.annotation.y).toBe('number'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.op === 'add')).toBe(true); + + expect((edit.ops[0].value as { type: string }).type).toBe('text'); + expect((edit.ops[0].value as { text: string }).text).toBe('New annotation'); + expect(typeof (edit.ops[0].value as { x: unknown }).x).toBe('number'); + expect(typeof (edit.ops[0].value as { y: unknown }).y).toBe('number'); chart.destroy(); }); @@ -1048,13 +1039,12 @@ describe('selection events', () => { textarea!.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true })); expect(onEdit).toHaveBeenCalledTimes(1); - const edit: ElementEdit = onEdit.mock.calls[0][0]; - expect(edit.type).toBe('add'); - if (edit.type === 'add') { - expect(edit.annotation.type).toBe('text'); - expect(typeof edit.annotation.x).not.toBe('undefined'); - expect(typeof edit.annotation.y).not.toBe('undefined'); - } + const edit: EditTransaction = onEdit.mock.calls[0][0]; + expect(edit.ops.some((op) => op.op === 'add')).toBe(true); + + expect((edit.ops[0].value as { type: string }).type).toBe('text'); + expect(typeof (edit.ops[0].value as { x: unknown }).x).not.toBe('undefined'); + expect(typeof (edit.ops[0].value as { y: unknown }).y).not.toBe('undefined'); chart.destroy(); }); diff --git a/packages/vanilla/src/export-sequence.ts b/packages/vanilla/src/export-sequence.ts index ebed7676..0f6c05f7 100644 --- a/packages/vanilla/src/export-sequence.ts +++ b/packages/vanilla/src/export-sequence.ts @@ -116,7 +116,7 @@ function mountOffscreen( document.body.appendChild(container); // enter:false → first spec settled immediately; update:true → tween on - // beginManualUpdate. responsive:false → no ResizeObserver on a detached node. + // beginManualUpdate. autoResize:false → no ResizeObserver on a detached node. const settledSpec = settleAnimation(spec); let instance: ReturnType; try { @@ -124,7 +124,7 @@ function mountOffscreen( darkMode, theme: themeConfig, renderer, - responsive: false, + autoResize: false, }); } catch (err) { // A spec that fails to compile must not leak the offscreen container. diff --git a/packages/vanilla/src/graph-3d/mount.ts b/packages/vanilla/src/graph-3d/mount.ts index a73cdf84..219dec2a 100644 --- a/packages/vanilla/src/graph-3d/mount.ts +++ b/packages/vanilla/src/graph-3d/mount.ts @@ -1,4 +1,3 @@ -import { warnDeprecated } from '../deprecations'; /** * The 3D graph renderer: a `GraphInstance` backed by `3d-force-graph`. * @@ -1830,10 +1829,6 @@ export function createGraph3DRenderer(ctx: GraphRendererContext): GraphInstance update, // `update()` already routes an id-set-preserving change through the // material-mutation path, which never touches `graphData()`. - updateVisuals: (spec) => { - warnDeprecated('updateVisuals is deprecated; use update.'); - update(spec); - }, search, clearSearch, zoomToFit, diff --git a/packages/vanilla/src/graph-mount.ts b/packages/vanilla/src/graph-mount.ts index 29c0f3b6..64a9ea5b 100644 --- a/packages/vanilla/src/graph-mount.ts +++ b/packages/vanilla/src/graph-mount.ts @@ -1,4 +1,3 @@ -import { warnDeprecated } from './deprecations'; /** * Graph mount API: the main entry point for vanilla JS graph usage. * @@ -158,7 +157,6 @@ export interface GraphMountOptions { export interface GraphInstance { update(spec: GraphSpec): void; /** Re-compile encoding/legend/chrome without restarting the simulation. Preserves node positions. */ - updateVisuals(spec: GraphSpec): void; search(query: string): void; clearSearch(): void; /** Fit all nodes into the viewport. Animated by default; `{ duration: 0 }` snaps. */ @@ -1901,10 +1899,6 @@ export function createGraph( * node positions when nothing structural changed. Kept as an alias for backward * compatibility. */ - function updateVisuals(newSpec: GraphSpec): void { - warnDeprecated('updateVisuals is deprecated; use update.'); - update(newSpec); - } /** Tear down ONLY the simulation, keeping interaction/transform/selection. */ function teardownSimOnly(): void { @@ -2036,7 +2030,6 @@ export function createGraph( return { update, - updateVisuals, search, clearSearch, zoomToFit, diff --git a/packages/vanilla/src/graph/__tests__/graph-mount-update.test.ts b/packages/vanilla/src/graph/__tests__/graph-mount-update.test.ts index 5699f1fb..75ede0f4 100644 --- a/packages/vanilla/src/graph/__tests__/graph-mount-update.test.ts +++ b/packages/vanilla/src/graph/__tests__/graph-mount-update.test.ts @@ -396,27 +396,5 @@ describe('update() search reconciliation', () => { }); // --------------------------------------------------------------------------- -// updateVisuals alias +// Unified graph updates // --------------------------------------------------------------------------- - -describe('updateVisuals deprecated alias', () => { - it('routes through update() (structural change still reheats)', async () => { - container = makeContainer(); - const graph = createGraph(container, graphSpec(['a', 'b'], [['a', 'b']])); - await settle(); - - const createSpy = vi.spyOn(SimulationManager, 'create'); - graph.updateVisuals( - graphSpec( - ['a', 'b', 'c'], - [ - ['a', 'b'], - ['b', 'c'], - ], - ), - ); - expect(createSpy).toHaveBeenCalledTimes(1); - createSpy.mockRestore(); - graph.destroy(); - }); -}); diff --git a/packages/vanilla/src/graph/__tests__/renderer-registry.test.ts b/packages/vanilla/src/graph/__tests__/renderer-registry.test.ts index e369ae31..9bbfcb07 100644 --- a/packages/vanilla/src/graph/__tests__/renderer-registry.test.ts +++ b/packages/vanilla/src/graph/__tests__/renderer-registry.test.ts @@ -49,7 +49,6 @@ function makeContainer(): HTMLElement { function fakeInstance(): GraphInstance { return { update: () => {}, - updateVisuals: () => {}, search: () => {}, clearSearch: () => {}, getSearchMatches: () => [], diff --git a/packages/vanilla/src/interactions/annotation-nudges.ts b/packages/vanilla/src/interactions/annotation-nudges.ts index 6f25f74f..26b96d38 100644 --- a/packages/vanilla/src/interactions/annotation-nudges.ts +++ b/packages/vanilla/src/interactions/annotation-nudges.ts @@ -1,6 +1,5 @@ import type { ChartLayout, - ElementEdit, ElementRef, ResolvedAnnotation, TextAnnotation, @@ -12,6 +11,7 @@ import { previewAnnotationMove, } from '@opendata-ai/openchart-engine'; import { updateAnnotationGeometry } from '../renderers/annotations'; +import type { GestureEdit } from './gesture-edit'; import { findElementByRef } from './selection'; /** Batch keyboard repeats into one placement transaction; flush before other gestures. */ @@ -22,7 +22,7 @@ export function createAnnotationNudges( svg: SVGElement | null; selected: ElementRef | null; }, - emit: (edit: ElementEdit) => void, + emit: (edit: GestureEdit) => void, ) { let pending: | { @@ -57,7 +57,12 @@ export function createAnnotationNudges( }); }; const keydown = (event: KeyboardEvent): boolean => { - if (!event.altKey || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) + if ( + event.altKey || + event.ctrlKey || + event.metaKey || + !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key) + ) return false; const { layout, spec, svg, selected } = read(); if (!svg || selected?.type !== 'annotation') return false; @@ -90,8 +95,14 @@ export function createAnnotationNudges( layout.theme.colors.neutral.surface, ); if (timer !== undefined) clearTimeout(timer); - timer = setTimeout(flush, 120); + timer = undefined; return true; }; - return { keydown, flush }; + const keyup = (event: KeyboardEvent) => { + if (!pending || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) + return; + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(flush, 500); + }; + return { keydown, keyup, flush }; } diff --git a/packages/vanilla/src/interactions/edit-transactions.ts b/packages/vanilla/src/interactions/edit-transactions.ts index c183af7d..f84fe05f 100644 --- a/packages/vanilla/src/interactions/edit-transactions.ts +++ b/packages/vanilla/src/interactions/edit-transactions.ts @@ -1,4 +1,5 @@ -import type { EditOp, EditTransaction, ElementEdit } from '@opendata-ai/openchart-core'; +import type { EditOp, EditTransaction } from '@opendata-ai/openchart-core'; +import type { GestureEdit } from './gesture-edit'; type Node = Record; const object = (value: unknown): Node | undefined => @@ -10,10 +11,10 @@ export { annotationSource } from '@opendata-ai/openchart-core'; import { annotationSource } from '@opendata-ai/openchart-core'; -/** Bridge existing gesture events to authored operations without changing legacy callbacks. */ +/** Convert internal gesture descriptions to atomic authored operations. */ export function editToTransaction( spec: unknown, - edit: ElementEdit, + edit: GestureEdit, revision?: string | number, ): EditTransaction | null { const root = object(spec) ?? {}; @@ -21,7 +22,6 @@ export function editToTransaction( const ops: EditOp[] = []; const set = (path: string, value: unknown, expect?: EditOp['expect']) => ops.push({ op: 'set', path, value, ...(expect ? { expect } : {}) }); - if (edit.type === 'legend-toggle') return null; if (edit.type === 'add') { ops.push({ op: 'add', path: `${source.path}/-`, value: edit.annotation }); } else if ('element' in edit && edit.element.type === 'annotation') { @@ -114,9 +114,6 @@ export function editToTransaction( path: '/legend', value: { ...(object(root.legend) ?? {}), show: false }, }); - } else if (edit.type === 'series-label') { - const key = edit.series.replace(/~/g, '~0').replace(/\//g, '~1'); - ops.push({ op: 'add', path: `/labels/offsets/${key}`, value: edit.offset }); } return ops.length ? { ...(revision !== undefined ? { revision } : {}), ops } : null; } diff --git a/packages/vanilla/src/interactions/editing-drags.ts b/packages/vanilla/src/interactions/editing-drags.ts index 39ed181c..026415dc 100644 --- a/packages/vanilla/src/interactions/editing-drags.ts +++ b/packages/vanilla/src/interactions/editing-drags.ts @@ -1,10 +1,8 @@ import { type Annotation, - type AnnotationOffset, type ChartLayout, type ChartSpec, type ChromeKey, - type ElementEdit, elementRef, type GraphSpec, type RangeAnnotation, @@ -20,6 +18,7 @@ import { } from '@opendata-ai/openchart-engine'; import { updateAnnotationGeometry } from '../renderers/annotations'; import { createDragHandler } from './drag-handler'; +import type { GestureEdit } from './gesture-edit'; import { invertScale } from './invert'; import { buildElementRef } from './selection'; @@ -30,10 +29,7 @@ import { buildElementRef } from './selection'; export function wireAnnotationDrag( svg: SVGElement, specAnnotations: Annotation[], - onAnnotationEdit: - | ((annotation: TextAnnotation, updatedOffset: AnnotationOffset) => void) - | undefined, - onEdit: ((edit: ElementEdit) => void) | undefined, + onEdit: ((edit: GestureEdit) => void) | undefined, setDragging: (dragging: boolean) => void, layout: ChartLayout, spec: unknown, @@ -74,17 +70,6 @@ export function wireAnnotationDrag( if (!moved) return; const preview = previewAnnotationMove(ctx, ref, { dx, dy }); const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); - // Preserve the deprecated callback's original-reference/relative-offset contract. - onAnnotationEdit?.(authored, { - dx: - Math.round( - ((authored.offset?.dx ?? 0) + preview.label!.x - original.label!.x) * 1e10, - ) / 1e10, - dy: - Math.round( - ((authored.offset?.dy ?? 0) + preview.label!.y - original.label!.y) * 1e10, - ) / 1e10, - }); onEdit?.({ type: 'annotation', element: { ...ref, path: `${ref.path}/offset` }, @@ -109,7 +94,7 @@ export function wireAnnotationDrag( export function wireConnectorEndpointDrag( svg: SVGElement, specAnnotations: Annotation[], - onEdit: (edit: ElementEdit) => void, + onEdit: (edit: GestureEdit) => void, setDragging: (dragging: boolean) => void, layout: ChartLayout, spec: unknown, @@ -213,7 +198,7 @@ export function wireConnectorEndpointDrag( export function wireAnnotationLabelDrag( svg: SVGElement, specAnnotations: Annotation[], - onEdit: (edit: ElementEdit) => void, + onEdit: (edit: GestureEdit) => void, setDragging: (dragging: boolean) => void, ): () => void { const cleanups: Array<() => void> = []; @@ -307,7 +292,7 @@ export function wireAnnotationLabelDrag( export function wireChromeDrag( svg: SVGElement, spec: ChartSpec | GraphSpec, - onEdit: (edit: ElementEdit) => void, + onEdit: (edit: GestureEdit) => void, setDragging: (dragging: boolean) => void, ): () => void { const chromeTexts = svg.querySelectorAll('.oc-chrome text[data-chrome-key]'); @@ -370,7 +355,7 @@ export function wireChromeDrag( export function wireLegendDrag( svg: SVGElement, spec: ChartSpec | GraphSpec, - onEdit: (edit: ElementEdit) => void, + onEdit: (edit: GestureEdit) => void, setDragging: (dragging: boolean) => void, ): () => void { // The color legend: it owns `spec.legend.offset`, which is what a drag writes @@ -421,69 +406,6 @@ export function wireLegendDrag( }; } -/** - * Wire drag on series label elements (.oc-mark-label[data-series]). - * Returns a cleanup function. - */ -export function wireSeriesLabelDrag( - svg: SVGElement, - spec: ChartSpec | GraphSpec, - onEdit: (edit: ElementEdit) => void, - setDragging: (dragging: boolean) => void, -): () => void { - const labels = svg.querySelectorAll('.oc-mark-label'); - const cleanups: Array<() => void> = []; - - const rawLabels = 'labels' in spec ? spec.labels : undefined; - const labelsConfig = typeof rawLabels === 'object' ? rawLabels : undefined; - - for (const label of labels) { - const labelEl = label as SVGTextElement; - const series = - labelEl.getAttribute('data-series') ?? - labelEl.closest('[data-series]')?.getAttribute('data-series'); - if (!series) continue; - - const existingSeriesOffset = labelsConfig?.offsets?.[series]; - const origSeriesDx = existingSeriesOffset?.dx ?? 0; - const origSeriesDy = existingSeriesOffset?.dy ?? 0; - - labelEl.style.cursor = 'grab'; - - const cleanup = createDragHandler({ - element: labelEl, - svg: svg as unknown as SVGSVGElement, - onMove: (dx, dy) => { - (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = - `translate(${dx}px, ${dy}px)`; - }, - onCancel: () => { - labelEl.style.transform = ''; - }, - onEnd: (dx, dy, moved) => { - (labelEl as SVGElement & ElementCSSInlineStyle).style.transform = ''; - - if (moved) { - onEdit({ - type: 'series-label', - series, - offset: { dx: origSeriesDx + dx, dy: origSeriesDy + dy }, - }); - } - }, - setDragging, - }); - - cleanups.push(cleanup); - } - - return () => { - for (const cleanup of cleanups) { - cleanup(); - } - }; -} - /** * Wire drag on annotation anchor handles (the data-point dot). * Dragging the anchor repositions the annotation in data space, emitting @@ -493,7 +415,7 @@ export function wireAnchorDrag( svg: SVGElement, specAnnotations: Annotation[], layout: ChartLayout, - onEdit: ((edit: ElementEdit) => void) | undefined, + onEdit: ((edit: GestureEdit) => void) | undefined, setDragging: (d: boolean) => void, ): () => void { if (!onEdit || !layout.xInvert || !layout.yInvert) return () => {}; diff --git a/packages/vanilla/src/interactions/gesture-edit.ts b/packages/vanilla/src/interactions/gesture-edit.ts new file mode 100644 index 00000000..8b26e7e0 --- /dev/null +++ b/packages/vanilla/src/interactions/gesture-edit.ts @@ -0,0 +1,52 @@ +/** Internal gesture descriptions; public edits are atomic EditTransactions. */ +import type { + AnnotationOffset, + ChromeKey, + ElementRef, + RangeAnnotation, + RefLineAnnotation, + TextAnnotation, +} from '@opendata-ai/openchart-core'; + +export type GestureEdit = + | { + type: 'annotation'; + element: ElementRef; + annotation: TextAnnotation; + offset: AnnotationOffset; + /** Resolved first-drag placement. Persist together with offset when supplied. */ + anchor?: TextAnnotation['anchor']; + /** A moved annotation stays inline. */ + responsive?: false; + } + | { + type: 'annotation-connector'; + element: ElementRef; + annotation: TextAnnotation; + endpoint: 'from' | 'to'; + offset: AnnotationOffset; + } + | { + type: 'range-label'; + element: ElementRef; + annotation: RangeAnnotation; + labelOffset: AnnotationOffset; + } + | { + type: 'refline-label'; + element: ElementRef; + annotation: RefLineAnnotation; + labelOffset: AnnotationOffset; + } + | { + type: 'annotation-anchor'; + element: ElementRef; + annotation: TextAnnotation; + x: string | number; + y: string | number; + } + | { type: 'chrome'; key: ChromeKey; text: string; offset: AnnotationOffset } + | { type: 'legend'; offset: AnnotationOffset } + | { type: 'add'; annotation: TextAnnotation } + | { type: 'delete'; element: ElementRef } + | { type: 'text-edit'; element: ElementRef; oldText: string; newText: string }; diff --git a/packages/vanilla/src/interactions/index.ts b/packages/vanilla/src/interactions/index.ts index f7e7ff25..396503e9 100644 --- a/packages/vanilla/src/interactions/index.ts +++ b/packages/vanilla/src/interactions/index.ts @@ -10,7 +10,6 @@ export { wireChromeDrag, wireConnectorEndpointDrag, wireLegendDrag, - wireSeriesLabelDrag, } from './editing-drags'; export type { HoverEmphasis } from './hover-emphasis'; export { createHoverEmphasis, wireEndpointLabelHover } from './hover-emphasis'; diff --git a/packages/vanilla/src/interactions/legend-interaction.ts b/packages/vanilla/src/interactions/legend-interaction.ts index 509709f9..2010ad15 100644 --- a/packages/vanilla/src/interactions/legend-interaction.ts +++ b/packages/vanilla/src/interactions/legend-interaction.ts @@ -1,4 +1,4 @@ -import type { ChartLayout, ElementEdit } from '@opendata-ai/openchart-core'; +import type { ChartLayout } from '@opendata-ai/openchart-core'; import type { HoverEmphasis } from './hover-emphasis'; /** @@ -11,7 +11,6 @@ export function wireLegendInteraction( _layout: ChartLayout, toggleSeries: (series: string) => boolean, onLegendToggle?: (series: string, visible: boolean) => void, - onEdit?: (edit: ElementEdit) => void, emphasis?: HoverEmphasis, ): () => void { // Scoped OUT of the size legend explicitly. Clicking a legend entry toggles a @@ -34,7 +33,6 @@ export function wireLegendInteraction( const nowHidden = toggleSeries(label); onLegendToggle?.(label, !nowHidden); - onEdit?.({ type: 'legend-toggle', series: label, hidden: nowHidden }); }; const handleClick = () => toggle(); diff --git a/packages/vanilla/src/interactions/selection.ts b/packages/vanilla/src/interactions/selection.ts index 5faa2b07..04c9618a 100644 --- a/packages/vanilla/src/interactions/selection.ts +++ b/packages/vanilla/src/interactions/selection.ts @@ -34,8 +34,6 @@ export function findElementByRef(svg: SVGElement, ref: ElementRef): SVGElement | } case 'chrome': return svg.querySelector(`[data-chrome-key="${ref.key}"]`) as SVGElement | null; - case 'series-label': - return svg.querySelector(`.oc-mark-label[data-series="${ref.series}"]`) as SVGElement | null; // The editable legend is the color legend. Excluding the size legend // explicitly rather than relying on it rendering second: `querySelector` // takes the first match, so a bare `.oc-legend` would silently retarget @@ -82,13 +80,6 @@ export function buildElementRef( if (key) return elementRef.chrome(key, `/chrome/${key}`); } - const seriesLabelEl = element.closest('.oc-mark-label[data-series]'); - if (seriesLabelEl) { - const series = seriesLabelEl.getAttribute('data-series'); - if (series) - return elementRef.seriesLabel(series, `/labels/offsets/${encodePointerToken(series)}`); - } - const legendEntryEl = element.closest('[data-legend-index]'); if (legendEntryEl) { const index = Number(legendEntryEl.getAttribute('data-legend-index')); @@ -137,17 +128,6 @@ export function getEditableElements( refs.push(elementRef.annotation(i, annotations[i].id, `${path}/${i}`)); } - const seriesLabels: string[] = []; - for (const mark of layout.marks) { - if (mark.type === 'line' && mark.label?.visible && mark.seriesKey) { - seriesLabels.push(mark.seriesKey); - } - } - seriesLabels.sort(); - for (const series of seriesLabels) { - refs.push(elementRef.seriesLabel(series)); - } - if ('entries' in layout.legend && layout.legend.entries.length > 0) { refs.push(elementRef.legend()); } @@ -208,8 +188,6 @@ export function refsEqual(a: ElementRef | null, b: ElementRef | null): boolean { } case 'chrome': return a.key === (b as typeof a).key; - case 'series-label': - return a.series === (b as typeof a).series; case 'legend': return true; case 'legend-entry': { diff --git a/packages/vanilla/src/mount.ts b/packages/vanilla/src/mount.ts index ad64c8d5..29bf650f 100644 --- a/packages/vanilla/src/mount.ts +++ b/packages/vanilla/src/mount.ts @@ -1,6 +1,6 @@ -import { warnDeprecated } from './deprecations'; import { createAnnotationNudges } from './interactions/annotation-nudges'; import { annotationSource, editToTransaction } from './interactions/edit-transactions'; +import type { GestureEdit } from './interactions/gesture-edit'; import { canTransitionSpecShape } from './transition/gate'; import { reconcileSVG } from './transition/reconcile'; import { sampleLayout } from './transition/scene'; @@ -21,7 +21,6 @@ import type { CompileOptions, DarkMode, DataRow, - ElementEdit, ElementRef, GraphSpec, LayerSpec, @@ -75,7 +74,6 @@ import { wireKeyboardNav, wireLegendDrag, wireLegendInteraction, - wireSeriesLabelDrag, wireTooltipEvents, wireVoronoiTooltipEvents, } from './interactions'; @@ -109,11 +107,6 @@ export interface MountOptions extends ChartEventHandlers { * console warning for non-point marks and faceted/layered/sparkline charts. */ renderer?: 'auto' | 'svg' | 'canvas'; - /** Callback when a data point is clicked. @deprecated Use onMarkClick instead. */ - onDataPointClick?: (data: Record) => void; - /** Enable responsive resizing. Defaults to true. */ - /** @deprecated Use autoResize. */ - responsive?: boolean; /** Observe container resizing. Defaults to true. */ autoResize?: boolean; /** Show the OpenData watermark. Defaults to true. */ @@ -218,13 +211,7 @@ function makeEditable(svg: SVGElement): void { } function hasEditingCallbacks(opts?: MountOptions): boolean { - return !!( - opts?.onEditTransaction || - opts?.onEdit || - opts?.onSelect || - opts?.onDeselect || - opts?.onTextEdit - ); + return !!(opts?.onEdit || opts?.onSelect || opts?.onDeselect); } // --------------------------------------------------------------------------- @@ -613,14 +600,6 @@ export function createChart( // Selection & text editing // --------------------------------------------------------------------------- - if (options?.onDataPointClick) - warnDeprecated('onDataPointClick is deprecated and has no effect; use onMarkClick.'); - if (options?.onAnnotationEdit) - warnDeprecated('onAnnotationEdit is deprecated; use onEditTransaction.'); - if (options?.onTextEdit) warnDeprecated('onTextEdit is deprecated; use onEditTransaction.'); - if (options?.responsive !== undefined) - warnDeprecated('The mount responsive option is deprecated; use autoResize.'); - let editRevision = options?.revision; const annotationNudges = createAnnotationNudges( () => ({ @@ -632,17 +611,11 @@ export function createChart( emitEdit, ); - function emitEdit(edit: ElementEdit): void { + function emitEdit(edit: GestureEdit): void { const tx = editToTransaction(currentSpec, edit, editRevision); - if (!tx && edit.type !== 'legend-toggle') return; - if (tx && !applyEditTransaction(currentSpec, tx).ok) return; - if ('element' in edit && edit.element.type === 'annotation') { - const base = `${annotationSource(currentSpec).path}/${edit.element.index}`; - const suffix = edit.element.path?.replace(/^.*\/annotations\/\d+/, '') ?? ''; - edit = { ...edit, element: { ...edit.element, path: base + suffix } }; - } - if (tx) options?.onEditTransaction?.(tx); - options?.onEdit?.(edit); + if (!tx) return; + if (!applyEditTransaction(currentSpec, tx).ok) return; + options?.onEdit?.(tx); } function getSpecAnnotations(): Annotation[] { @@ -689,6 +662,7 @@ export function createChart( } function enterTextEditing(): void { + annotationNudges.flush(); if (!svgElement || !selectedElement || isTextEditingActive) return; const specAnnotations = getSpecAnnotations(); @@ -716,7 +690,6 @@ export function createChart( textEditCleanup = null; if (newText !== currentText) { - options?.onTextEdit?.(editRef, currentText, newText); emitEdit({ type: 'text-edit', element: editRef, @@ -797,12 +770,7 @@ export function createChart( } // Double-click on empty canvas: create a new annotation - if ( - !ref && - (options?.onEdit || options?.onEditTransaction) && - currentLayout?.xInvert && - currentLayout?.yInvert - ) { + if (!ref && options?.onEdit && currentLayout?.xInvert && currentLayout?.yInvert) { const svgEl = svg as SVGSVGElement; const viewBox = svgEl.viewBox?.baseVal; const svgRect = svgEl.getBoundingClientRect(); @@ -883,31 +851,18 @@ export function createChart( break; } - case 'ArrowDown': - case 'ArrowRight': { - if (!isTextEditingActive && selectedElement) { - e.preventDefault(); + case 'Tab': { + if (!isTextEditingActive) { const editables = getEditableElements(currentSpec, currentLayout); - if (editables.length === 0) break; - - const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement)); - const nextIndex = currentIndex >= editables.length - 1 ? 0 : currentIndex + 1; - - selectElement(editables[nextIndex]); - } - break; - } - - case 'ArrowUp': - case 'ArrowLeft': { - if (!isTextEditingActive && selectedElement) { + if (!editables.length) break; e.preventDefault(); - const editables = getEditableElements(currentSpec, currentLayout); - if (editables.length === 0) break; - + annotationNudges.flush(); const currentIndex = editables.findIndex((r) => refsEqual(r, selectedElement)); - const nextIndex = currentIndex <= 0 ? editables.length - 1 : currentIndex - 1; - + const nextIndex = e.shiftKey + ? currentIndex <= 0 + ? editables.length - 1 + : currentIndex - 1 + : (currentIndex + 1) % editables.length; selectElement(editables[nextIndex]); } break; @@ -927,7 +882,8 @@ export function createChart( svg.addEventListener('keydown', handleKeyDown); const flushNudges = () => annotationNudges.flush(); - svg.addEventListener('keyup', flushNudges); + const releaseNudge = (event: KeyboardEvent) => annotationNudges.keyup(event); + svg.addEventListener('keyup', releaseNudge); const startGesture = (event: MouseEvent | TouchEvent) => { const target = event.target as Element; const ref = buildElementRef(target, getSpecAnnotations()); @@ -972,7 +928,7 @@ export function createChart( return () => { svg.removeEventListener('keydown', handleKeyDown); - svg.removeEventListener('keyup', flushNudges); + svg.removeEventListener('keyup', releaseNudge); svg.removeEventListener('mousedown', startGesture, true); svg.removeEventListener('touchstart', startGesture, true); document.removeEventListener('pointercancel', flushNudges, true); @@ -1073,10 +1029,7 @@ export function createChart( // Point marks are pixels on a canvas, not selectable elements, so // per-mark edit selection is unavailable. Annotation, chrome, and legend // editing still work -- those stay in the SVG. Warned once per mount. - if ( - !warnedCanvasEditMode && - (options?.editable || hasEditingCallbacks(options) || options?.onAnnotationEdit) - ) { + if (!warnedCanvasEditMode && (options?.editable || hasEditingCallbacks(options))) { warnedCanvasEditMode = true; console.warn( 'openchart: canvas mark mode does not support per-mark edit selection. ' + @@ -1139,7 +1092,9 @@ export function createChart( // Wire keyboard navigation. Skipped when the author hid the chart from // assistive technology (a11y.hidden): a hidden chart must not be a tab stop. - if (!currentLayout.a11y.hidden) { + const keyboardEditing = + !wantsSeriesSearch(currentSpec) && (options?.editable ?? hasEditingCallbacks(options)); + if (!currentLayout.a11y.hidden && !keyboardEditing) { cleanupKeyboardNav = wireKeyboardNav( svgElement, container, @@ -1157,7 +1112,7 @@ export function createChart( if ( editSuppressed && !editSuppressWarned && - (options?.editable || hasEditingCallbacks(options) || options?.onAnnotationEdit) + (options?.editable || hasEditingCallbacks(options)) ) { editSuppressWarned = true; console.warn( @@ -1186,7 +1141,6 @@ export function createChart( currentLayout, toggleSeriesVisibility, options?.onLegendToggle, - editSuppressed ? undefined : emitEdit, hoverEmphasis, ); @@ -1214,15 +1168,10 @@ export function createChart( const dragAnnotations = getSpecAnnotations(); // Wire annotation drag editing - if ( - !editSuppressed && - (options?.editable ?? - Boolean(options?.onAnnotationEdit || options?.onEdit || options?.onEditTransaction)) - ) { + if (!editSuppressed && (options?.editable ?? Boolean(options?.onEdit))) { cleanupAnnotationDrag = wireAnnotationDrag( svgElement, dragAnnotations, - options?.onAnnotationEdit, emitEdit, setDragging, currentLayout, @@ -1231,10 +1180,7 @@ export function createChart( } // Wire all edit drag handlers when onEdit is provided (or editable is true) - if ( - !editSuppressed && - (options?.editable ?? Boolean(options?.onEdit || options?.onEditTransaction)) - ) { + if (!editSuppressed && (options?.editable ?? Boolean(options?.onEdit))) { const editCleanups: Array<() => void> = []; const onEditFn = emitEdit; @@ -1261,7 +1207,6 @@ export function createChart( const editSpec = currentSpec as ChartSpec | GraphSpec; editCleanups.push(wireChromeDrag(svgElement, editSpec, onEditFn, setDragging)); editCleanups.push(wireLegendDrag(svgElement, editSpec, onEditFn, setDragging)); - editCleanups.push(wireSeriesLabelDrag(svgElement, editSpec, onEditFn, setDragging)); cleanupEditDrags = () => { for (const cleanup of editCleanups) { @@ -1733,7 +1678,7 @@ export function createChart( container.dataset.ocFontsState = pending ? 'pending' : 'ready'; // Set up responsive resize - if ((options?.autoResize ?? options?.responsive) !== false) { + if (options?.autoResize !== false) { disconnectResize = observeResize(container, (width, height) => { const widthChanged = lastRenderedWidth === null || Math.abs(width - lastRenderedWidth) >= 0.5; const selfInducedHeight = diff --git a/packages/vanilla/src/story/create-chart-story.ts b/packages/vanilla/src/story/create-chart-story.ts index 17c235e1..608e0dcc 100644 --- a/packages/vanilla/src/story/create-chart-story.ts +++ b/packages/vanilla/src/story/create-chart-story.ts @@ -127,7 +127,7 @@ function morphWithinMarkCap( * the base spec, then drives it through cumulative patch steps as the * reader scrolls (or via `goTo`). * - * `editMode` (any of `onEdit`/`onSelect`/`onDeselect`/`onTextEdit` on + * `editMode` (any of `onEdit`/`onSelect`/`onDeselect` on * `mountOptions`) and an active story are mutually exclusive in v1: passing * both warns once and disables the story's own drive loop, matching the * seriesSearch/edit-mode precedent in `mount.ts`. @@ -160,8 +160,7 @@ export function createChartStory( const editModeRequested = !!( mountOptions?.onEdit || mountOptions?.onSelect || - mountOptions?.onDeselect || - mountOptions?.onTextEdit + mountOptions?.onDeselect ); if (editModeRequested) { console.warn( @@ -183,7 +182,7 @@ export function createChartStory( theme: mountOptions?.theme, darkMode: mountOptions?.darkMode, watermark: mountOptions?.watermark, - responsive: mountOptions?.responsive, + responsive: mountOptions?.autoResize, }; instance = createGeoMap(container, initialSpec as GeoMapSpec, mapOpts); } else { diff --git a/packages/vue/src/Chart.ts b/packages/vue/src/Chart.ts index 8ac76641..415cc45e 100644 --- a/packages/vue/src/Chart.ts +++ b/packages/vue/src/Chart.ts @@ -8,16 +8,13 @@ import type { Annotation, - AnnotationOffset, ChartSpec, DarkMode, EditTransaction, - ElementEdit, ElementRef, GraphSpec, LayerSpec, MarkEvent, - TextAnnotation, ThemeConfig, } from '@opendata-ai/openchart-core'; import { mergeThemeConfig } from '@opendata-ai/openchart-core'; @@ -43,10 +40,8 @@ export interface ChartProps { /** Rendering backend for point marks; see the vanilla `MountOptions.renderer`. */ renderer?: 'auto' | 'svg' | 'canvas'; autoResize?: boolean; - /** @deprecated Use autoResize. */ - responsive?: boolean; revision?: string | number; - onEditTransaction?: (tx: EditTransaction) => void; + onEdit?: (tx: EditTransaction) => void; class?: string; style?: string | CSSProperties; } @@ -73,12 +68,11 @@ export const Chart = defineComponent({ // A declared listener prop makes presence reactive. Vue excludes declared // emits listeners from update checks, which would leave editing enabled // when a parent removes its transaction listener. - onEditTransaction: { + onEdit: { type: Function as PropType<(tx: EditTransaction) => void>, default: undefined, }, autoResize: { type: Boolean, default: undefined }, - responsive: { type: Boolean, default: undefined }, revision: { type: [String, Number] as PropType, default: undefined }, class: { type: String, @@ -107,15 +101,8 @@ export const Chart = defineComponent({ 'mark-leave': () => true, 'legend-toggle': (_series: string, _visible: boolean) => true, 'annotation-click': (_annotation: Annotation, _event: MouseEvent, _element: ElementRef) => true, - /** @deprecated Use edit-transaction. */ - 'annotation-edit': (_annotation: TextAnnotation, _updatedOffset: AnnotationOffset) => true, - edit: (_edit: ElementEdit) => true, select: (_element: ElementRef) => true, deselect: (_element: ElementRef) => true, - /** @deprecated Use edit-transaction. */ - 'text-edit': (_element: ElementRef, _oldText: string, _newText: string) => true, - /** @deprecated Has no effect; use mark-click. */ - 'data-point-click': (_data: Record) => true, }, setup(props, { emit, expose }) { const containerRef = ref(null); @@ -128,16 +115,11 @@ export const Chart = defineComponent({ // Check which event listeners are bound by the parent. // Vue 3 passes listeners as onXxx props on the component vnode. - // Legacy listener gates retain their 8.x setup behavior; transaction - // listener presence is a reactive prop below. + // Edit listener presence is a reactive prop below. const vm = getCurrentInstance(); const vnodeProps = vm?.vnode.props ?? {}; - const hasAnnotationEditListener = - 'onAnnotation-edit' in vnodeProps || 'onAnnotationEdit' in vnodeProps; - const hasEditListener = 'onEdit' in vnodeProps; const hasSelectListener = 'onSelect' in vnodeProps; const hasDeselectListener = 'onDeselect' in vnodeProps; - const hasTextEditListener = 'onText-edit' in vnodeProps || 'onTextEdit' in vnodeProps; function resolveTheme(): ThemeConfig | undefined { return mergeThemeConfig(contextTheme?.value, props.theme); @@ -155,9 +137,6 @@ export const Chart = defineComponent({ theme: resolveTheme(), darkMode: resolveDarkMode(), renderer: props.renderer, - ...('onDataPointClick' in vnodeProps || 'onData-point-click' in vnodeProps - ? { onDataPointClick: (data: Record) => emit('data-point-click', data) } - : {}), onMarkClick: (event: MarkEvent) => emit('mark-click', event), onMarkHover: (event: MarkEvent) => emit('mark-hover', event), onMarkLeave: () => emit('mark-leave'), @@ -167,33 +146,17 @@ export const Chart = defineComponent({ emit('annotation-click', annotation, event, element), // Only include editing callbacks when the parent binds a listener. // Without this gate, every chart gets drag editing wired up. - ...(hasAnnotationEditListener - ? { - onAnnotationEdit: (annotation: TextAnnotation, updatedOffset: AnnotationOffset) => - emit('annotation-edit', annotation, updatedOffset), - } - : {}), - ...(hasEditListener ? { onEdit: (edit: ElementEdit) => emit('edit', edit) } : {}), ...(hasSelectListener ? { onSelect: (element: ElementRef) => emit('select', element) } : {}), ...(hasDeselectListener ? { onDeselect: (element: ElementRef) => emit('deselect', element) } : {}), - ...(hasTextEditListener - ? { - onTextEdit: (element: ElementRef, oldText: string, newText: string) => - emit('text-edit', element, oldText, newText), - } - : {}), ...(props.editable != null ? { editable: props.editable } : {}), ...(props.selectedElement ? { selectedElement: props.selectedElement } : {}), autoResize: props.autoResize, - responsive: props.responsive, revision: props.revision, - ...(props.onEditTransaction - ? { onEditTransaction: (tx: EditTransaction) => vm?.emit('edit-transaction', tx) } - : {}), + ...(props.onEdit ? { onEdit: (tx: EditTransaction) => props.onEdit?.(tx) } : {}), }; instance = createChart(container, props.spec, options); @@ -278,9 +241,8 @@ export const Chart = defineComponent({ // Recreate chart when theme, darkMode, renderer, or editable change watch( [ - () => Boolean(props.onEditTransaction), + () => Boolean(props.onEdit), () => props.autoResize, - () => props.responsive, () => props.theme, () => props.darkMode, () => props.renderer, diff --git a/packages/vue/src/Graph.ts b/packages/vue/src/Graph.ts index 817442e1..f3c0b79f 100644 --- a/packages/vue/src/Graph.ts +++ b/packages/vue/src/Graph.ts @@ -233,9 +233,6 @@ export const Graph = defineComponent({ getActiveCategories(): string[] { return instance?.getActiveCategories() ?? []; }, - updateVisuals(spec: GraphSpec) { - instance?.updateVisuals(spec); - }, get instance() { return instance; }, diff --git a/packages/vue/src/__tests__/Chart.test.ts b/packages/vue/src/__tests__/Chart.test.ts index 9e0ae7e2..54d0ce35 100644 --- a/packages/vue/src/__tests__/Chart.test.ts +++ b/packages/vue/src/__tests__/Chart.test.ts @@ -162,7 +162,7 @@ describe('annotation transactions', () => { editable: true, autoResize: false, revision: 'one', - 'onEdit-transaction': (tx: unknown) => transactions.push(tx), + onEdit: (tx: unknown) => transactions.push(tx), }, }); await flushPromises(); @@ -200,13 +200,13 @@ it('reacts to transaction listener presence without enabling all charts for edit const cursor = () => (wrapper.find('[data-annotation-index="0"]').element as SVGElement).style.cursor; expect(cursor()).not.toBe('grab'); - await wrapper.setProps({ 'onEdit-transaction': (tx: unknown) => transactions.push(tx) }); + await wrapper.setProps({ onEdit: (tx: unknown) => transactions.push(tx) }); await flushPromises(); expect(cursor()).toBe('grab'); await wrapper.find('[data-annotation-index="0"]').trigger('click'); await wrapper.find('svg').trigger('keydown', { key: 'Delete' }); expect(transactions).toHaveLength(1); - await wrapper.setProps({ 'onEdit-transaction': undefined }); + await wrapper.setProps({ onEdit: undefined }); await flushPromises(); expect(cursor()).not.toBe('grab'); wrapper.unmount(); diff --git a/packages/vue/src/__tests__/Graph.dimensions.test.ts b/packages/vue/src/__tests__/Graph.dimensions.test.ts index f5e6968e..28c66091 100644 --- a/packages/vue/src/__tests__/Graph.dimensions.test.ts +++ b/packages/vue/src/__tests__/Graph.dimensions.test.ts @@ -12,7 +12,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const instanceStub = { update: vi.fn(), - updateVisuals: vi.fn(), search: vi.fn(), clearSearch: vi.fn(), getSearchMatches: vi.fn(() => []), diff --git a/packages/vue/src/__tests__/Graph.suppress-entrance.test.ts b/packages/vue/src/__tests__/Graph.suppress-entrance.test.ts index bacfb5ef..316ee39c 100644 --- a/packages/vue/src/__tests__/Graph.suppress-entrance.test.ts +++ b/packages/vue/src/__tests__/Graph.suppress-entrance.test.ts @@ -11,7 +11,6 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; const instanceStub = { update: vi.fn(), - updateVisuals: vi.fn(), search: vi.fn(), clearSearch: vi.fn(), getSearchMatches: vi.fn(() => ['a']), diff --git a/packages/vue/src/composables/useChart.ts b/packages/vue/src/composables/useChart.ts index 2d1d3f7f..01381dd9 100644 --- a/packages/vue/src/composables/useChart.ts +++ b/packages/vue/src/composables/useChart.ts @@ -21,10 +21,8 @@ export interface UseChartOptions { theme?: ThemeConfig; /** Dark mode setting. */ darkMode?: DarkMode; - /** Data point click handler. */ - onDataPointClick?: MountOptions['onDataPointClick']; - /** Enable responsive resizing. Defaults to true. */ - responsive?: boolean; + /** Observe container resizes. Defaults to true. */ + autoResize?: boolean; /** Rendering backend for point marks; see the vanilla `MountOptions.renderer`. */ renderer?: MountOptions['renderer']; } @@ -59,8 +57,7 @@ export function useChart( const mountOpts: MountOptions = { theme: options?.theme, darkMode: options?.darkMode, - onDataPointClick: options?.onDataPointClick, - responsive: options?.responsive, + autoResize: options?.autoResize, renderer: options?.renderer, }; diff --git a/packages/vue/src/composables/useGraph.ts b/packages/vue/src/composables/useGraph.ts index 24631e47..84966403 100644 --- a/packages/vue/src/composables/useGraph.ts +++ b/packages/vue/src/composables/useGraph.ts @@ -6,7 +6,6 @@ * graph instance. The full vanilla GraphInstance API is threaded through. */ -import type { GraphSpec } from '@opendata-ai/openchart-core'; import type { CameraFlightOptions, GraphCamera, @@ -40,7 +39,6 @@ export interface GraphHandle { setActiveCategories: (values: string[]) => void; getActiveCategories: () => string[]; /** Re-compile encoding/legend/chrome without restarting the simulation. */ - updateVisuals: (spec: GraphSpec) => void; /** The underlying GraphInstance from the vanilla adapter. */ instance: GraphInstance | null; } From 0659aac98bd68df978d0ef9f960863374d8973f3 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 15:34:09 -0500 Subject: [PATCH 05/17] fix: preserve annotation click targets before a drag preview --- .../src/__tests__/annotation-gestures.test.ts | 11 +++++++++++ packages/vanilla/src/interactions/editing-drags.ts | 14 +++++++++++--- 2 files changed, 22 insertions(+), 3 deletions(-) diff --git a/packages/vanilla/src/__tests__/annotation-gestures.test.ts b/packages/vanilla/src/__tests__/annotation-gestures.test.ts index f9b71bba..4e7a8253 100644 --- a/packages/vanilla/src/__tests__/annotation-gestures.test.ts +++ b/packages/vanilla/src/__tests__/annotation-gestures.test.ts @@ -245,3 +245,14 @@ it('flushes a pending nudge before opening the text editor', () => { expect(m.transactions).toHaveLength(1); expect(m.container.querySelector('textarea, input, [contenteditable]')).not.toBeNull(); }); + +it('keeps the clicked text node connected through an unmoved pointer gesture', () => { + const m = mount(); + const text = m.group().querySelector('.oc-annotation-label')!; + const line = text.firstChild!; + text.dispatchEvent(createMouseEvent('mousedown', 100, 100)); + document.dispatchEvent(createMouseEvent('mouseup', 100, 100)); + expect(line.isConnected).toBe(true); + expect(text.firstChild).toBe(line); + expect(m.transactions).toHaveLength(0); +}); diff --git a/packages/vanilla/src/interactions/editing-drags.ts b/packages/vanilla/src/interactions/editing-drags.ts index 026415dc..f99d5b4c 100644 --- a/packages/vanilla/src/interactions/editing-drags.ts +++ b/packages/vanilla/src/interactions/editing-drags.ts @@ -49,8 +49,14 @@ export function wireAnnotationDrag( } if (!initial.label?.visible || initial.footnoteIndex !== undefined) continue; const original = initial; - const restore = () => + let previewed = false; + const restore = () => { + // Preserve the click target when a pointer gesture never changed geometry. + // Replacing label children on mouseup prevents the browser's click bubbling. + if (!previewed) return; updateAnnotationGeometry(group, original, layout.theme.colors.neutral.surface); + previewed = false; + }; group.style.cursor = 'grab'; cleanups.push( createDragHandler({ @@ -58,12 +64,14 @@ export function wireAnnotationDrag( svg: svg as SVGSVGElement, setDragging, animationFrame: true, - onMove: (dx, dy) => + onMove: (dx, dy) => { + previewed = true; updateAnnotationGeometry( group, previewAnnotationMove(ctx, ref, { dx, dy }), layout.theme.colors.neutral.surface, - ), + ); + }, onCancel: restore, onEnd: (dx, dy, moved) => { restore(); From ee0a0c6c29ea0f3272ea0b0facdc24dbe0e8d080 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 15:34:45 -0500 Subject: [PATCH 06/17] feat: expose unified graph updates through framework handles --- packages/react/src/Graph.tsx | 3 +++ packages/react/src/hooks/useGraph.ts | 4 +++- packages/svelte/src/Graph.svelte | 3 +++ packages/vanilla/src/mount.ts | 2 +- packages/vue/src/Graph.ts | 3 +++ packages/vue/src/composables/useGraph.ts | 4 +++- 6 files changed, 16 insertions(+), 3 deletions(-) diff --git a/packages/react/src/Graph.tsx b/packages/react/src/Graph.tsx index 5782a357..c97175f0 100644 --- a/packages/react/src/Graph.tsx +++ b/packages/react/src/Graph.tsx @@ -273,6 +273,9 @@ export const Graph = forwardRef(function Graph( getActiveCategories() { return graphRef.current?.getActiveCategories() ?? []; }, + update(spec: GraphSpec) { + graphRef.current?.update(spec); + }, get instance() { return graphRef.current; }, diff --git a/packages/react/src/hooks/useGraph.ts b/packages/react/src/hooks/useGraph.ts index 110b6bcd..f43a1b6a 100644 --- a/packages/react/src/hooks/useGraph.ts +++ b/packages/react/src/hooks/useGraph.ts @@ -1,3 +1,4 @@ +import type { GraphSpec } from '@opendata-ai/openchart-core'; /** * useGraph: hook for imperative graph control. * @@ -81,7 +82,8 @@ export interface GraphHandle { getLegend: () => GraphLegendData | null; setActiveCategories: (values: string[]) => void; getActiveCategories: () => string[]; - /** Re-compile encoding/legend/chrome without restarting the simulation. */ + /** Update graph data, encoding, or presentation through the unified update path. */ + update: (spec: GraphSpec) => void; /** The underlying GraphInstance from the vanilla adapter. */ instance: GraphInstance | null; } diff --git a/packages/svelte/src/Graph.svelte b/packages/svelte/src/Graph.svelte index 25f571a5..074e2f49 100644 --- a/packages/svelte/src/Graph.svelte +++ b/packages/svelte/src/Graph.svelte @@ -243,6 +243,9 @@ export function setActiveCategories(values: string[]): void { export function getActiveCategories(): string[] { return instance?.getActiveCategories() ?? []; } +export function update(nextSpec: GraphSpec): void { + instance?.update(nextSpec); +}
( const handleMouseEnter = (e: Event) => { const target = (e.target as Element).closest( - '[data-annotation-index], [data-chrome-key], .oc-mark-label[data-series], .oc-legend:not(.oc-legend--size), [data-legend-index]', + '[data-annotation-index], [data-chrome-key], .oc-legend:not(.oc-legend--size), [data-legend-index]', ); if (target) { (target as SVGElement).classList.add('oc-editable-hover'); diff --git a/packages/vue/src/Graph.ts b/packages/vue/src/Graph.ts index f3c0b79f..4e1c7c66 100644 --- a/packages/vue/src/Graph.ts +++ b/packages/vue/src/Graph.ts @@ -233,6 +233,9 @@ export const Graph = defineComponent({ getActiveCategories(): string[] { return instance?.getActiveCategories() ?? []; }, + update(spec: GraphSpec) { + instance?.update(spec); + }, get instance() { return instance; }, diff --git a/packages/vue/src/composables/useGraph.ts b/packages/vue/src/composables/useGraph.ts index 84966403..9f75e156 100644 --- a/packages/vue/src/composables/useGraph.ts +++ b/packages/vue/src/composables/useGraph.ts @@ -1,3 +1,4 @@ +import type { GraphSpec } from '@opendata-ai/openchart-core'; /** * useGraph: composable for imperative graph control. * @@ -38,7 +39,8 @@ export interface GraphHandle { getLegend: () => GraphLegendData | null; setActiveCategories: (values: string[]) => void; getActiveCategories: () => string[]; - /** Re-compile encoding/legend/chrome without restarting the simulation. */ + /** Update graph data, encoding, or presentation through the unified update path. */ + update: (spec: GraphSpec) => void; /** The underlying GraphInstance from the vanilla adapter. */ instance: GraphInstance | null; } From deb528de2ed8a11f60e3042b1dbd437258d36670 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 15:48:09 -0500 Subject: [PATCH 07/17] feat!: adopt v9 chart defaults and annotation UX --- CHANGELOG.md | 64 + README.md | 3 +- docs/agent-patterns.md | 6 +- docs/architecture.md | 33 +- docs/chart-types.md | 12 +- docs/codebase-map.md | 71 +- docs/dashboards.md | 2 +- docs/design-evolution.md | 39 +- docs/design-system.md | 40 +- docs/getting-started.md | 10 +- docs/integration-guide.md | 26 +- docs/migrating-v9.md | 192 +++ docs/spec-reference.md | 268 ++-- docs/tables.md | 1 - docs/theming-tokens.md | 24 +- e2e/annotation-invariants/curves.spec.ts | 3 + e2e/annotation-invariants/layout.spec.ts | 214 ++- e2e/annotation-invariants/notes.spec.ts | 71 + e2e/visual/stories-mobile.spec.ts | 5 + e2e/visual/stories.spec.ts | 5 + .../src/__tests__/migration-parity.test.ts | 16 +- .../__tests__/v9-editorial-examples.test.ts | 100 ++ .../src/gallery/charts-bar-column.stories.tsx | 18 +- .../src/gallery/charts-line-area.stories.tsx | 26 +- .../gallery/features-annotations.stories.tsx | 29 +- .../gallery/features-edit-mode.stories.tsx | 9 +- examples/src/gallery/showcase.stories.tsx | 32 +- examples/src/gallery/welcome.stories.tsx | 14 +- .../src/testing/fixtures-bars.stories.tsx | 12 +- .../src/testing/fixtures-lines.stories.tsx | 4 +- llms-full.txt | 352 ++--- packages/core/schema/chart.schema.json | 199 +-- packages/core/schema/table.schema.json | 120 +- packages/core/schema/vizspec.schema.json | 394 ++--- .../helpers/__tests__/spec-builders.test.ts | 41 +- packages/core/src/helpers/spec-builders.ts | 97 +- packages/core/src/index.ts | 3 - .../layout/__tests__/note-band-compat.test.ts | 18 +- packages/core/src/layout/chrome.ts | 17 - packages/core/src/layout/index.ts | 1 - packages/core/src/responsive/index.ts | 2 - packages/core/src/responsive/metrics.ts | 6 - packages/core/src/styles/interaction.css | 14 + packages/core/src/styles/table.css | 6 +- packages/core/src/types/layout.ts | 9 +- packages/core/src/types/spec.ts | 132 +- .../engine/src/__test-fixtures__/specs.ts | 6 +- .../src/__test-fixtures__/v8-styling.json | 1335 +++++++++++++++++ .../compile-snapshot.test.ts.snap | 530 +++---- .../src/__tests__/annotation-behavior.test.ts | 11 +- packages/engine/src/__tests__/axes.test.ts | 4 +- .../src/__tests__/compile-footnotes.test.ts | 25 +- .../src/__tests__/facet-grow-opt-in.test.ts | 16 +- .../engine/src/__tests__/known-bugs.test.ts | 17 +- .../src/__tests__/layout-invariants.test.ts | 40 +- .../engine/src/__tests__/mark-keys.test.ts | 2 +- .../src/__tests__/migrate-spec-v8.test.ts | 118 +- .../src/__tests__/series-color-parity.test.ts | 62 +- .../engine/src/__tests__/spec-sugar.test.ts | 136 +- .../track-a-wrapping-and-dual-axis.test.ts | 2 +- .../src/annotations/__tests__/compute.test.ts | 8 +- .../__tests__/connector-crossings.test.ts | 36 + .../__tests__/layout-opt-ins.test.ts | 24 +- .../__tests__/resolve-text.test.ts | 21 +- .../annotations/__tests__/thinning.test.ts | 20 +- .../annotations/__tests__/v9-defaults.test.ts | 149 ++ packages/engine/src/annotations/compute.ts | 62 +- .../src/annotations/connector-crossings.ts | 128 ++ packages/engine/src/annotations/constants.ts | 2 +- packages/engine/src/annotations/notes.ts | 40 +- packages/engine/src/annotations/placement.ts | 22 +- .../engine/src/annotations/resolve-text.ts | 13 +- packages/engine/src/annotations/thinning.ts | 11 +- .../barlist/__tests__/compile-barlist.test.ts | 11 +- .../engine/src/barlist/compile-barlist.ts | 2 +- packages/engine/src/barlist/types.ts | 3 - .../src/charts/line/__tests__/compute.test.ts | 123 -- .../src/charts/line/__tests__/labels.test.ts | 94 -- packages/engine/src/charts/line/index.ts | 24 - packages/engine/src/charts/line/labels.ts | 177 --- .../charts/waffle/__tests__/compute.test.ts | 5 +- packages/engine/src/compile.ts | 54 +- .../compile/__tests__/fill-patterns.test.ts | 2 +- packages/engine/src/compile/layer.ts | 4 +- packages/engine/src/compile/spec-sugar.ts | 84 +- .../src/compiler/__tests__/compile.test.ts | 2 +- .../fixtures/vl-parity/layer-theta.json | 49 +- .../fixtures/vl-parity/theta-arc.json | 49 +- .../__tests__/mark-render-mode.test.ts | 28 +- .../src/compiler/__tests__/normalize.test.ts | 16 +- .../__tests__/v9-removed-fields.test.ts | 45 + .../src/compiler/__tests__/validate.test.ts | 15 +- packages/engine/src/compiler/a11y-warnings.ts | 18 + packages/engine/src/compiler/animation.ts | 9 +- packages/engine/src/compiler/normalize.ts | 108 +- .../src/compiler/removed-spec-fields.ts | 51 + packages/engine/src/compiler/types.ts | 9 +- packages/engine/src/compiler/validate.ts | 56 +- .../src/design/__tests__/design.test.ts | 3 +- .../engine/src/endpoint-labels/compute.ts | 1 - .../src/geo/__tests__/compile-geo-map.test.ts | 34 +- packages/engine/src/geo/compile-geo-map.ts | 3 +- packages/engine/src/geo/types.ts | 3 - packages/engine/src/layout/dimensions.ts | 50 +- packages/engine/src/layout/shared.ts | 9 +- .../src/legend/__tests__/suppression.test.ts | 29 +- packages/engine/src/legend/compute.ts | 1 - packages/engine/src/legend/suppression.ts | 56 +- packages/engine/src/migration/v8.ts | 282 ++-- .../sankey/__tests__/compile-sankey.test.ts | 19 +- packages/engine/src/sankey/compile-sankey.ts | 2 +- packages/engine/src/sankey/types.ts | 3 - .../tables/__tests__/compile-table.test.ts | 3 +- .../src/tables/__tests__/table-design.test.ts | 21 +- packages/engine/src/tables/compile-table.ts | 4 +- .../engine/src/tilemap/compile-tilemap.ts | 6 +- packages/engine/src/tilemap/types.ts | 2 - packages/svelte/vitest.config.ts | 2 +- .../src/__tests__/annotation-notes.test.ts | 180 +++ .../src/__tests__/annotations-render.test.ts | 4 +- .../src/__tests__/barlist-mount.test.ts | 10 +- .../src/__tests__/chrome-renderer.test.ts | 2 +- .../vanilla/src/__tests__/edit-events.test.ts | 2 +- .../vanilla/src/__tests__/label-halo.test.ts | 4 +- .../src/__tests__/note-band-compat.test.ts | 12 +- .../src/__tests__/table-design.test.ts | 4 +- .../vanilla/src/__tests__/table-mount.test.ts | 7 +- packages/vanilla/src/export.ts | 17 +- .../src/interactions/annotation-notes.ts | 55 + packages/vanilla/src/renderers/annotations.ts | 10 + packages/vanilla/src/renderers/chrome.ts | 42 +- packages/vanilla/src/svg-renderer.ts | 2 + packages/vanilla/src/table-mount.ts | 2 +- packages/vanilla/src/table-renderer.ts | 4 - 134 files changed, 4604 insertions(+), 2924 deletions(-) create mode 100644 docs/migrating-v9.md create mode 100644 e2e/annotation-invariants/notes.spec.ts create mode 100644 examples/src/__tests__/v9-editorial-examples.test.ts create mode 100644 packages/engine/src/__test-fixtures__/v8-styling.json create mode 100644 packages/engine/src/annotations/__tests__/connector-crossings.test.ts create mode 100644 packages/engine/src/annotations/__tests__/v9-defaults.test.ts create mode 100644 packages/engine/src/annotations/connector-crossings.ts delete mode 100644 packages/engine/src/charts/line/__tests__/labels.test.ts delete mode 100644 packages/engine/src/charts/line/labels.ts create mode 100644 packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts create mode 100644 packages/engine/src/compiler/removed-spec-fields.ts create mode 100644 packages/vanilla/src/__tests__/annotation-notes.test.ts create mode 100644 packages/vanilla/src/interactions/annotation-notes.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 36c6795b..0125ba71 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,6 +6,70 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] +### 9.0.0 — annotation and figure defaults + +- Adopt 12px small/axis/source/byline/footer text and 16px/12px chrome spacing. + Background stays transparent; default axis and eyebrow colors are unchanged. +- Default callouts to measured wrapping, regular primary text, 12/13 subtitle + sizing, and curves without implied arrows. Explicit author settings still win. +- Default reference labels to automatic placement and reference/range labels to + theme small text. Process automatic annotations by priority then source order, + and score connector crossings when selecting placements. +- Keep one eligible inline callout at compact widths by default. Per-annotation + `responsive: false` stays pinned; existing chart-level responsive opt-outs retain + their compact behavior. Null still disables count and wrap limits. +- Grow omitted `chromeLayout` by the note band only. Explicit `subtract` keeps an + exact outer budget; `grow` grows by all chrome. Resolved heights avoid double + counting in mounts and exports, including layered and faceted figures. +- Render complete wrapped rich-text notes with source-order numbering. Number + gaps are intentional and facet instances share source identity. Notes and + markers support transient linked focus/click/tap emphasis. +- Retune default reference-rule and range ink; derive marker and plate fills from + the resolved neutral surface while preserving authored colors. + +### 9.0.0 — breaking API and authoring changes + +- Make `onEdit(EditTransaction)` the sole durable editing callback. Remove + `ElementEdit`, `onEditTransaction`, `onAnnotationEdit`, and `onTextEdit`; use + `applyEditTransaction` for atomic application, stale-target rejection, and undo. +- In edit mode, arrows nudge 1px, Shift+arrows nudge 10px, and Tab/Shift+Tab cycle + selection. Native text editing keeps its normal keyboard behavior. +- Remove the inert `onDataPointClick`, graph `updateVisuals()`, and the Chart + mount/wrapper `responsive` resize alias. Use `onMarkClick`, `update()`, and + `autoResize`, respectively; spec `responsive` is unchanged. +- Enforce the discriminated `MarkDef` union and reject inapplicable authored mark + options before sugar expansion, with matching TypeScript/schema/runtime checks. +- Reject retired table `compact`, Sankey/map/barlist `valueFormat`, annotation + `rule`, spec `darkMode`, theme chrome color strings, arc-family `encoding.y` + aliases, dead encoding channels, `mark.render`, and explicit `legacy` animation + presets. Tilemap `valueFormat` and ordinary chrome text shorthand remain valid. +- Remove legacy line-end series labels, manual series-label offsets/dragging, and + series-label element refs. Use the endpoint column; warn when both identifiers + are explicitly disabled on a multi-series line chart. +- Remove `footnoteBandHeight` helpers and narrow-breakpoint constant aliases. + Read `layout.chrome.noteBandHeight` and use `NARROW_VIEWPORT_MAX`. +- Unify builder/compiler field inference: sample up to 50 rows, require more than + 80% agreement, and recognize numeric strings before dates. Explicit field types + remain authoritative. + +### 9.0.0 — themes and migration + +- Deep-merge spec/provider/host themes; leaf themes override parent themes and + mount/compile options have highest precedence. Arrays and color pairs replace + atomically; partial rules inherit and `rule: null` clears a preset rule. +- Prefer authored chrome font sizes over general font propagation. Remove public + `Theme` and the empty `editorial` preset; use `ThemeConfig` / `ResolvedTheme` and + omit the preset. Deprecate `clarity`, retaining its remaining color choices. +- Stamp only authored CSS theme overrides through one renderer helper. Host CSS + can override omitted tokens; dark defaults include all registered tokens and + SVG/raster exports retain live CSS token values. +- Extend `migrateSpecV8` with `{ styling: 'v9' }` for alias-only migration. Default + migration materializes former v8 styling, including annotation colors/spacing, + and reports repairs without mutating input. Layout pixel parity is not promised. +- Add [the v9 migration guide](docs/migrating-v9.md), including persistence handler, + exact-height embed, theme, CSS, and mark-union changes. + + ## [8.9.1] - 2026-10-17 ### Annotation layout and theme options diff --git a/README.md b/README.md index 5c171a3e..bbbe5d04 100644 --- a/README.md +++ b/README.md @@ -54,7 +54,7 @@ function App() { That's the whole interface. `encoding` maps data fields to visual channels. `chrome` adds editorial framing. The engine figures out the rest. -The fixed-height wrapper is optional. Without one, the chart sizes itself: a 400px budget for the visualization (axes + plot), and titles, legends, and other chrome grow the figure on top of that. Give the wrapper an explicit height when you need the chart to fit a fixed slot. +The fixed-height wrapper is optional. Without one, the chart sizes itself: a 400px budget for the visualization (axes + plot), and titles, legends, and other chrome grow the figure on top of that. Give the wrapper an explicit height and set `chromeLayout: 'subtract'` when the chart must fit an exact slot; otherwise annotation notes can grow the figure. ## Features @@ -115,6 +115,7 @@ Release candidates publish under the `next` dist-tag, so a plain install always | [Accessibility](docs/accessibility.md) | What's automatic, what's opt-in (alt text, pattern fills, contrast warnings), WCAG mapping | | [Agent patterns](docs/agent-patterns.md) | Visualization patterns and cookbook for LLM-generated charts | | [Architecture](docs/architecture.md) | Compilation pipeline, package boundaries, design decisions | +| [Migrating to v9](docs/migrating-v9.md) | Annotation defaults, transaction editing, theme precedence, and retired APIs | | [Migrating to v8](docs/migrating-v8.md) | Breaking changes, before/after specs, and jq codemod recipes for the v8 upgrade | | [Theming tokens](docs/theming-tokens.md) | Design token system, `TokenValue` light/dark pairs, `seriesStrategy`, named presets | | [Contributing](CONTRIBUTING.md) | Setup, running tests, adding chart types, PR guidelines | diff --git a/docs/agent-patterns.md b/docs/agent-patterns.md index 73c30df7..f97f5e5a 100644 --- a/docs/agent-patterns.md +++ b/docs/agent-patterns.md @@ -64,7 +64,7 @@ All specs use realistic data with plausible numbers. Copy and adapt. } ``` -**Multi-series variant:** Add a `color` encoding to split by category. Keep to 5 series max for readability. +**Multi-series variant:** Add a `color` encoding to split by category. Keep to 5 series max for readability. Use the legend or `endpointLabels` to identify series; legacy line-end labels and manual series-label offsets are removed. ```json { @@ -182,7 +182,7 @@ All specs use realistic data with plausible numbers. Copy and adapt. { "browser": "Other", "share": 8.4 } ], "encoding": { - "y": { "field": "share", "type": "quantitative" }, + "theta": { "field": "share", "type": "quantitative" }, "color": { "field": "browser", "type": "nominal" } }, "chrome": { @@ -553,6 +553,8 @@ All specs use realistic data with plausible numbers. Copy and adapt. | `range` | Time periods, regions, phases | Use low `opacity` (0.05-0.1) so data is still visible. | | `refline` | Thresholds, baselines, averages | Use `dashed` or `dotted` style to visually separate from data. | +**Responsive annotation defaults:** Omit offsets and anchors when automatic placement is sufficient. Lower `priority` values stay inline first; compact charts keep one eligible text callout and put the rest in wrapped notes, including subtitles. `responsive: false` pins a callout inline and can cause overlap at narrow widths. Use `maxWidth: null` or `responsive.compactAnnotationLimit: null` only when an unlimited layout is intentional. Source-order note numbers may have gaps. Curves do not imply arrows; use `{ "type": "curve", "arrow": true }` explicitly. + **Combining annotations:** Layer them. Ranges go behind the data (low opacity). Reference lines mark horizontal/vertical thresholds. Text callouts highlight specific inflection points or notable data values. This layering creates visual depth. --- diff --git a/docs/architecture.md b/docs/architecture.md index 9419c3dc..419c87d1 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -79,9 +79,9 @@ graph LR Step by step: -1. **Validate**: Runtime type checking of the raw spec. Catches missing fields, wrong types, invalid encoding combinations. On a missing data field, the validator suggests the nearest real column name (Levenshtein) so the error tells the author how to fix it. -2. **Normalize**: Fill in defaults, resolve shorthand, produce a `NormalizedChartSpec` with all optional fields resolved. This stage also expands spec sugar (Vega-Lite idioms, bin/timeUnit) and collects deprecation warnings. The engine never calls `console.*`; it returns a structured warnings array that the vanilla adapter surfaces once at its boundary (see Compile warnings below). -3. **Resolve theme**: Deep-merge user theme overrides onto the default theme. Produces a `ResolvedTheme` with every property set. +1. **Validate**: Reject removed fields and inapplicable mark options against authored paths before sugar expansion, then validate the canonical spec. Catches missing fields, wrong types, invalid encoding combinations. On a missing data field, the validator suggests the nearest real column name (Levenshtein) so the error tells the author how to fix it. +2. **Normalize**: Fill in defaults, resolve shorthand, produce a `NormalizedChartSpec` with all optional fields resolved. The pre-validation sugar pass expands supported Vega-Lite idioms and bin/timeUnit transforms. Deprecated v8 aliases are no longer normalized silently. The engine never calls `console.*`; it returns a structured warnings array that the vanilla adapter surfaces once at its boundary (see Compile warnings below). +3. **Resolve theme**: Deep-merge authored layers with `mergeThemeConfig`, then resolve defaults into `ResolvedTheme`. Leaf themes override their parent and mount/compile options override the spec. 4. **Dark mode adapt**: If dark mode is active, transform colors (lighten backgrounds, adjust palette brightness, swap text colors). 5. **Compute legend**: Determine legend entries from the color encoding. Calculate space needed for legend positioning. Categorical color produces swatch entries; a quantitative color scale produces a continuous legend instead, either a gradient bar (sequential/diverging scales) or discrete swatches with class breaks (binned scales like quantile/quantize/threshold). 6. **Compute dimensions**: Account for chrome (title, subtitle, source, footer), legend, axis labels. Produce the chart area `Rect`. @@ -92,7 +92,7 @@ Step by step: 11. **Compute endpoint labels**: For line/area charts with `layout.endpointLabels` enabled, place a chip+swatch label at each series' trailing point. A capped iterative sweep (`endpoint-labels/compute.ts`) resolves vertical overlaps and clamps to the chart edges so the tail label never falls off-canvas. 12. **Compute metric bar**: When `chrome.metrics` is set, lay out the chrome metric pills (label + value + delta) so the renderer can paint them above the chart area. 13. **Apply suppression truth table**: A single source of truth (`legend/suppression.ts`) decides what disappears for each hidden series — endpoint label, leader line, dot annotation, and series-anchored text annotations all consult the same table so legend toggles, `hiddenSeries`, and runtime hides never disagree. -14. **Compute annotations**: Map annotation specs (reference lines, ranges, text callouts) to pixel positions using the scales. +14. **Compute annotations**: Resolve reference lines, ranges, and text callouts using the scales. Automatic placement follows priority/source order and scores leader crossings; compact thinning creates source-numbered rich notes. Resolved note bands participate in figure growth. 15. **Compute tooltips**: Build tooltip content descriptors keyed by mark ID. 16. **Compute a11y**: Generate alt text, ARIA labels, and a screen-reader data table from the spec and data. @@ -224,17 +224,32 @@ sequenceDiagram Vanilla->>DOM: Restore valid focus and wire interactions ``` +## Annotation editing and figure ownership + +The engine owns resolved annotation geometry and frozen edit contexts. Vanilla +previews gestures from that geometry and emits one `EditTransaction` through +`onEdit` at commit. Core's `applyEditTransaction` validates all operations before +returning a new spec and inverse; cancelled gestures emit nothing. Internal +gesture payloads are not public editing APIs. Framework wrappers forward the +same transaction contract. + +Omitted `chromeLayout` grows only the note band. `subtract` preserves exact outer +height; `grow` keeps the full plot budget and adds all chrome. Layers and facets +report `chrome.grownHeight` once, and mounts consume final `dimensions.height` +without double counting. Notes preserve source identity across thinning and +facet instances. Their linked marker emphasis is transient renderer state. + ## Theme resolution -Themes flow through three stages: +Themes flow through authored composition, default resolution, and mode adaptation: -1. **User config** (`ThemeConfig`): Partial overrides. Only specify what you want to change. -2. **Deep merge** (`resolveTheme()`): Merge user config onto `DEFAULT_THEME`. Produces a `ResolvedTheme` with every field set. +1. **Authored composition** (`ThemeConfig`, `mergeThemeConfig`): Deep-merge partial spec/provider/host overrides. Arrays and TokenValues are atomic; null replaces. A layer leaf overrides its parent before host options apply. +2. **Default resolution** (`resolveTheme()`): Fill omitted fields from `DEFAULT_THEME`. Produces `ResolvedTheme`, with authored metadata retained for token stamping. Authored chrome font sizes win over general size propagation; partial rules inherit and null clears them. 3. **Dark mode adaptation** (`adaptTheme()`): If dark mode is active, transform the resolved theme. Swap background/text, adjust palette brightness, lighten gridlines. -The default theme uses Inter as the primary font and a categorical palette tuned for distinguishability. Theme tokens remain the source of truth for typography; the opt-in `clarity` preset provides the newer editorial hierarchy without changing v8 defaults. +The default theme uses Inter, 12px small/tick text, 16px/12px chrome gaps, and a categorical palette tuned for distinguishability. Background remains transparent. `clarity` is deprecated and retains only its remaining colors; `legacyEditorial` provides v8 typography. The public `Theme` alias and empty `editorial` preset are removed. -Themes can be applied per-component (via prop) or globally (via `VizThemeProvider` in React, or the `theme` option in vanilla). +Themes can be applied per component or through `VizThemeProvider` in React/Vue/Svelte. Provider and component props deep-merge; vanilla mount options take highest precedence. A shared renderer helper stamps only authored token overrides and restores previous host inline values when overrides disappear. Unauthored values remain CSS defaults; exports capture live token values. ## Accessibility diff --git a/docs/chart-types.md b/docs/chart-types.md index a77d0d16..0cd358a0 100644 --- a/docs/chart-types.md +++ b/docs/chart-types.md @@ -195,13 +195,13 @@ const spec = { { os: "Other", share: 1 }, ], encoding: { - y: { field: "share", type: "quantitative" }, + theta: { field: "share", type: "quantitative" }, color: { field: "os", type: "nominal" }, }, }; ``` -The `y` channel maps to slice values, `color` maps to slice categories. No `x` encoding is needed. +The `theta` channel maps to slice values, `color` maps to slice categories. No `x` encoding is needed. **Live examples**: [Basic pie](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#pie-inline-labels) | [Small slice grouping](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#small-slice-grouping) | [Seven categories](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#many-categories) @@ -222,7 +222,7 @@ const spec = { { category: "All Other", spending: 29 }, ], encoding: { - y: { field: "spending", type: "quantitative" }, + theta: { field: "spending", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }; @@ -347,7 +347,7 @@ const spec = { ``` -Options: `nodeWidth`, `nodePadding`, `nodeAlign` (`'justify'` | `'left'` | `'right'` | `'center'`), `linkStyle` (`'gradient'` | `'source'` | `'target'` | `'neutral'`), `encoding.value.format` for number formatting (top-level `valueFormat` is deprecated in v8). +Options: `nodeWidth`, `nodePadding`, `nodeAlign` (`'justify'` | `'left'` | `'right'` | `'center'`), `linkStyle` (`'gradient'` | `'source'` | `'target'` | `'neutral'`), `encoding.value.format` for number formatting (top-level `valueFormat` is removed in v9). For the full field reference, see [SankeySpec in spec-reference.md](spec-reference.md#sankeyspec). @@ -566,7 +566,7 @@ const spec = { }; ``` -`theta` is the quantitative share value (the same part-to-whole channel arc marks use; it is an alias for `y`, so provide one of the two). `color` is the required category. Values normalize to the grid via largest-remainder rounding, so the cells always sum exactly even when the shares are fractional. +`theta` is the quantitative share value (the same part-to-whole channel arc marks use; the old authored `y` alias is removed). `color` is the required category. Values normalize to the grid via largest-remainder rounding, so the cells always sum exactly even when the shares are fractional. **Mark options:** - `units` (default 100) -- total number of cells in the grid @@ -632,7 +632,7 @@ const spec = { }; ``` -`theta` is the quantitative seat count (an alias for `y`; provide one of the two) and `color` is the required party. Order the data left to right by political group; the layout preserves that order across the arc. Party colors typically come from an explicit `color.scale.range` rather than the default palette. +`theta` is the quantitative seat count (the old authored `y` alias is removed) and `color` is the required party. Order the data left to right by political group; the layout preserves that order across the arc. Party colors typically come from an explicit `color.scale.range` rather than the default palette. **Mark options:** - `shape` (`'hemicycle'`, the default and only shape) diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 1820c8d1..cbd1544f 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -12,15 +12,15 @@ |---|---| | Top-level chart spec types (`ChartSpec`, `Encoding`, `Mark`, `Annotation`, `Chrome`) | `packages/core/src/types/spec.ts` (~1670 lines) | | Resolved layout types (`ChartLayout`, `ResolvedChrome`, `AxisLayout`, `ResolvedLabel`) | `packages/core/src/types/layout.ts` (~1290 lines) | -| Theme types (`Theme`, `ResolvedTheme`, `ThemeColors`, `ThemeFonts`, `ThemeChromeDefaults`) | `packages/core/src/types/theme.ts` | +| Theme types (`ThemeConfig` input, `ResolvedTheme` output, `ThemeColors`, `ThemeFonts`, `ThemeChromeDefaults`) | `packages/core/src/types/theme.ts` | | `DEFAULT_THEME` literal | `packages/core/src/theme/defaults.ts` | -| Theme deep-merge | `packages/core/src/theme/resolve.ts` → `resolveTheme()` | +| Theme deep-merge | `packages/core/src/theme/merge.ts` → public `mergeThemeConfig()` for authored layers; `resolve.ts` → `resolveTheme()` for defaults and resolved metadata. Arrays and TokenValues replace atomically. | | Dark-mode adapter (binary-search lightness) | `packages/core/src/theme/dark-mode.ts` → `adaptTheme()`, `adaptColorForDarkMode()` | -| Theme presets (`editorial`, `essay`, `wire`, `broadsheet`, `terminal`) | `packages/core/src/theme/presets.ts`. `broadsheet` is the only one that sets `rule`; `terminal` resolves `isDark: true` in light mode off its own dark background. | -| Editorial rule (masthead bar above the chrome block) | `ThemeConfig.rule` (`types/spec.ts`) → `Theme.rule` (`types/theme.ts`, `ThemeRule`) → reserved by `computeChrome` (`layout/chrome.ts`, `ResolvedChrome.rule`) → drawn by `renderChrome` as `` (`vanilla/src/renderers/chrome.ts`). `rule.color` is registered in `collectTokenPairs` so dark mode needs no literal branch. | +| Theme presets (`essay`, `wire`, `broadsheet`, `terminal`; deprecated colors-only `clarity` and compatibility `legacyEditorial`) | `packages/core/src/theme/presets.ts`. `broadsheet` is the only one that sets `rule`; `terminal` resolves `isDark: true` in light mode off its own dark background. | +| Editorial rule (masthead bar above the chrome block) | `ThemeConfig.rule` (`types/spec.ts`) → `ResolvedTheme.rule` (`types/theme.ts`, `ThemeRule`) → reserved by `computeChrome` (`layout/chrome.ts`, `ResolvedChrome.rule`) → drawn by `renderChrome` as `` (`vanilla/src/renderers/chrome.ts`). `rule.color` is registered in `collectTokenPairs` so dark mode needs no literal branch. | | Categorical palettes (stroke/fill x light/dark, plus `CATEGORICAL_EXTENDED_PALETTE`) and sequential/diverging | `packages/core/src/colors/palettes.ts` | | `ThemeColors.categoricalFill` (quieter fill variant for bar/area/arc/waffle/calendar/rect; copied from `categorical` when unset) and `hairline` (structural line, distinct from `gridline`) | `types/theme.ts`, `theme/defaults.ts`, `theme/resolve.ts` (`themeConfigToPartial`); picked per mark type by `packages/engine/src/compile/color-scale-range.ts` (`FILL_MARKS` set, `categoricalColorsForDomain`) | -| Derived neutral gray ramp (`deriveNeutralRamp`, on `ResolvedTheme.colors.neutral`) | `packages/core/src/colors/neutral.ts`. Also carries `neutral.surface`: the one answer to "what opaque color does this theme paint on" (the background when it is opaque, else the mode's `--oc-bg` token). Knockout rings (`engine/charts/utils.ts` → `resolveKnockoutColor`), table fills (`engine/tables/utils.ts` → `resolveTableSurface`), graph rings (`engine/graphs/encoding.ts` → `resolveGraphSurface`), tilemap label blending and `vanilla/theme-tokens.ts` → `resolvedSurface` all read it — no `--oc-bg` resolution outside core. | +| Derived neutral gray ramp (`deriveNeutralRamp`, on `ResolvedTheme.colors.neutral`) | `packages/core/src/colors/neutral.ts`. Also carries `neutral.surface`: the one answer to "what opaque color does this theme paint on" (the background when it is opaque, else the mode's `--oc-bg` token). Knockout rings (`engine/charts/utils.ts` → `resolveKnockoutColor`), table fills (`engine/tables/utils.ts` → `resolveTableSurface`), graph rings (`engine/graphs/encoding.ts` → `resolveGraphSurface`), default annotation marker/plate fills, tilemap label blending and `vanilla/theme-tokens.ts` → `resolvedSurface` all read it — no `--oc-bg` resolution outside core. | | Palette a11y guard (contrast, hue adjacency, CVD) | `packages/core/src/colors/__tests__/palette-a11y.test.ts` | | Color contrast / accessibility helpers | `packages/core/src/colors/contrast.ts`, `colorblind.ts` | | CSS token source of truth | `packages/core/src/styles/token-definitions.ts` | @@ -62,10 +62,10 @@ | Annotations: collision resolution | `packages/engine/src/annotations/collisions.ts` | | Annotations: chrome guard (labels can't climb into the title block) | `packages/engine/src/annotations/collisions.ts` → `clampAnnotationsToBounds(annotations, svgWidth, svgHeight, measure, topBound)`. `topBound` is the first y a label may occupy; `compile.ts` passes `dims.topPad + dims.chrome.topHeight` (`LayoutDimensions.topPad` in `engine/layout/dimensions.ts`) when there is a chrome block, and the same band is fed to auto-placement as a `'chrome'` `ObstacleKind` (`annotations/placement.ts`). | | Annotations: auto-thinning (demote callouts → numbered footnotes) | `packages/engine/src/annotations/thinning.ts` → `thinAnnotations()`. Three independent demotion rules: pairwise overlap (padded by `COLLISION_PADDING`), containment (`fitsWithin`, checked against `containerBounds ?? plotArea` — callouts legitimately live in the margin, so only labels escaping the *chart* demote), and a `COVERAGE_BUDGET` cap on the share of *plot* area inline labels may claim. The budget is what thins narrow charts — collision resolution spreads labels apart and clamping tucks them back in, so crowded layouts never trip the first two rules. Pinned (`responsive: false`) labels are exempt from the budget. | -| Annotations: wrapped note lines and reserve counts | `packages/engine/src/annotations/notes.ts` → `wrapAnnotationNotes()`, `annotationNoteLineCount()`, `annotationNoteCapacity()` (internal; source capacity including pinned text/subtitles and widest numeric prefix). `AnnotationFootnote.lines` carries engine-wrapped display lines to rendering/export; optional `ResolvedChrome.noteBandHeight` carries the stable source-capacity reserve to footer/brand/export and layered alignment. | +| Annotations: wrapped note lines and reserve counts | `packages/engine/src/annotations/notes.ts` → `wrapAnnotationNotes()`, `annotationNoteLineCount()`, `annotationNoteCapacity()` (internal; source capacity including pinned text/subtitles and widest numeric prefix). `AnnotationFootnote.lines` and `.spans` carry engine-wrapped, source-numbered rich display lines to rendering/export; optional `ResolvedChrome.noteBandHeight` carries the stable source-capacity reserve to footer/brand/export and layered alignment. | | Annotations: frozen editing geometry | `packages/engine/src/annotations/edit-context.ts` → public `createAnnotationEditContext()`, `previewAnnotationMove()`, `previewConnectorBend()`, `inverseLabelPlacement()`. Uses compiled annotation metadata (scales and measurement stay inside engine); snapshots preserve source/facet identity and clamp bounds. | | Annotations: data → pixel resolver | `packages/engine/src/annotations/position.ts` | -| Annotations: shared constants (`ANCHOR_OFFSET=28`, `CONNECTOR_STANDOFF=6`, `MIN_CONNECTOR_LENGTH=8`, dot defaults, `LEDE_FONT_WEIGHT`, connector strokes, default font) | `packages/engine/src/annotations/constants.ts` | +| Annotations: shared constants (`ANCHOR_OFFSET=28`, `CONNECTOR_STANDOFF=6`, `MIN_CONNECTOR_LENGTH=8`, dot defaults, subtitle sizing, connector strokes, default font) | `packages/engine/src/annotations/constants.ts` | | Legend layout (entries, wrapping, positioning, `entryPositions`) | `packages/engine/src/legend/compute.ts`, `wrap.ts`; internal `measureCategoricalLegendContent()` reuses the same measurement for merged layer entries. | | Size legend (graduated circles + title row) | `packages/engine/src/legend/size.ts` (`computeSizeLegendContent`, `placeSizeLegend`, `sizeLegendTitleStyle`). The title comes from `size.title ?? size.field`; the map's own copy is built inline in `geo/compile-geo-map.ts` (`pointSizeLegend`). Rendered by `renderSizeLegend` in `packages/vanilla/src/renderers/legend.ts`. | | Knockout ring / seam color (scatter, line points, dot, beeswarm, pie seams, stacked seams, map bubbles) | `packages/engine/src/charts/utils.ts` → `resolveKnockoutColor(theme)`. Resolved theme background when opaque, else the mode surface. `stackSeamStroke()` delegates to it. Never hardcode `'#ffffff'` — the `terminal` preset is dark in both modes. | @@ -121,13 +121,13 @@ | Demo index registry (Welcome's findability index; single source of truth) | `examples/src/gallery/registry.ts` assembles per-page `examples/src/gallery/.demos.ts` sidecars. Sidecars live outside `*.stories.*` on purpose: Ladle turns every named export in a story file into a story, so a shared `export const demos` collides (e.g. all five Charts pages → duplicate `charts--demos`) and fails `ladle build`. | | Legacy-slug redirect map (old published `?story=` deep links → new page+anchor; guarantees no 404s after the flip) | `examples/.ladle/redirects.ts`. Provider reads `?story=` on mount and redirects. | | Ladle config (defaultStory, storyOrder wildcards, width/theme addons) + head (favicon, OG/twitter meta, Bricolage-only font), shell CSS, 6 named house styles | `examples/.ladle/config.mjs`, `head.html`, `shell.css`, `themes.ts`. Social OG image: `examples/public/og-preview.png`. | -| Theming gallery page (five presets, six named house styles, custom ThemeConfig, dark adaptation) | `examples/src/gallery/features-theming.stories.tsx` (slug `features--theming`) | +| Theming gallery page (default and four presets, six named house styles, custom ThemeConfig, dark adaptation) | `examples/src/gallery/features-theming.stories.tsx` (slug `features--theming`) | | Visual regression spec | `e2e/visual/stories.spec.ts` | | Mobile visual regression spec (390px viewport, `visual-mobile` project) | `e2e/visual/stories-mobile.spec.ts` | | Visual regression baselines | `e2e/visual/__screenshots__/` | | Mobile layout invariants (6 geometry rules incl. Rule 6 label presence; 4 Playwright projects, the chromium ones run in CI) | `e2e/invariants/mobile-invariants.spec.ts` | | Rendered layout invariants (real Chromium `getBoundingClientRect`, catches engine-vs-renderer disagreements happy-dom can't) | `e2e/invariants/rendered-invariants.spec.ts` (reuses the visual-suite story slugs); table column widths: `e2e/invariants/table-column-width.spec.ts` (an explicit `200px` column renders at exactly 200px, no horizontal overflow). Others in `e2e/invariants/`: `canvas-alpha-parity`, `font-race`, `graph-surface`, `known-bugs`, `graph-3d` (live WebGL context on a `dimensions: 3` demo, the over-gate 2D fallback, and one context released per destroyed 3D mount; chromium-desktop only, no fps assertions). | -| Blog-theme mobile repro (bugs on labs.tryopendata.ai need `axisTick: 14`, not the 11px default; production mirror story) | `examples/src/charts/mobile-regression.stories.tsx` → `testing--mobile-regression--one-wide-x-label-large-ticks` (retitled `Testing / Mobile Regression`; file stays put, slugs are now `testing--mobile-regression--*`) | +| Blog-theme mobile repro (bugs on labs.tryopendata.ai need `axisTick: 14`, not the 12px default; production mirror story) | `examples/src/charts/mobile-regression.stories.tsx` → `testing--mobile-regression--one-wide-x-label-large-ticks` (retitled `Testing / Mobile Regression`; file stays put, slugs are now `testing--mobile-regression--*`) | | Frozen e2e fixture copies (pinned visual/invariant stories, pixel-identical, `Testing / Fixtures`) + frozen stylesheet | `examples/src/testing/fixtures-*.stories.tsx` (+ `testing.css`, the `.tfix-` frozen namespace). Slugs are `testing--fixtures--*`. Includes `rotated-with-source`, moved here from `examples/src/charts/`. Design-refresh fixtures live in `fixtures-refresh.stories.tsx` (flat columns, donut `centerLabel`, presets), `fixtures-tables.stories.tsx` (density, delta chips, heatmap dark, shared sparklines, cards), `fixtures-tilemaps.stories.tsx`, `fixtures-sankey.stories.tsx` (energy light/dark, `other` bucket), `fixtures-dashboards.stories.tsx` (KPI tiles, 140px tiny tile); `us-bubbles` and `world-diverging-quantize` are appended to `fixtures-maps.stories.tsx`. | | GeoMap spec types (`GeoMapSpec`, `GeoMapEncoding`, `GeoMapPointsLayer`, `GeoMapGeo`) | `packages/core/src/types/spec.ts` (search "GeoMapSpec") | | GeoMap layout types (`GeoMapLayout`, `GeoMapFeatureMark`, `GeoMapPointMark`, `GeoMapBorders`) | `packages/core/src/types/layout.ts` (search "GeoMapLayout") | @@ -161,11 +161,11 @@ | Graph interaction resolve (hover mode, cursor repulsion, springy drag, select/flyTo) | `packages/engine/src/graphs/interaction.ts` → `resolveGraphInteraction()`. | | Graph mount (lifecycle, events, tooltips, legend, search, camera, highlight, update) | `packages/vanilla/src/graph-mount.ts` → `createGraph()`. Public API: `GraphInstance` (search/zoomToFit/zoomToNode/flyTo/centerAt/getCamera/selectNode/highlight/clearHighlight/getHighlight/setActiveCategories/getActiveCategories/getLegend/update). `suppressEntrance` mount option skips the reveal on a theme/darkMode-only remount (instant fit, warmup still runs). Physics-feel gates: `SPRINGY_DRAG_MAX_NODES` (5000), `CURSOR_FORCE_MAX_NODES` (2000). | | Graph camera payload (`getCamera`/`onCameraChange`/`flyTo` types, shared 2D+3D) | `packages/vanilla/src/graph/types.ts` → `GraphCamera`, `GraphFlyTarget`. `x`/`y`/`k` always present (2D zoom transform, 3D look-at + `FIT_DISTANCE/distance`); `position`/`target` are 3D-only and therefore optional. `graph-3d/mount.ts` `Camera3D` extends `GraphCamera` narrowing those to required; `FlyTarget3D` is an alias of `GraphFlyTarget`. Re-exported from the vanilla barrel and by each framework's `useGraph`. | -| Graph mount shell (wrapper, chrome, legend slot, tooltip, resize) | `packages/vanilla/src/graph/shell.ts` → `createGraphShell()`, `getContainerDimensions()`, `escapeHtml()`. Everything that is the same in 2D and 3D. Owns the `--oc-bg`/`--oc-text` theme custom properties on `.oc-graph-wrapper`, the `oc-dark` class on the container, `renderChrome`/`syncChromeInset`, and `mountSurface(el)` which inserts the renderer's surface between the chrome and the legend. `createDOM()` in graph-mount.ts no longer exists; the 2D canvas is built by `createSurface()`. | +| Graph mount shell (wrapper, chrome, legend slot, tooltip, resize) | `packages/vanilla/src/graph/shell.ts` → `createGraphShell()`, `getContainerDimensions()`, `escapeHtml()`. Everything that is the same in 2D and 3D. Uses `theme-tokens.ts` to stamp authored theme custom properties on `.oc-graph-wrapper`, the `oc-dark` class on the container, `renderChrome`/`syncChromeInset`, and `mountSurface(el)` which inserts the renderer's surface between the chrome and the legend. `createDOM()` in graph-mount.ts no longer exists; the 2D canvas is built by `createSurface()`. | | Graph renderer registry (2D vs 3D dispatch) | `packages/vanilla/src/graph/renderer-registry.ts` → `GraphShell`, `GraphRendererContext`, `GraphRendererFactory`, `registerGraphRenderer(3, factory)`, `getGraphRenderer(dims)`, `resetGraphRendererRegistry()` (test hook), `GRAPH_3D_NOT_REGISTERED_ERROR`. `createGraph()` dispatches on `compilation.numDimensions` after building the shell. | | 3D graph renderer (WebGL, optional peers) | `packages/vanilla/src/graph-3d/` → the `@opendata-ai/openchart-vanilla/graph-3d` subpath (one-line framework mirrors, so a host never deep-imports a transitive dep: `packages/{react,vue,svelte}/src/graph-3d.ts` → `@opendata-ai/openchart-{react,vue,svelte}/graph-3d`). Registers itself on import; `three`, `3d-force-graph`, `three-spritetext` are optional peers, `d3-force-3d` a dependency. Spec field: `GraphSpec.dimensions?: 2 \| 3`. Node gate `MAX_3D_NODES` (2000) lives in `packages/engine/src/graphs/types.ts` and is applied by `compileGraph` (above it, 3D warns and falls back to 2D). | | Graph canvas renderer (batched draw passes, focus tiers, label budget) | `packages/vanilla/src/graph/canvas-renderer.ts` → `GraphCanvasRenderer`. `labelBudget(zoom)` = `clamp(round(zoom*30), 12, 80)` non-forced labels, placed greedily by priority with an AABB declutter; hovered/selected/`alwaysShowLabel`/search-matched labels bypass both and are the only ones with a halo. Resting edge alpha is 0.3 light / 0.25 dark; the dim tier is `interaction.dimOpacity` (0.3) for nodes and a third of it for edges. | -| Graph surface (canvas background, node knockout ring) | `packages/engine/src/graphs/encoding.ts` → `resolveGraphSurface(theme)` and `vanilla/src/theme-tokens.ts` → `resolvedSurface(theme)`, both thin reads of `theme.colors.neutral.surface` which `graph-mount.ts` stamps as `--oc-bg` on the wrapper. Node strokes are a 1.5px ring in that color. `graph.css` carries NO dark-mode canvas override (the old `#0d1117` is gone); `e2e/invariants/graph-surface.spec.ts` locks it. | +| Graph surface (canvas background, node knockout ring) | `packages/engine/src/graphs/encoding.ts` → `resolveGraphSurface(theme)` and `vanilla/src/theme-tokens.ts` → `resolvedSurface(theme)`, both thin reads of `theme.colors.neutral.surface` which the shared theme-token helper stamps as `--oc-bg` only when the theme authors a background. Node strokes are a 1.5px ring in that color. `graph.css` carries NO dark-mode canvas override (the old `#0d1117` is gone); `e2e/invariants/graph-surface.spec.ts` locks it. | | Graph force simulation (d3-force wrapper, warmup, tick streaming, sync + worker) | `packages/vanilla/src/graph/simulation.ts` → `SimulationManager`. Worker protocol: `graph/worker-protocol.ts`; worker URL: `graph/simulation-worker-url.ts`. | | Graph seeded layout (deterministic initial positions) | `packages/vanilla/src/graph/seed.ts` → `seedNodePositions()`. Start position is a pure function of `(id, seed, community)`, so the settled layout is reproducible across data reshapes (not d3's order-dependent phyllotaxis). | | Animation scheduler (rAF loop, first-frame arming) | `packages/vanilla/src/motion/scheduler.ts` → `AnimationScheduler`, `Animation`. Shared by graph and the scatter canvas layer. Owns the running animations; the host's render loop ticks it once/frame and re-arms rAF only while animations are active (zero cost when idle). | @@ -198,6 +198,7 @@ | "Did you mean" repair hints | levenshtein `editDistance`/`nearestColumn`/`didYouMean` in `packages/engine/src/compiler/validate.ts` (wired into DATA_FIELD_MISSING suggestions). Test: `validate-did-you-mean.test.ts`. | | LLM spec-generation eval (manual, per-release) | `scripts/llm-eval/` (`fixtures.json`, `run.mjs`, `README.md`). Hits the Anthropic API; not in CI; `@anthropic-ai/sdk` is not a monorepo dep (dynamic import). | | Generating-specs guide | `docs/generating-specs.md` (schema usage, tool-use, strict-mode transform, validate-repair loop). | +| v9 migration guide | `docs/migrating-v9.md` (default changes, edit transactions, theme merging, removed forms, and styling migration modes) | | v8 migration guide | `docs/migrating-v8.md` (breaking changes, before/after specs, jq codemods). Keep in sync with the deprecation warnings. | | Dashboard usage guide (tile sizing, sparkline KPI cards, metrics vs HTML stat cards, chrome economy, watermark auto-hide rules, grid composition, BarList/DataTable in tiles) | `docs/dashboards.md` | | VL-idiom sugar + deprecation warnings | `packages/engine/src/compile/spec-sugar.ts` (`expandSpecSugar`, `emitSpecWarnings`) — dead channels (`radius`/`shape`/`href`/`order`, removed in v8), stack default, theta<->y (theta is canonical in v8; `y` on arc/waffle/parliament is a deprecated alias, expanded both directions since the engine still reads `y` internally), `'rule'`->`'refline'` annotation alias, `$schema`. All of these run pre-validation so canonical form is all `compiler/normalize.ts`/`validate.ts` ever see. Tests: `engine/src/__tests__/spec-sugar.test.ts`. | @@ -253,15 +254,16 @@ The vanilla adapter (`mount.ts`) takes the resulting `ChartLayout` and calls `re - **`refreshConnector(connector, bounds, markerRadius)`** (`geometry.ts`) is the single rebuild path used by `resolve-text.ts`, `compute.ts` Pass 2, and every `collisions.ts` reposition. It re-runs `connectorExit` for `from` (then re-applies the author's `connectorOffset.from`, carried on the resolved connector as `fromOffset`, before the pullback) and re-derives `to` by pulling back from `connector.endpoint` (never from the already-pulled-back `to`). Both author nudges survive rebuilds this way: `to` via `endpoint`, `from` via `fromOffset`. Returns `undefined` when the connector should be suppressed. - **Connector suppression:** a connector shorter than `MIN_CONNECTOR_LENGTH` (8) after pullback, or one whose target is inside the text block, is dropped. The dot survives. `MIN_CONNECTOR_LENGTH` must stay well under the standoff + marker-pullback overhead (~18px) that is spent before any line is drawn — set it near or above that and the default annotation becomes structurally unable to draw a connector at any offset, which is exactly the bug `842c1dc` fixed. A plain `{type:'text'}` annotation sits `ANCHOR_OFFSET` (28) off its point and draws a real leader with zero authoring. - **One marker system.** `annotation.dot` is it — the renderer's old hardcoded bullseye (ring + center dot) is gone. `resolveDot` (`resolve-text.ts`) resolves a default dot whenever a connector is enabled, no arrowhead is set, and `dot` is `undefined`. `dot: false` → bare. Arrow + unset `dot` → no marker. The dot sits on the data point (plus `connectorOffset.to`), not on the pulled-back connector tip. Drop-lines get one too. -- **Connector spec union**: `boolean | ConnectorType | ConnectorConfig` where `ConnectorType = 'straight' | 'curve' | 'drop-line'` and `ConnectorConfig = { type: ConnectorType, arrow?: boolean }`. Defaults: `arrow=true` for curve, `arrow=false` for straight/drop-line. Resolved connector carries `arrow: boolean` and `exit`. Two voices: arrowed connectors take the label's text ink, quiet leaders (non-arrow straight, drop-line) take `LIGHT/DARK_CONNECTOR_STROKE`. Curves render as a single quadratic; arrowheads are a stroked open-V `` (`geometry.ts:computeArrowheadPoints`, a public engine export, defaults length 7 / halfWidth 3.5). +- **Connector spec union**: `boolean | ConnectorType | ConnectorConfig` where `ConnectorType = 'straight' | 'curve' | 'drop-line'` and `ConnectorConfig = { type: ConnectorType, arrow?: boolean, bend?: number }`. All connector types default to `arrow=false`; explicit arrows remain supported. Resolved connector carries `arrow: boolean` and `exit`. Two voices: arrowed connectors take the label's text ink, quiet leaders (non-arrow curves, straight, drop-line) take `LIGHT/DARK_CONNECTOR_STROKE`. Curves render as a single quadratic; arrowheads are a stroked open-V `` (`geometry.ts:computeArrowheadPoints`, a public engine export, defaults length 7 / halfWidth 3.5). - **Rich text** (`annotations/rich-text.ts`): `parseAnnotationSpans(line)` → `RichSpan[]` for `**bold**` markers, in `text` and `subtitle`. Unmatched (and empty) delimiters render literally. `computeTextBlockBounds` measures bold spans at `BOLD_SPAN_FONT_WEIGHT` (700), so bounds, collisions, placement, and connector exits all account for them. Exported from `engine/src/index.ts` for the renderer. - **Alignment:** the *text* never center-aligns. `textAnchor` is `'end'` when `anchor: 'left'` (block sits left of the point, right edge faces it) and `'start'` otherwise; the auto-placement `DIRECTIONS` table in `placement.ts` assigns start/end by direction. - **Block centering (distinct from text alignment):** a `top`/`bottom` anchor means the block sits *above/below* the point, so it straddles it horizontally — the lines inside stay left-aligned/ragged-right. Explicit path: `centeringShift` in `resolve-text.ts` backs `labelX` off by half the widest line (primary vs subtitle — a subtitle is often wider). Auto path: `labelPositionFromAttachment` in `placement.ts` honors `attach: 'top-center'`/`'bottom-center'`. Skip either and `anchor: 'top'` renders as "up and to the right", forcing authors to hand-compute a negative `dx` of half the block width. -- **Typography:** 13px / weight 400 by default, in `theme.fonts.family` (threaded via `AnnotationContext.fontFamily` from `compile.ts`; `FALLBACK_FONT_FAMILY` covers SSR paths). The **lede rule** (`resolveLedeFontWeight`): a `subtitle` with no explicit `fontWeight` promotes the primary text to `LEDE_FONT_WEIGHT` (700); the subtitle stays 400. -- **Thinning** (`annotations/thinning.ts`): `thinAnnotations(annotations, specs, measure, plotArea?, containerBounds?)` demotes overlapping/overflowing labels to numbered footnotes. Containment is checked against `containerBounds ?? plotArea` — annotations legitimately sit in the margin (above a peak, below a trough), so only labels escaping the *chart* demote. The coverage budget still measures against the plot. +- **Typography:** 13px / weight 400 by default, in `theme.fonts.family` (threaded via `AnnotationContext.fontFamily` from `compile.ts`; `FALLBACK_FONT_FAMILY` covers SSR paths). A subtitle no longer promotes the primary text to bold; explicit `fontWeight` and rich bold spans win. Subtitle size is the rounded primary size × 12/13; reference/range labels use the theme small size. +- **Placement order and crossings:** `compute.ts` places higher-priority annotations first with source order as the tie-breaker; `connector-crossings.ts` spatially indexes labels, marks, and accepted leaders for candidate scoring. Explicit zero offsets are pinned. +- **Thinning** (`annotations/thinning.ts`): `thinAnnotations(annotations, specs, measure, plotArea?, containerBounds?)` demotes overlapping/overflowing labels to numbered footnotes. Containment is checked against `containerBounds ?? plotArea` — annotations legitimately sit in the margin (above a peak, below a trough), so only labels escaping the *chart* demote. The coverage budget still measures against the plot. Compact charts default to one inline callout; D15 responsive opt-outs bypass thinning. Note numbers use source annotation ordinals and can have gaps. - **`specIndex` — the resolved array is NOT the spec array.** `computeAnnotations` drops any annotation whose position falls outside the scale domain, so `annotations[i]` is not `spec.annotations[i]`. Every resolved annotation carries `specIndex` for the trip back; thinning's priority/`responsive` lookup and the faceted footnote numbering both key on it. Indexing `specs[i]` by resolved position is the bug this exists to prevent — it silently mis-associates annotations, and it only shows up once one of them fails to resolve. - **Faceted thinning** (`compile.ts`, in `compileFaceted`): faceted charts do *not* reach the single-chart `thinAnnotations` call — `compileChart` branches to `compileFaceted` first, so before this existed `autoThin` on a facet was a silent no-op and labels sprawled across neighbouring panels. Each panel now thins against its own `gridPanel.area`, passed as **both** `plotArea` and `containerBounds` (a panel is its own little chart). Footnotes pool into one figure-level `chrome.footnotes` list; since a spec annotation resolves into *every* panel, numbering is keyed on `specIndex` so a label demoted in all N panels gets one footnote, not N copies. The shared preflight reserves source note capacity once per annotation, including pinned text and subtitles. `frozenChartArea` prevents leaf recompilation from moving the common layer plot. -- **Shared constants:** `annotations/constants.ts` (`ANCHOR_OFFSET`, `CONNECTOR_STANDOFF`, `MIN_CONNECTOR_LENGTH`, `DEFAULT_DOT_RADIUS`/`_STROKE_WIDTH`, `LEDE_FONT_WEIGHT`, connector strokes, default font sizing, dash pattern, refline/text fills for light/dark). +- **Shared constants:** `annotations/constants.ts` (`ANCHOR_OFFSET`, `CONNECTOR_STANDOFF`, `MIN_CONNECTOR_LENGTH`, `DEFAULT_DOT_RADIUS`/`_STROKE_WIDTH`, `SUBTITLE_FONT_SIZE_RATIO`, connector strokes, default font sizing, dash pattern, refline/text fills for light/dark). ## Chrome system @@ -272,14 +274,15 @@ The vanilla adapter (`mount.ts`) takes the resulting `ChartLayout` and calls `re - `dimensions.ts` (engine) calls `computeChrome` and subtracts top/bottom reservations from the available area to get `chartArea`. - The renderer (`vanilla/src/renderers/chrome.ts`) is small (~96 lines) — it just maps `ResolvedChrome` to SVG `` elements with the right CSS classes. - `chromeLayout` (`'subtract' | 'grow'`, top-level spec prop) and `ChromeTextStyle.maxLines` are the chrome-layout spec surface in `packages/core/src/types/spec.ts`. -- `resolveChromeLayout` in `packages/engine/src/layout/shared.ts` picks subtract vs grow (spec wins over compile option; faceted specs pin to `'subtract'`). +- `resolveChromeLayout` in `packages/engine/src/layout/shared.ts` picks the public subtract/grow opt-outs or the internal default `'notes'` policy. Notes grow below the ordinary figure budget; explicit subtract keeps the exact height and grow preserves the plot budget. Facet assembly owns growth once. - `truncateToWidth` in `packages/core/src/responsive/metrics.ts` is the exported ellipsis helper that caps text to a pixel budget (backs both `maxLines` and rotated-tick truncation). ## Theme system - **Default theme:** `DEFAULT_THEME` literal in `packages/core/src/theme/defaults.ts`. Single source of truth. -- **User overrides:** `ThemeConfig` (partial) → `resolveTheme()` deep-merges onto `DEFAULT_THEME` → `ResolvedTheme` (every field set). +- **User overrides:** public `mergeThemeConfig()` deep-merges authored configuration; arrays and TokenValues are atomic, null replaces. Layer leaf values beat parent values; mount options beat both. Framework providers merge into component props. `resolveTheme()` adds defaults and returns `ResolvedTheme`, retaining internal `_authored` metadata for CSS and dark adaptation. Public `Theme` is removed; `ThemeBase` remains internal. - **Dark mode:** `adaptTheme(resolved)` in `dark-mode.ts` swaps surface tokens, adapts categorical palette via binary-search on lightness to preserve contrast against the dark background. +- **Authored CSS tokens:** `packages/vanilla/src/theme-tokens.ts` → `stampThemeProperties()` is shared by chart/table/graph/map and specialized renderers. It stamps authored overrides only, restoring previous host inline values when an override disappears. `theme-style-block.ts` copies computed tokens into export clones. - **CSS tokens** (`tokens.css`, `dark.css`) are **generated** from `packages/core/src/styles/token-definitions.ts`. Run `bun run generate:tokens` after editing. CI enforces freshness via `check:generated`. - **Cascade layers:** all CSS is wrapped in `@layer oc.*` sub-layers declared in `index.css`: `oc.tokens, oc.base, oc.components, oc.animation, oc.reduced-motion`. Consumer un-layered CSS beats all openchart rules (v8 breaking change, see `docs/migrating-v8.md` section 14). @@ -371,11 +374,11 @@ The browser authoring application lives in the separate [OpenStudio repository]( | Concern | Source / exports | | --- | --- | | Atomic authored-spec edits and inverse operations | `packages/core/src/helpers/edit-transactions.ts`: public `applyEditTransaction`, `EditTransactionResult`; `types/events.ts`: `EditOp`, `EditTransaction`, optional revision and callback | -| Authored annotation ownership and legacy event bridge | `packages/core/src/helpers/annotation-source.ts`: `annotationSource`; `packages/vanilla/src/interactions/edit-transactions.ts`: `editToTransaction`; mount stamps source paths, renderer stamps facet instances; selection and click events preserve both identities | +| Authored annotation ownership and transaction bridge | `packages/core/src/helpers/annotation-source.ts`: `annotationSource`; `packages/vanilla/src/interactions/edit-transactions.ts`: `editToTransaction`; mount stamps source paths, renderer stamps facet instances; selection and click events preserve both identities | | Alias migration with repair paths | `packages/engine/src/migration/v8.ts`: public `migrateSpecV8`, `SpecRepair`; layer migrations reuse `compile/layer.ts:buildPrimarySpec` and `compiler/normalize.ts:flattenLayers` | -| Shared v8 shallow theme precedence | `packages/core/src/theme/merge.ts`: `mergeThemeConfig`; `ThemeBase` is internal, public `Theme` remains deprecated | -| Mount compatibility warnings | `packages/vanilla/src/deprecations.ts`; engine warnings remain in `compile/spec-sugar.ts` | -| Example migration parity | `examples/src/__tests__/migration-parity.test.ts`; test-only collector in `examples/vitest.config.ts` exposes authored story/layout values | +| Shared theme precedence | `packages/core/src/theme/merge.ts`: `mergeThemeConfig`; introduced shallow in Track A and deep in v9. `ThemeBase` is internal; public inputs use `ThemeConfig`. | +| Retired compatibility forms | v9 rejects removed spec fields in `packages/engine/src/compiler/removed-spec-fields.ts` before sugar; migration remains in `migration/v8.ts`. | +| Example migration contract fixtures | `examples/src/__tests__/migration-parity.test.ts`; test-only collector in `examples/vitest.config.ts` exposes authored story/layout values | Annotation computation requires the existing `AnnotationContext`; dimensions require a `LayoutPlan`. Their old positional/plan-less fallbacks are gone. @@ -391,7 +394,7 @@ transitions accept `MapTransitionSnapshot` only. | Curve control and per-frame annotation geometry | `annotations/geometry.ts`: `automaticControlPoint`, `refreshConnector`; `annotations/interpolate.ts`: `interpolateAnnotationGeometry`; all exported from engine | | Cancellable mouse/touch gestures and keyboard batches | `packages/vanilla/src/interactions/drag-handler.ts`, `annotation-nudges.ts`; mount flushes pending edits before gesture starts and retargets after synchronous host updates | | In-place annotation drawing shared by preview and animation | `packages/vanilla/src/renderers/annotations.ts`: `updateAnnotationGeometry`; transition gate/build/apply consume resolved geometry | -| Cross-browser geometry contract | `e2e/annotation-invariants/`; two dedicated projects; gallery `AnnotationCurves` and `AnnotationGestures` stay outside visual baselines | +| Cross-browser geometry contract | `e2e/annotation-invariants/`; two dedicated projects; gallery `AnnotationCurves` and `AnnotationGestures` provide dedicated fixtures; visual-suite selection lives in `e2e/visual/stories.spec.ts` | | Preview benchmark | `packages/engine/src/annotations/__tests__/preview.bench.ts`; Vitest 5 test-context bench, run with `bunx vitest bench --run` | The chart compiler places right-side legends after resolving the endpoint column, @@ -405,13 +408,13 @@ regular, shared-layer and dual-axis column separation. | Concern | Location | |---|---| | Rich wrapping without changing authored text/transition keys | `packages/engine/src/annotations/wrap-text.ts`: `wrapAnnotationText`; `resolve-text.ts` stores `label.lines`/subtitle lines | -| Opt-in reference end/auto/outside geometry | `packages/engine/src/annotations/reference-layout.ts`: `referenceLabelReserve`, `placeReferenceLabel`; `geometry.ts` retains corridor attachments during rebuilds; `compute.ts` uses per-panel reserved lanes owned by `layout/facet.ts`; `dimensions.ts` and `compile/layer.ts` reserve the gutter | +| Reference end/auto/outside geometry (auto by default in v9) | `packages/engine/src/annotations/reference-layout.ts`: `referenceLabelReserve`, `placeReferenceLabel`; `geometry.ts` retains corridor attachments during rebuilds; `compute.ts` uses per-panel reserved lanes owned by `layout/facet.ts`; `dimensions.ts` and `compile/layer.ts` reserve the gutter | | Compact priority/source count limit | `annotations/thinning.ts`: optional final `compactLimit`; `compile.ts` gates on outer width and height before coverage/collision thinning | -| Resolved note arithmetic and compatibility exports | `packages/core/src/layout/chrome.ts`: `computeNoteBandHeight` and deprecated `footnoteBandHeight`, exported through core index; vanilla adapter in `renderers/chrome.ts`; renderers/brand consume resolved `noteBandHeight` | +| Resolved note arithmetic | `packages/core/src/layout/chrome.ts`: public `computeNoteBandHeight`; vanilla adapter in `renderers/chrome.ts`; renderers/brand consume resolved `noteBandHeight`. The `footnoteBandHeight` compatibility export is removed in v9. | | Exactly-once facet grow metadata | `packages/engine/src/compile.ts` facet assembly and `layout/dimensions.ts` populate optional `chrome.grownHeight`; `packages/vanilla/src/mount.ts` deducts engine-owned growth | | Authored mark-option contract | `core/src/types/spec.ts`: discriminated `MarkDef`; `engine/src/compiler/types.ts`: internal wide `NormalizedMarkDef`; `compiler/mark-options.ts`: `collectMarkOptionErrors` / `assertMarkOptions`, checked before sugar with authored JSON Pointer paths | | Mark contract parity matrix | `packages/engine/src/__test-fixtures__/mark-options.ts`: every mark × option plus compile-time union assertions; `compiler/__tests__/mark-options.test.ts`: published schema, strict-null transform, and runtime agreement | -| 8.x default materialization | `packages/engine/src/migration/v8.ts`: `migrateSpecV8`; parity fixtures in `examples/src/__tests__/migration-parity.test.ts` | +| Migration and optional v8 styling materialization | `packages/engine/src/migration/v8.ts`: `migrateSpecV8`; default preserves v8 styling, `{ styling: 'v9' }` applies alias rewrites only. Contract fixtures in `examples/src/__tests__/migration-parity.test.ts`; no pixel-layout parity promise. | | Layout opt-in browser fixtures | `examples/src/gallery/features-annotations.demos.ts` and `.stories.tsx`: ReferenceLabels, CrowdedCallouts, NotesSizing; `e2e/annotation-invariants/layout.spec.ts` covers geometry, note counts, heights, SVG/PNG and canvas | The seven annotation colors are resolved theme fields in `core/src/types/theme.ts`, @@ -419,4 +422,20 @@ registered with dark pairs in `theme/resolve.ts` and `theme/dark-mode.ts`, and m by `styles/token-definitions.ts` and generated CSS. Existing generic annotation colors retain their previous uses. -V9 editing: `ChartEventHandlers.onEdit` takes `EditTransaction`; `packages/vanilla/src/interactions/gesture-edit.ts` is the internal gesture union (not a package export). `annotation-nudges.ts` previews arrows/Shift+arrows and commits 500ms after release; `mount.ts` cycles selection with Tab/Shift+Tab. +## v9 current contract additions and removals + +| Concern | Source / exports | +|---|---| +| Transaction-only edit events | `core/src/types/events.ts`: `ChartEventHandlers.onEdit(EditTransaction)`; internal `vanilla/src/interactions/gesture-edit.ts` feeds `edit-transactions.ts`. `ElementEdit` and `onEditTransaction` are removed. | +| Keyboard annotation editing | `vanilla/src/interactions/annotation-nudges.ts` previews arrows/Shift+arrows and commits 500ms after release; `mount.ts` handles Tab/Shift+Tab selection and batch flush/cancel paths. | +| Linked notes and markers | `vanilla/src/interactions/annotation-notes.ts`: `wireAnnotationNotes`; renderer `data-note-spec-index` / `data-note-marker-index` identity links pointer/focus/keyboard emphasis without changing placement. | +| Removed-spec validation | `engine/src/compiler/removed-spec-fields.ts`: `collectRemovedSpecErrors`; called before sugar and by `validateSpec`, with authored paths. | +| Shared bounded field inference | `core/src/helpers/spec-builders.ts`: public `inferFieldType`; normalization and chart helpers sample up to 50 values, with numeric strings before temporal matching. The engine duplicate is removed. | +| V9 default and removal guards | `engine/src/annotations/__tests__/v9-defaults.test.ts`, `compiler/__tests__/v9-removed-fields.test.ts`; migration fixtures in `engine/src/__tests__/migrate-spec-v8.test.ts`. | +| Connector crossing score | `engine/src/annotations/connector-crossings.ts`: internal `ConnectorCrossings`; geometry tests in `annotations/__tests__/connector-crossings.test.ts`. | +| Former line label implementation | `engine/src/charts/line/labels.ts` removed; endpoint columns use `engine/src/endpoint-labels/` and `vanilla/src/renderers/endpoint-labels.ts`. | + +The migration guide records remaining type/field removals. The built-in `editorial` +preset, public `Theme`, `footnoteBandHeight`, and responsive padding-cap constants +are removed. CSS defaults and theme configuration remain supported for all mount +families; `clarity` is a deprecated colors-only migration bridge. diff --git a/docs/dashboards.md b/docs/dashboards.md index 5ad02144..53028b2f 100644 --- a/docs/dashboards.md +++ b/docs/dashboards.md @@ -120,7 +120,7 @@ const spec: BarListSpec = { }; ``` -`TableSpec` tiles should set `density: 'condensed'` for reduced padding and font size (40px rows instead of 48px), and keep row counts small enough that the tile doesn't grow a second scrollbar inside a dashboard card. `compact: true` still works as an alias but warns — prefer `density` directly. Tables resolve `watermark` on their own (see [Watermark rules](#watermark-rules) above), so set it explicitly: +`TableSpec` tiles should set `density: 'condensed'` for reduced padding and font size (40px rows instead of 48px), and keep row counts small enough that the tile doesn't grow a second scrollbar inside a dashboard card. `compact` is removed in v9; use `density` directly. Tables resolve `watermark` on their own (see [Watermark rules](#watermark-rules) above), so set it explicitly: ```ts const spec: TableSpec = { diff --git a/docs/design-evolution.md b/docs/design-evolution.md index 93aa6b9e..8dd474b4 100644 --- a/docs/design-evolution.md +++ b/docs/design-evolution.md @@ -4,7 +4,7 @@ OpenChart's direction is editorial clarity, continuous interaction, and explaina ## Delivery and evidence -This record distinguishes implementation from release validation. No comprehension, authoring, or performance improvement is claimed without measurements. Breaking defaults remain reserved for the next major release. +This record distinguishes implementation from release validation. No comprehension, authoring, or performance improvement is claimed without measurements. Breaking defaults are recorded separately from publication and release validation. | Phase | Scope retained | Status | | --- | --- | --- | @@ -53,3 +53,40 @@ Performance targets: 60fps reference motion; desktop p95 frame interval below 33 Human validation (requires real participants, not simulated results): matched baseline comprehension tasks, at least 80% correct, no accuracy regression and 20% lower median response time; at least five representative authors, four completing import/refine/mobile check/undo/reopen/export without JSON. Record completion time and corrections. These gates remain pending until the study is conducted. Technical architecture and product UX reviewers must independently approve the implementation after fix/review cycles before the final commit, merge, and push. This does not substitute for the human release gates. + + +## v9 chart clarity and annotation contract (implementation record) + +The additive Track A work shipped in 8.9.1. The v9 implementation adopts the +accepted chart-clarity plan's defaults and removes its compatibility APIs. This +record describes the library changes in the v9 branch; it does not claim that an +RC or final release has been published, that Studio has adopted it, or that the +human studies above have been conducted. + +| Decision | v9 contract and reason | +| --- | --- | +| Figure budget | Omitted `chromeLayout` adds only the note band; public `grow` and `subtract` retain their meanings. The engine reports total and grown heights so mounts/exports count growth once. Exact-height embeds author `subtract`. | +| Editorial typography | Small/axis/footer text becomes 12px, with 16px chrome-to-chart and 12px chart-to-footer gaps. Background, axis, and eyebrow defaults retain their prior choices. Subtitle ratio becomes 12/13 and a subtitle no longer implies a bold primary label. | +| Annotation placement | Automatic labels use priority then source order, measured wrapping, and connector-crossing costs. Curves no longer imply arrows. Reference labels default to automatic placement unless a legacy anchor is explicitly authored. | +| Compact reading | One eligible inline callout is the compact default; null removes that count limit. Pinned annotations remain inline and may overlap after narrow clamping. Existing chart-level responsive opt-outs retain their behavior. | +| Note identity | Rich primary/subtitle content wraps in the engine. Numbers follow source text-annotation order before filtering, allow gaps, and are shared across facet instances. Linked note/marker emphasis is transient reader state. | +| Surface and ink | D19 reference/range colors are retuned; marker and plate colors derive from the neutral surface. Authored annotation and theme colors remain authoritative. | +| Editing | `onEdit` receives atomic transactions; hosts use `applyEditTransaction` and its inverse. Arrows nudge, Shift accelerates, Tab cycles selection. Engine geometry remains authoritative through preview, commit, animation, and export. | +| Theme composition | Spec, provider, and host overrides deep-merge; leaves override parent themes. Authored chrome sizes beat general font propagation. Arrays/color pairs are atomic and null clears rules. Unauthored CSS tokens remain available to host styles. | +| Authoring grammar | `MarkDef` is a discriminated union with pre-sugar errors and schema/type/runtime parity. Inference has one core implementation. Retired aliases fail validation; endpoint labels replace legacy line-end labels. | +| Migration | Default `migrateSpecV8` rewrites aliases and materializes former styling. `{ styling: 'v9' }` rewrites aliases only. Neither mode promises v8 pixel layout; every repair is inspectable. | + +The implementation retains the portable boundary: core owns pure contracts and +theme/inference helpers, engine owns geometry and layout, and vanilla/framework +packages own rendering and interaction. OpenStudio's v2 envelope, migration +choice, storage recovery, and version adoption remain separate application work. +The external authoring-skill synchronization and publication are separate release +steps, not implied by this implementation record. + +Validation locations: `e2e/annotation-invariants/` owns the two annotation browser +projects; the existing `invariants*` projects keep their original scope. The +preview benchmark is `packages/engine/src/annotations/__tests__/preview.bench.ts`. +Theme and transaction behavior have direct integration tests. Visual baselines +must be reviewed once for the completed v9 composition before release; this +record does not substitute for that review or claim reader-comprehension gains. +See [the migration guide](migrating-v9.md) for the public before/after contract. diff --git a/docs/design-system.md b/docs/design-system.md index 4236f969..b896089b 100644 --- a/docs/design-system.md +++ b/docs/design-system.md @@ -111,12 +111,14 @@ and the mode's `--oc-bg` token (`#ffffff` light, `#09090b` dark) when the background is transparent. Everything that has to knock a hole in the canvas reads it, so there is exactly one answer: knockout rings and stacked-segment seams (`resolveKnockoutColor`), table cell fills (`resolveTableSurface`), -graph node rings (`resolveGraphSurface`), tilemap label contrast, and the -`--oc-bg` the vanilla mount stamps (`resolvedSurface`). +graph node rings (`resolveGraphSurface`), annotation marker/plate fills, and +tilemap label contrast. Authored backgrounds also feed the `--oc-bg` token +stamped by the shared renderer helper (`resolvedSurface`). Source: `packages/core/src/colors/neutral.ts` — `deriveNeutralRamp(text, bg, isDark)`. Computed once in `resolveTheme`/`adaptTheme` and carried on -`ResolvedTheme.colors.neutral`; mounts stamp the CSS tokens from it. The +`ResolvedTheme.colors.neutral`. Mounts stamp authored theme overrides and +their derived tokens; omitted tokens keep the CSS cascade available to hosts. The direction is one-way (theme → CSS): engine code reads `theme.colors.neutral.*` directly and never calls `cssTokenDefault`. When the background is transparent (the default theme's is) the ramp falls back to the static CSS @@ -133,6 +135,9 @@ case. | `colors.gridline` | `rgba(0,0,0,0.08)` | `rgba(255,255,255,0.06)` | | `colors.hairline` | `rgba(0,0,0,0.14)` | `rgba(255,255,255,0.14)` | | `colors.axis` | `#71717a` | `#a1a1aa` | +| `colors.annotationRule` | `#888888` | `#9ca3af` | +| `colors.annotationRangeFill` | `#f0c040` | `#f0c040` | +| `colors.annotationMarkerFill` / `annotationPlate` | Resolved neutral surface | Resolved neutral surface | `axis` is both the axis-line color and the tick-label ink, and must clear WCAG AA (4.5:1) on its background — zinc-500 hits roughly 5.7:1 on white, @@ -148,17 +153,18 @@ Source: `packages/core/src/theme/defaults.ts` (`DEFAULT_THEME`), One weight ladder everywhere: 400 regular (body), 500 medium (eyebrow, labels, UI chrome, values), 600 semibold (title, series names, tooltip -title, annotation lede), 700 bold (`**bold**` spans only). Tabular numerals -apply to every number, including static SVG chrome. +title), 700 bold (`**bold**` spans or explicit author emphasis). Annotation +primary text stays regular when a subtitle is present. Tabular numerals apply +to every number, including static SVG chrome. | Element | Size | Weight | Color | |---|---|---|---| | Title | 26px | 600, -0.022em tracking | `#09090b` | | Subtitle | 14px | 400 | `#71717a` (muted) | | Eyebrow | 11px | 500 | `#06b6d4` (accent) | -| Source / byline / footer | 11px | 400 | `#71717a` | +| Source / byline / footer | 12px | 400 | `#71717a` | | Body | 13px | 400 | — | -| Axis tick / small | 11px | 400 | — | +| Axis tick / small | 12px | 400 | — | | Metric label | 11px | 500 | muted | | Metric value | 22px | 600 | — | @@ -167,14 +173,21 @@ Source: `packages/core/src/theme/defaults.ts` (`fonts.sizes`, `fonts.weights`, fallback stack; nothing is bundled. Monospace is JetBrains Mono, used by the `terminal` preset and any mono-family theme. +Annotation primary text defaults to 13px/400 with a 12px subtitle at that size +(the ratio is 12/13, rounded). Reference/range labels use the theme small size. +Callouts wrap within 240px or the available plot width; compact charts keep one +eligible inline callout by default and move the rest to complete numbered notes. +Curves use quiet leaders unless an arrow is authored. Explicit weights, colors, +widths, and responsive opt-outs remain authoritative. + ## Spacing, lines, and radius | Token | Default | |---|---| | `spacing.padding` | 20px | | `spacing.chromeGap` | 6px | -| `spacing.chromeToChart` | 8px | -| `spacing.chartToFooter` | 8px | +| `spacing.chromeToChart` | 16px | +| `spacing.chartToFooter` | 12px | | `spacing.axisMargin` | 6px | Hairline vs. gridline is a deliberate two-tier system: the hairline (14% @@ -392,18 +405,19 @@ the committed values diverge from the in-progress edit. ## Presets -Five named presets, exported from `@opendata-ai/openchart-core`: +Four named presets, exported from `@opendata-ai/openchart-core`, alongside the default configuration: | Preset | Palette | Background | Title | Radius | Notable | |---|---|---|---|---|---| -| `editorial` | default six-hue | white / `#09090b` | 26/600 | 8px | The default; empty `ThemeConfig` | +| Default (omit theme) | default six-hue | transparent | 26/600 | 8px | Empty `ThemeConfig`; follows the host surface | | `essay` | default | warm paper `#faf8f5` / `#1a1816` | 28/600 | 0px | Serif feel via larger sizes, generous spacing | | `wire` | default | `#ffffff` / `#0a0a0a` | 16/600 | 2px | Dense, tight chrome, dashboard feel | | `broadsheet` | `#1d5f8a #e3120b #7fb0d3 #e0a63b #3a8a6e #8c8680` | warm paper `#fffdf9` / `#171513` | 24/700 | 0px | Red masthead `rule` (40×3px) and eyebrow; hairline 0.28 alpha | | `terminal` | dark-mode categorical variants | `#0b0f14` (both modes) | 15/600 | 4px | `seriesStrategy: 'accent-neutral'`, JetBrains Mono available, dense spacing | -`editorial` is `{}` — every other theme value in this document is its -default. `broadsheet`'s slot-2 red (`#e3120b`, OKLCH chroma 0.232) is a +`editorial` has been removed; omit `theme` or use `{}` for the default. +`clarity` is deprecated and retains only its background, axis, and eyebrow +colors. `legacyEditorial` retains the v8 typography for migrations. `broadsheet`'s slot-2 red (`#e3120b`, OKLCH chroma 0.232) is a deliberate deviation from the palette's usual chroma ceiling: it's the one loud hue in an otherwise quiet ramp (the other five broadsheet slots run 0.011–0.137 chroma), kept because it still clears both CVD checks at a diff --git a/docs/getting-started.md b/docs/getting-started.md index 4ac34b47..5dba26e3 100644 --- a/docs/getting-started.md +++ b/docs/getting-started.md @@ -108,7 +108,7 @@ const spec = { The `encoding` object maps data fields to visual channels. `type` tells the engine how to interpret the values: `temporal` for dates, `quantitative` for numbers, `nominal` for categories. -The chart component fills its parent container. Set width and height on the wrapper element. +Set the wrapper width to the desired embed width. An explicit height supplies the figure budget; omitted `chromeLayout` can grow it by the note band. Set `chromeLayout: 'subtract'` for an exact-height slot. Without a height constraint, the mount uses a 400px visualization budget and adds chrome. See this running: [live example](https://tryopendata.github.io/openchart/?story=charts--line-and-area#single-line) @@ -151,7 +151,7 @@ chrome: { ## Change mark type -Swap `mark: 'line'` for any supported mark type. The encoding channels stay the same, the engine handles the rest. +Swap `mark: 'line'` for any supported mark type. Use the channels required by the new mark: arc, waffle, and parliament use `theta` for values; positional marks use `x`/`y`. Mark-specific options must belong to the chosen mark. ```tsx const spec = { @@ -284,7 +284,7 @@ Dark mode adapts the theme automatically: background, text colors, gridlines, ax ## Custom theme -Override any part of the default theme with a `ThemeConfig` object. The engine deep-merges your overrides onto the defaults, so you only specify what you want to change. +Override any part of the default theme with a `ThemeConfig` object. Spec, provider, and component themes deep-merge in that order; nested fields survive and arrays/color pairs replace atomically. Mount options have highest precedence. Only authored overrides are stamped inline, so omitted CSS tokens remain available to host styles. ```tsx import { Chart, VizThemeProvider } from "@opendata-ai/openchart-react"; @@ -594,7 +594,7 @@ const chart = createChart( }, { darkMode: "auto", - responsive: true, + autoResize: true, }, ); @@ -618,7 +618,7 @@ chart.destroy(); | `export(format)` | Export as `'svg'`, `'png'`, `'jpg'`, `'gif'` (animated, needs the optional `gifenc` peer), or `'csv'` | | `destroy()` | Remove DOM elements, disconnect observers | -Responsive mode (enabled by default) uses a `ResizeObserver` on the container, so charts resize automatically when the container changes size. +`autoResize` (enabled by default) uses `ResizeObserver` so charts resize with their container. The spec `responsive` field controls layout adaptation, not observation. For transaction-based editing and changed defaults, see [Migrating to v9](migrating-v9.md). ### Table diff --git a/docs/integration-guide.md b/docs/integration-guide.md index d995f192..e8020a82 100644 --- a/docs/integration-guide.md +++ b/docs/integration-guide.md @@ -327,7 +327,7 @@ import { VizThemeProvider, Chart, DataTable } from "@opendata-ai/openchart-svelt ``` -Each framework uses its native context system (React context, Vue provide/inject, Svelte context). A `theme` prop on a component takes precedence over the context theme. +Each framework uses its native context system (React context, Vue provide/inject, Svelte context). A component `theme` prop deep-merges over the context theme, preserving unrelated nested provider values. Arrays and light/dark pairs replace atomically. ### Reading the theme in custom components @@ -394,7 +394,7 @@ createGraph(container, graphSpec, { watermark: false }); createSankey(container, sankeySpec, { watermark: false }); ``` -Spec-level `watermark` takes precedence over the mount option when both are set. +The mount `watermark` option takes precedence over the spec and active breakpoint values when both are set. Chart-family specs also auto-hide the watermark when the compiled container is under 200px tall — chrome is already hidden at that height, so the brand band would paint over the plot. Set `watermark` explicitly (top level or in an active breakpoint override) to opt back in; an explicit `watermark: true` in that cramped state reserves a compact brand band instead of overlapping the chart. This auto-hide is scoped to charts — sankey, tilemap, graph, geo-map, and table watermarks are unaffected and still need an explicit `watermark: false`. @@ -576,6 +576,18 @@ import type { MarkEvent } from "@opendata-ai/openchart-core"; // event.event - the raw browser MouseEvent ``` +### Persisting edits + +In edit mode, `onEdit` receives one `EditTransaction` per committed gesture. +Apply it atomically with `applyEditTransaction` from core, persist the returned +spec, and retain the inverse for undo. A rejected transaction leaves the original +unchanged. Do not persist transient selection, note emphasis, or legend visibility +as gesture edits. Cancelled pointer gestures emit nothing. + +React uses `onEdit`, Vue uses `@edit`, and Svelte uses `onedit`. The former +`onEditTransaction` and element-edit callbacks are removed. See the complete +[persistence example and keyboard contract](migrating-v9.md#editing-and-keyboard-navigation). + ### Click-to-drill-down pattern ```tsx @@ -1002,13 +1014,13 @@ The responsive system also uses breakpoints to adjust layout strategy at smaller - **Label density**: Fewer labels at smaller widths to avoid overcrowding - **Legend position**: May shift from inline to top/bottom at narrow widths - **Annotation placement**: Adjusts for available space -- **Tables**: Auto-apply compact mode at small breakpoints +- **Tables**: Adapt density and switch to the chosen mobile layout ### Container sizing The `` and `` components render a wrapper div set to `width: 100%; height: 100%`. They fill whatever container you put them in. -For charts, the container needs an explicit height (charts don't have intrinsic height): +An explicit height supplies the chart's figure budget. To keep an exact outer height even when annotations produce notes, set `chromeLayout: 'subtract'` on the spec. Without a height constraint, charts use a 400px visualization budget and add chrome: ```tsx { @@ -1029,7 +1041,7 @@ For charts, the container needs an explicit height (charts don't have intrinsic
; { - /* Won't work: no height constraint, chart collapses to minimum */ + /* Automatic height: 400px visualization budget plus chrome */ }
@@ -1052,7 +1064,7 @@ createChart(container, spec, { autoResize: false }); ### Forcing size for export -When you need a chart at specific dimensions (for image export, thumbnails, etc.), set the container to the exact size you want, disable responsive, and export: +For exact export dimensions, set the container size, use `chromeLayout: 'subtract'` to prevent note-band growth, and disable automatic resize: ```ts const container = document.createElement("div"); @@ -1060,7 +1072,7 @@ container.style.width = "1200px"; container.style.height = "800px"; document.body.appendChild(container); -const chart = createChart(container, spec, { autoResize: false }); +const chart = createChart(container, { ...spec, chromeLayout: 'subtract' }, { autoResize: false }); const pngBlob = await chart.export("png", { dpi: 2 }); chart.destroy(); diff --git a/docs/migrating-v9.md b/docs/migrating-v9.md new file mode 100644 index 00000000..9c5aea1b --- /dev/null +++ b/docs/migrating-v9.md @@ -0,0 +1,192 @@ +# Migrating to OpenChart v9 + +v9 makes the annotation layout options introduced in 8.9.1 the default, consolidates theme precedence, and removes the deprecated authoring and editing APIs. Upgrade all six OpenChart packages together. This guide covers the library; OpenStudio's document envelope, migration dialog, and adoption are separate work. + +## Choose how older documents should look + +Run the public engine migration on the original authored document, before compiling it: + +```ts +import { migrateSpecV8 } from '@opendata-ai/openchart-engine'; + +// Default: rewrite retired fields and materialize former v8 styling defaults. +const preserved = migrateSpecV8(legacyDocument); + +// Rewrite retired fields, leaving omitted defaults to v9. +const adopted = migrateSpecV8(legacyDocument, { styling: 'v9' }); + +// Present these changes to the author or record them with the document. +console.table(preserved.repairs); +const spec = preserved.spec; +``` + +The helper is pure and idempotent. Repairs report `path`, `from`, `to`, and `reason`. Explicit authored values, including `false` and `null` opt-outs, remain authoritative. Keep the original document until you have reviewed and saved the result. The helper migrates spec data; it cannot rewrite JavaScript callbacks, TypeScript imports, or host CSS. + +The default styling mode writes former annotation weights, subtitle sizes, curve arrows, unlimited wrapping/compact limits, legacy reference anchors, `chromeLayout: 'subtract'`, and v8 theme typography, spacing, and annotation colors. `legacyEditorial` remains available for v8 typography alone; it is not a full document migration. + +**Keeping v8 styling does not promise pixel-identical v8 layout.** Placement scoring, note numbering, theme composition, rich-text notes, and other v9 layout rules still apply. Review migrated charts at their actual embed sizes. In particular, the legacy series-label rewrite moves labels into the endpoint column and drops manual per-series offsets; each dropped offset appears in `repairs`. + +## Changed annotation and figure defaults + +| Setting | v8 default | v9 default / explicit override | +| --- | --- | --- | +| Small and axis tick text | 11px | 12px; set `theme.fonts.sizes.small` / `axisTick` explicitly | +| Source, byline, footer | 11px | 12px; set `theme.chrome..fontSize` | +| Chrome-to-chart / chart-to-footer gap | 8px / 8px | 16px / 12px; set the corresponding `theme.spacing` fields | +| Subtitled callout primary weight | 700 | 400; set `fontWeight: 700` for a bold lede | +| Subtitle size | `round(fontSize * 0.85)` | `round(fontSize * 12 / 13)`; set `subtitleFontSize` to override | +| Curve connector arrow | Implied | Off; use `connector: { type: 'curve', arrow: true }` | +| Labeled reference line | Legacy anchor placement | Automatic placement if neither `labelPlacement` nor `labelAnchor` is authored | +| Callout wrapping | Unlimited | Up to `min(240, plotWidth - 2 * padding)` pixels, with a positive minimum; `maxWidth: null` disables wrapping limits | +| Compact inline callout limit | Unlimited | One eligible text callout; `responsive.compactAnnotationLimit: null` removes this count limit | +| Reference/range label size | 11px | Theme `small`; explicit annotation `fontSize` wins | +| Reference rule ink | `#666666` in both modes | `#888888` light / `#9ca3af` dark | +| Range fill | `#000000` | `#f0c040`; opacity behavior is unchanged | +| Marker/label-plate fill | Fixed white / near-black | Resolved neutral surface, unless explicitly authored | + +Callouts with `responsive: false` remain inline and do not count toward the compact limit. The limit uses the **outer container width**, including facets, at the compact breakpoint of 400px. It does not override the existing behavior of chart-level `responsive: false`, `responsive.autoThin: false`, or fixed-size specs: those still hide unpinned annotations at compact widths. Heights below 200px retain tooltip-only behavior. + +Automatic placement processes higher-priority annotations first, breaking ties by authored source order. Placement also penalizes connector crossings. Those choices can move labels even when you preserve v8 styling. Explicit offsets remain pinned, including `{ dx: 0, dy: 0 }`. + +Omitting `chromeLayout` now grows the figure by its note band while title, subtitle, source, and other chrome retain their existing subtraction behavior. The internal name for this mode is `notes`; it is **not** an authored spec value. + +- Use `chromeLayout: 'subtract'` when an embed needs an exact outer height. +- Use `chromeLayout: 'grow'` to grow by all chrome, including facets. +- Read `layout.dimensions.height` for the resulting height. Engine-added height is reported in `layout.chrome.grownHeight`; mounts already account for it once. + +The note band includes annotation notes and `seriesSummary`. Its reserved source capacity stays stable during annotation drags, so it can contain unused space. Use `layout.chrome.noteBandHeight`; the old core and vanilla `footnoteBandHeight` helpers are removed. + +## Notes and annotation identity + +Notes include the complete primary text and subtitle, with wrapping and `**bold**` spans. Numbers are source-order ordinals of text annotations, assigned before filtering and thinning. Gaps are intentional: note 4 remains note 4 when another note is hidden. Inserting, deleting, or reordering authored annotations can renumber them. Facet instances share one source number and note. + +Notes and their markers support linked focus/click/tap emphasis. This is transient reader state, not a spec edit, and is excluded from exports. Authored unique annotation IDs remain the recommended durable identity. Missing or duplicate IDs use revision-local source indices and produce diagnostics. + +A label drag converts resolved placement into an explicit anchor/offset and sets `responsive: false` in one transaction. The annotation then stays inline on narrow screens; the position is clamped and can overlap other pinned annotations. Changing the data attachment remains a separate operation. + +### Editing and keyboard navigation + +`onEdit` now receives an `EditTransaction`. The old `ElementEdit` union and the +additive `onEditTransaction` callback are removed. Apply the complete transaction +atomically; its inverse supports one undo entry per gesture. + +```ts +// v8 additive API +createChart(container, spec, { onEditTransaction: persist }); + +// v9 +import { applyEditTransaction } from '@opendata-ai/openchart-core'; + +const chart = createChart(container, spec, { + onEdit(transaction) { + const result = applyEditTransaction(spec, transaction); + if (!result.ok) return; // Stale or invalid target: keep the current document. + spec = result.spec; + undoStack.push(result.inverse); + chart.update(spec); + }, +}); +``` + +React uses `onEdit`, Vue uses `@edit` (or `onEdit`), and Svelte uses `onedit`. +`revision` still cancels an active gesture when the document revision changes. +Selection and transient visibility remain separate: use `onSelect`, `onDeselect`, +and `onLegendToggle`; legend toggles do not emit durable transactions. + +In edit mode, arrow keys nudge the selected text annotation 1px; Shift+arrow nudges +10px. Tab/Shift+Tab cycle editable elements. A sequence of nudges commits 500ms +after the last arrow release, or immediately before selection, another gesture, +or cancellation. Native text-editing keys keep their usual behavior. + +Removed APIs and their replacements: + +| Removed | Replacement | +| --- | --- | +| `onAnnotationEdit`, `onTextEdit` | `onEdit` transaction operations | +| `onDataPointClick` | `onMarkClick` (the old callback had no effect) | +| Chart mount/wrapper `responsive` option | `autoResize` (spec `responsive` is unchanged) | +| Graph `updateVisuals()` | `update()` | +| `ElementRef` series-label variant and `elementRef.seriesLabel()` | Endpoint labels; manual endpoint-label offsets are not supported | + +Other mount families retain their existing `responsive` resize option. + +`applyEditTransaction` validates the whole operation list before returning a new spec. A failed transaction leaves the input unchanged. Annotation `expect` preconditions carry identity/type/text checks, so a stale edit cannot silently target a reordered ID-less annotation. The helper does not compare `revision`; hosts may use it for their own bookkeeping. + +## Theme composition + +Authored theme layers now deep-merge: spec theme, provider theme, then component prop or mount/compile option. In a layer, the leaf theme overrides its parent before host overrides apply. This preserves unrelated nested fields that a shallow merge previously discarded. + +Arrays and `{ light, dark }` color pairs replace atomically. `colors: string[]` remains shorthand for `colors.categorical`. An authored `chrome..fontSize` wins over propagation from the general `fonts.sizes` scale, even when those values originate in different layers. Presets no longer duplicate general title sizes in chrome defaults. + +```ts +import { broadsheet, mergeThemeConfig } from '@opendata-ai/openchart-core'; + +// Preserve the preset, changing only the rule length. +const wideRule = mergeThemeConfig(broadsheet, { rule: { width: 80 } }); +// Explicitly remove its rule. +const noRule = mergeThemeConfig(broadsheet, { rule: null }); +``` + +`rule` accepts a partial object or `null`. Without a preset rule, omitted width/thickness resolve to 40px/3px and color uses theme text ink. The mount/compile `watermark` option overrides spec and breakpoint values. The graph mount `legend` option also takes precedence over the spec. + +Use `ThemeConfig` for authored input and `ResolvedTheme` for compiled output; `Theme` is removed. The empty `editorial` preset is removed: omit `theme` or pass `{}`. `clarity` is deprecated for removal in v10 and now contains only its remaining background, axis, and eyebrow colors. The default background is still transparent; the default axis/eyebrow colors have not adopted clarity's colors. + +Only authored theme tokens and their derived values are stamped inline. Omitted tokens stay available to host CSS, including `--oc-border-radius`. Removing an authored override restores the host value. Dark-mode defaults come from generated CSS; authored light/dark pairs still win. SVG/raster export preserves the live CSS token values before leaving the page. See [the token guide](theming-tokens.md). + +## Retired spec forms and helpers + +These retired authoring forms are now validation errors. `migrateSpecV8` handles their spec rewrites in either styling mode. + +| Removed form | Replacement | +| --- | --- | +| Table `compact: true` | `density: 'condensed'`; `false` becomes `'regular'` | +| `valueFormat` on Sankey/barlist | `encoding.value.format` | +| `valueFormat` on maps | `encoding.color.format` | +| Annotation `type: 'rule'` | `type: 'refline'` | +| Arc/waffle/parliament authoring through `encoding.y` | `encoding.theta` | +| `theme.chrome.title: '#333'` and other theme chrome strings | `theme.chrome.title: { color: '#333' }` | +| Spec `darkMode` | Mount/component `darkMode` option | +| `mark.render` | Mount/component `renderer` option | +| `encoding.radius`, `shape`, `href`, `order` | Remove them; these channels had no renderer | +| `animation: 'legacy'` / `animation.preset: 'legacy'` | Use `true` / omit `preset`; omitting animation itself still disables it | +| `labels.offsets` and legacy line-end series labels | `endpointLabels` | +| `TOP_PAD_NARROW_MAX`, `LABEL_GAP_NARROW_MAX` imports | `NARROW_VIEWPORT_MAX` | + +The spec-level chrome text shorthand **stays**: `chrome: { title: 'A title' }` is valid. Tilemap `valueFormat` also stays, including for record data with no encoding channel. `colors: string[]`, ordinary Vega-Lite sugar, and chart-type value-label configuration remain supported. + +Host table styles targeting `.oc-table--compact` must target `.oc-table--condensed` instead. `TableLayout.compact` is removed; read `density`. + +For multi-series lines, the traditional legend and endpoint column are now the two series identifiers. If both are explicitly off, the compiler emits an accessibility warning. The old fallback of setting `legend: false`, `endpointLabels: false`, and relying on line-end labels no longer renders those labels. Migration replaces that fallback with: + +```ts +endpointLabels: { content: 'label', showMarker: false } +``` + +Series ending before the last x value move to the right-edge column. Manual legacy label offsets are dropped; endpoint-label dragging is not part of v9. + +## Mark-specific options and field inference + +`MarkDef` is now a discriminated union. Options must belong to the authored mark: density `bandwidth`, for example, is not a bar option. Validation checks authored paths before sugar expands histogram/density, and agrees with the published JSON Schema and TypeScript union. Correct the mark or remove the inapplicable option; the migration helper does not guess the intended mark. + +A union cannot be extended with an interface. Replace: + +```ts +// v8 +interface AppMark extends MarkDef { + editorLabel?: string; +} +``` + +with: + +```ts +// v9 +import type { MarkDef } from '@opendata-ai/openchart-core'; +type AppMark = MarkDef & { editorLabel?: string }; +``` + +Builders and compilation share one field-inference function. It examines up to 50 rows, ignores nulls, and requires more than 80% agreement. Numeric strings are considered numeric before date parsing, so an untyped column containing `'2020'` is quantitative. Set `encoding..type` explicitly when numeric-looking years should be ordinal or temporal. This avoids relying on inference for mixed columns. + +## Validate the upgrade + +Review exact-height embeds, narrow charts, custom fonts/themes, layers/facets, and exported SVG/PNG. Exercise one edit, undo, cancelled drag, and keyboard nudge with the new transaction handler. Test application persistence against stale annotation identity. Library migration does not upgrade OpenStudio storage or publish packages; those release steps have their own validation. diff --git a/docs/spec-reference.md b/docs/spec-reference.md index afd2117f..d250acfe 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -68,12 +68,12 @@ The primary input for standard chart types. Source: `core/src/types/spec.ts`. | `data` | `DataRow[]` | (required) | Array of data rows. Each row is a `Record`. Must be non-empty. | | `encoding` | `Encoding` | (required) | Maps data fields to visual channels. See [Encoding](#encoding). | | `chrome` | `Chrome` | `undefined` | Editorial text: title, subtitle, source, byline, footer. See [Chrome](#chrome). | -| `chromeLayout`| `'subtract' \| 'grow'` | `'subtract'` | How chrome text interacts with the container height budget. See [Chrome layout](#chrome-layout). | +| `chromeLayout`| `'subtract' \| 'grow'` | note-band growth | How chrome text interacts with the container height budget. See [Chrome layout](#chrome-layout). | | `annotations` | `Annotation[]` | `undefined` | Text callouts, highlighted ranges, reference lines. See [Annotations](#annotations). | | `labels` | `LabelConfig` | `undefined` | Data label display controls. See [Labels](#labels). | -| `responsive` | `boolean` | `true` | Whether the chart adapts to container width via ResizeObserver. | +| `responsive` | `boolean \| ResponsiveSpec` | `true` | Layout adaptations at compact sizes; mount `autoResize` controls ResizeObserver. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. Deep-merged onto the default theme. See [ThemeConfig](#themeconfig). | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. Defaults to `true`; auto-hides when the container is under 200px tall unless explicitly set. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. Defaults to `true`; auto-hides when the container is under 200px tall unless explicitly set. | | `animation` | `AnimationSpec`| `undefined` | Animation configuration. `true` enables entrance + update/exit animations. See [Animation](#animation). | | `description` | `string` | `undefined` | Alt text for the chart (Vega-Lite aligned). Sugar for `a11y.description`. Auto-generated when absent. See the [accessibility guide](accessibility.md). | | `a11y` | `A11yConfig` | `undefined` | Accessibility overrides: `description` (custom alt text, wins over top-level `description`) and `hidden` (hide from assistive technology). | @@ -88,6 +88,8 @@ A plain object with string keys. Values can be numbers, strings, dates, nulls, a ### Mark properties +`MarkDef` is a discriminated union: each object accepts only options for its mark type. TypeScript, JSON Schema, and runtime validation enforce the same contract before sugar expansion. Extend it with an intersection type rather than an interface. + The `mark` field on ChartSpec accepts either a string (`'line'`) or an object with additional mark configuration. LayerSpec children are ChartSpecs, so they use `mark` too. ```ts @@ -234,6 +236,7 @@ interface Encoding { y?: EncodingChannel; color?: EncodingChannel; size?: EncodingChannel; + theta?: EncodingChannel; // arc/waffle/parliament quantitative value detail?: EncodingChannel; facet?: FacetChannel; // small multiples -- see Faceting row?: FacetChannel; // vertical facet stack -- see Faceting @@ -311,14 +314,14 @@ The engine validates encoding channels at runtime using `MARK_ENCODING_RULES`. S | `area` | temporal, ordinal (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Same encoding as line, filled region | | `bar` | quantitative (req) | nominal, ordinal (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Horizontal bars. x = values, y = categories | | `column` | nominal, ordinal, temporal (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Vertical bars. x = categories, y = values | -| `pie` | -- (opt, unused) | quantitative (req) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | color = slice categories, y = slice values | -| `donut` | -- (opt, unused) | quantitative (req) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | Same as pie with inner radius | +| `pie` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | color = slice categories, theta = slice values | +| `donut` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | theta = slice values; same as pie with inner radius | | `dot` | quantitative (req) | nominal, ordinal (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Dot plot. x = values, y = categories | | `scatter` | quantitative (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Both axes quantitative. size creates bubble chart | | `beeswarm` | quantitative or nominal (opt) | quantitative or nominal (opt) | nominal, ordinal, quantitative (opt) | quantitative (opt) | nominal (opt) | One position is the quantitative value axis; the other (nominal/ordinal) splits into grouped lanes | | `range` | quantitative or nominal (req) | quantitative or nominal (req) | nominal, ordinal (opt) | -- | nominal (opt) | Dumbbell/arrow/range-bar. x+x2 (horizontal) or y+y2 (vertical); orientation-dependent second endpoint | -| `waffle` | -- (opt, unused) | quantitative (req, via `theta`) | nominal, ordinal (req) | -- | nominal (opt) | Unit grid. `theta` (alias for y) = share, color = category | -| `parliament` | -- (opt, unused) | quantitative (req, via `theta`) | nominal, ordinal (req) | -- | nominal (opt) | Hemicycle seats. `theta` (alias for y) = seat count, color = party | +| `waffle` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | -- | nominal (opt) | Unit grid. `theta` = share, color = category | +| `parliament` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | -- | nominal (opt) | Hemicycle seats. `theta` = seat count, color = party | | `histogram` | quantitative (req) | quantitative (opt) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Raw values on x; y defaults to the per-bin count. Color groups overlap rather than dodge | | `density` | quantitative (req) | -- (computed) | nominal, ordinal (opt) | -- | nominal (opt) | KDE over raw x values; y is the estimated density | | `calendar` | temporal (req) | -- (not allowed) | quantitative (req) | -- | nominal (opt) | Calendar heatmap. x = daily date, color = per-day value; owns its own geometry | @@ -329,6 +332,8 @@ The engine validates encoding channels at runtime using `MARK_ENCODING_RULES`. S **y channel**: Vertical position. Quantitative values for most types. Nominal/ordinal categories for bar and dot (horizontal layout). +**theta channel**: Quantitative part-to-whole values for arc, waffle, and parliament. Their former authored `y` alias is rejected; use `theta`. + **color channel**: Series differentiation. Assigns colors from the categorical palette. Required for pie/donut (defines slices). Optional for all others (creates multi-series with legend). **size channel**: Mark size. Creates bubble charts when applied to scatter. Maps a quantitative field to the dot **radius in pixels**, area-proportionally (sqrt curve); the default range is [3, 30] and `scale.range` overrides it in radius px. A constant lives at `mark.size`, also a radius (default 5, typical 2-12). Note this differs from Vega-Lite, where point `size` is an **area** in px² — a VL size of 110 is `mark.size: 6` here (r = √(area/π)); validation rejects radii above 50 with the conversion. @@ -422,12 +427,12 @@ Each field accepts either a plain string or a `ChromeText` object for style over | Field | Position | Default style | | ---------- | ------------------------------- | ------------------------------------------------------------ | -| `eyebrow` | Above title | 11px, semibold, uppercase, theme accent — leading accent dot | -| `title` | Top, above chart | 22px, bold (700), `#333333` | -| `subtitle` | Below title | 15px, normal (400), `#666666` | -| `source` | Below chart area | 12px, normal (400), `#999999` | -| `byline` | Below source | 12px, normal (400), `#999999` | -| `footer` | Below byline | 12px, normal (400), `#999999` | +| `eyebrow` | Above title | 11px, medium (500), uppercase, theme accent — leading accent dot | +| `title` | Top, above chart | 26px, semibold (600), `#09090b` | +| `subtitle` | Below title | 14px, normal (400), `#71717a` | +| `source` | Below chart area | 12px, normal (400), `#71717a` | +| `byline` | Below source | 12px, normal (400), `#71717a` | +| `footer` | Below byline | 12px, normal (400), `#71717a` | | `brand` | Right-aligned on the source row | 12px, suppresses the default watermark | **KPI metric row** is a separate top-level field on chart specs, not part of `Chrome`. Use `metrics: Metric[]` on the chart spec for a horizontal row of label+value cells rendered between the subtitle and the chart area. Each cell can carry a delta and a secondary value. See `Metric` in `core/src/types/spec.ts` for the full shape. @@ -481,7 +486,8 @@ The top-level `chromeLayout` property controls how chrome text height interacts | Value | Behavior | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `'subtract'` | Default. The container height is fixed and chrome text (title, subtitle, source) is subtracted from it, shrinking the plot. Historical behavior. | +| omitted | Notes and the series summary grow the figure; title, subtitle, source, and other chrome subtract from the container height budget. | +| `'subtract'` | Keep the exact outer height; all chrome and notes subtract from it, shrinking the plot. | | `'grow'` | The container height is treated as the plot budget. The rendered SVG grows taller by the measured chrome height, so the plot keeps its full budget (minus axis and padding overhead) no matter how many lines the title wraps to. | Reach for `'grow'` on fixed-height article or blog charts where a long title on a narrow phone must not compress the plot. @@ -496,7 +502,7 @@ Reach for `'grow'` on fixed-height article or blog charts where a long title on } ``` -`chromeLayout: 'grow'` is a no-op for faceted (small-multiples) specs in this version and falls back to `'subtract'`. It is also honored by bar list, sankey, map, and tilemap specs via the spec or compile option. +`chromeLayout: 'grow'` supports faceted charts and layers; engine-added height is counted once. Read `layout.dimensions.height` for the final height and `layout.chrome.grownHeight` for the added height. The default note-growth policy is internal: `'notes'` is not a public spec value. Bar list, Sankey, map, and tilemap also honor the public spec/compile policies. --- @@ -533,6 +539,8 @@ Carrying both bin edges matters: the end edge goes to `x2`, which is what puts t Data-positioned overlays. Source: `core/src/types/spec.ts`. +Use unique annotation `id` values for durable selection and editing. Notes preserve complete primary/subtitle text and rich spans; their numbers follow source text-annotation order before filtering, so gaps are intentional. Notes and markers support linked focus and click/tap emphasis. + Three annotation types are available, discriminated by the `type` field: ### TextAnnotation @@ -546,15 +554,17 @@ A callout label positioned at a data coordinate. | `y` | `string \| number` | (required) | Y-axis data value or position. | | `text` | `string` | (required) | The annotation text. Supports `\n` for line breaks and `**bold**` spans. | | `subtitle` | `string` | `undefined` | Optional muted second-tone text rendered below `text`. Use for supporting context (methodology, source, etc.). Supports `**bold**` too. | -| `maxWidth` | `number \| null` | unlimited | Optional positive wrapping width in pixels; null disables wrapping. | -| `subtitleFontSize` | `number` | `round(fontSize * 0.85)` | Optional positive subtitle size in pixels. | +| `maxWidth` | `number \| null` | `min(240, plotWidth - 2 * padding)`, at least 1px | Positive display wrapping width in pixels; null disables wrapping. | +| `subtitleFontSize` | `number` | `round(fontSize * 12 / 13)` | Optional positive subtitle size in pixels. | | `dot` | `boolean \| AnnotationDot` | see below | Open-ring marker on the data point. Appears by default on non-arrowed connectors. `false` for none, an object to restyle. | | `label` | `string` | `undefined` | Additional label text. | | `fontSize` | `number` | `13` | Font size override in pixels. | -| `fontWeight` | `number` | `400` (`700` with a `subtitle`) | Font weight override. | +| `priority` | `number` | source order | Lower values get placement and compact-inline preference; ties retain source order. | +| `responsive` | `boolean` | `true` | `false` pins the annotation inline and exempts it from thinning/count limits. | +| `fontWeight` | `number` | `400` | Font weight override. | | `offset` | `AnnotationOffset` | `undefined` | Pixel offset from computed position. `{ dx?: number, dy?: number }`. | | `anchor` | `AnnotationAnchor` | `'auto'` | Label placement direction: `'top'`, `'bottom'`, `'left'`, `'right'`, `'auto'`. | -| `connector` | `boolean \| ConnectorType \| ConnectorConfig` | `true` | `'straight'`, `'curve'`, or `'drop-line'`; `{ type, arrow? }` for explicit arrow control; `false` disables it. | +| `connector` | `boolean \| ConnectorType \| ConnectorConfig` | `true` | `'straight'`, `'curve'`, or `'drop-line'`; `{ type, arrow?, bend? }` for explicit control; `false` disables it. | | `connectorOffset` | `{ from?, to? }` | `undefined` | Per-endpoint pixel offsets for the connector line. | | `background` | `string \| true` | `undefined` | Background plate behind the text, for readability over chart lines. `true` follows the theme surface. | | `fill` | `string` | theme `annotationFill` | Fill color. | @@ -562,7 +572,7 @@ A callout label positioned at a data coordinate. | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. Higher values render on top. | -#### Text: line breaks, bold spans, and the lede rule +#### Text: wrapping, line breaks, and bold spans Curve connectors accept optional `bend: -1..1`; omitted or zero preserves the existing automatic curve. Other connector types ignore `bend` with a warning. @@ -578,14 +588,14 @@ An authored `offset` object pins placement even when both values are zero. Only matched pairs turn bold. An unmatched `**` (and an empty `****`) renders literally, so specs with stray asterisks aren't mangled. Bold spans are measured at weight 700, so bounds, collision nudging, and connector exits all account for them. -When a `subtitle` is present and you set no `fontWeight`, the primary text resolves to weight 700 and the subtitle stays 400. That's the lede + context stack ("Feb. 25" over "2015 maximum"). Setting `fontWeight` explicitly opts out. +Primary text and subtitles default to weight 400. Set `fontWeight: 700` for a bold primary line or use `**bold**` for selective emphasis; adding a subtitle does not change the primary weight. #### Connectors The connector leaves the text block (primary text plus subtitle, treated as one box) on the side facing the data point, with a 6px standoff gap, and stops short of the marker at the other end. Two voices: -- **Emphasis.** An arrowed connector (`'curve'` defaults to `arrow: true`) is drawn in the label's text ink with a small open-V arrowhead, so the gesture reads as part of the sentence. -- **Quiet.** A non-arrowed straight leader or a `'drop-line'` is a gray hairline. It points without competing with the words. +- **Emphasis.** An explicitly arrowed connector (`{ type: 'curve', arrow: true }`) is drawn in the label's text ink with a small open-V arrowhead, so the gesture reads as part of the sentence. +- **Quiet.** A non-arrowed curve, straight leader, or `'drop-line'` is a gray hairline. It points without competing with the words. Connectors are dropped when they'd be noise rather than signal: a leader shorter than 8px, or one whose data point sits inside the text block. The marker still renders. A default annotation sits far enough off its point (28px) to draw a real leader without any `offset` authoring; you only lose the line by pulling the label back onto its own marker. @@ -594,7 +604,7 @@ Connectors are dropped when they'd be noise rather than signal: a leader shorter | Field | Type | Default | Description | | ------------- | -------- | --------------------------- | --------------------------------- | | `radius` | `number` | `4` | Circle radius in pixels. | -| `fill` | `string` | theme background | Interior fill (the "open ring"). | +| `fill` | `string` | theme `annotationMarkerFill` (neutral surface by default) | Interior fill (the "open ring"). | | `stroke` | `string` | the connector's stroke | Ring color. | | `strokeWidth` | `number` | `1.5` | Ring width in pixels. | @@ -615,9 +625,9 @@ A highlighted band or rectangle. | `label` | `string` | `undefined` | Range label text. | | `labelOffset` | `AnnotationOffset` | `undefined` | Pixel offset for the label. `{ dx?: number, dy?: number }`. | | `labelAnchor` | `AnnotationAnchor` | `'auto'` | Label placement direction. | -| `fontSize` | `number` | `11` | Font size override for the range label (px). | +| `fontSize` | `number` | theme `small` (12px) | Font size override for the range label (px). | | `fontWeight` | `number` | `500` | Font weight override for the range label. | -| `fill` | `string` | theme `annotationFill` | Fill color. | +| `fill` | `string` | theme `annotationRangeFill` (`#f0c040`) | Fill color. | | `stroke` | `string` | `undefined` | Stroke color. | | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. | @@ -642,11 +652,11 @@ A horizontal or vertical reference line. | `strokeWidth` | `number` | `1` | Line width in pixels. | | `labelOffset` | `AnnotationOffset` | `undefined` | Pixel offset for the label. | | `labelAnchor` | `AnnotationAnchor` | `'auto'` | Label placement direction. | -| `labelPlacement` | `'auto' \| 'start' \| 'end' \| 'outside'` | legacy anchor behavior | Opt-in screen-space end selection, automatic containment, or a reserved outside gutter/lane. `labelAnchor` selects the side; an authored offset pins the base. | -| `fontSize` | `number` | `11` | Font size override for the label (px). | +| `labelPlacement` | `'auto' \| 'start' \| 'end' \| 'outside'` | `'auto'` unless `labelAnchor` is authored | Screen-space end selection, automatic containment, or a reserved outside gutter/lane. `labelAnchor` selects the side; an authored offset pins the base. | +| `fontSize` | `number` | theme `small` (12px) | Font size override for the label (px). | | `fontWeight` | `number` | `400` | Font weight override for the label. | | `fill` | `string` | `undefined` | Fill color. | -| `stroke` | `string` | theme `annotationText` | Stroke/line color. | +| `stroke` | `string` | theme `annotationRule` | Stroke/line color. | | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. | @@ -920,124 +930,80 @@ For line/area marks, mid-morph interruption uses a crossfade from the frozen int ## ThemeConfig -User-facing theme overrides. Source: `core/src/types/spec.ts`. +Public authored theme overrides live in `core/src/types/spec.ts`. Every field is optional. Use `ThemeConfig` for input and `ResolvedTheme` for compiled output; the former public `Theme` alias is removed. -All fields are optional. The engine deep-merges these onto `DEFAULT_THEME`. You only specify what you want to change. +`mergeThemeConfig()` deep-merges authored layers before `resolveTheme()` supplies defaults. Component theme props override provider themes; mount/compile options override the spec. Layer leaf themes override their parent first. Nested objects merge, arrays and `{ light, dark }` TokenValues replace atomically, and null replaces. `colors: string[]` is shorthand for `colors.categorical`. ```ts -interface ThemeConfig { - colors?: { - categorical?: string[]; - categoricalFill?: string[]; - sequential?: Record; - diverging?: Record; - background?: string; - text?: string; - gridline?: string; - hairline?: string; - axis?: string; - }; - fonts?: { - family?: string; - mono?: string; - }; - spacing?: { - padding?: number; - chromeGap?: number; - }; - rule?: { color: TokenValue; width: number; thickness: number }; - borderRadius?: number; -} -``` +import { broadsheet, mergeThemeConfig } from '@opendata-ai/openchart-core'; +import type { ThemeConfig } from '@opendata-ai/openchart-core'; -### Color overrides - -| Field | Type | Default | Description | -| -------------------- | -------------------------- | --------------------------------- | ----------------------------------------------------------------- | -| `colors.categorical` | `string[]` | 8-color palette | Categorical palette for nominal data. Array of CSS color strings. | -| `colors.categoricalFill` | `string[]` | copied from `categorical` | Quieter palette used for area-filling marks (bar, area, arc, waffle, calendar, rect). Set it only to control fills and strokes independently; leaving it unset copies `categorical` so a custom palette never drifts between the two. | -| `colors.sequential` | `Record` | `{ blue, green, orange, purple }` | Sequential palettes keyed by name. | -| `colors.diverging` | `Record` | `{ redBlue, brownTeal }` | Diverging palettes keyed by name. | -| `colors.background` | `string` | `'#ffffff'` | Visualization background color. | -| `colors.text` | `string` | `'#1d1d1d'` | Default text color. | -| `colors.gridline` | `string` | `'#e8e8e8'` | Gridline color. | -| `colors.hairline` | `string` | `'rgba(0,0,0,0.14)'` | Structural line color for axis lines and separators. Distinct from `gridline`, which is lighter. | -| `colors.axis` | `string` | `'#888888'` | Axis line and tick color. | - -### Font overrides +const theme: ThemeConfig = mergeThemeConfig(broadsheet, { + fonts: { sizes: { small: 12, axisTick: 12 } }, + chrome: { title: { fontSize: 28 } }, + rule: { width: 80 }, +}); +``` -| Field | Type | Default | Description | -| -------------- | -------- | ----------------------------------------- | ----------------------------------- | -| `fonts.family` | `string` | `'Inter, -apple-system, ..., sans-serif'` | Primary font family. | -| `fonts.mono` | `string` | `'"JetBrains Mono", ..., monospace'` | Monospace font for tabular numbers. | +Authored `chrome..fontSize` takes precedence over propagation from `fonts.sizes`, including across theme layers. Theme chrome values are objects such as `{ color: '#333' }`; string color shorthand is removed. Spec chrome text shorthand (`chrome: { title: 'A title' }`) remains valid. -### Spacing overrides +### Color overrides -| Field | Type | Default | Description | -| ------------------- | -------- | ------- | ---------------------------------------- | -| `spacing.padding` | `number` | `12` | Padding inside the chart container (px). | -| `spacing.chromeGap` | `number` | `4` | Gap between chrome elements (px). | +Single colors accept `TokenValue` (`string | { light: string; dark: string }`). Palette arrays contain color strings. These are light-mode defaults unless a pair is shown: + +| Field | Default | Description | +|---|---|---| +| `colors.categorical` | Six-hue palette | Nominal-series stroke palette | +| `colors.categoricalFill` | Default fill palette; inherits an authored categorical palette when omitted | Quieter fill colors for area-filling marks | +| `colors.sequential` / `diverging` | Named palette maps | Numeric color scales | +| `colors.background` | `transparent` | Visualization background; opaque surfaces derive from the neutral surface | +| `colors.text` | `#09090b` | Main text ink | +| `colors.gridline` | `rgba(0,0,0,0.08)` | Grid lines | +| `colors.hairline` | `rgba(0,0,0,0.14)` | Structural separators | +| `colors.axis` | `#71717a` | Axis lines and tick text | +| `colors.annotationLabel` | `#333333` | Primary callout ink | +| `colors.annotationMuted` | `#6b7280` | Subtitle and note ink | +| `colors.annotationLeader` | `#8f8f8f` | Quiet connector ink | +| `colors.annotationRule` | `#888888` / `#9ca3af` | Reference line ink, light/dark | +| `colors.annotationRangeFill` | `#f0c040` | Range fill before opacity | +| `colors.annotationMarkerFill` / `annotationPlate` | Resolved neutral surface | Marker interior and label plate; explicit colors win | + +Generic `annotationFill` and `annotationText` retain their other uses. See [Theming tokens](theming-tokens.md) for the complete token and dark-mode mapping. + +### Fonts, chrome, and spacing + +| Field | Default | Description | +|---|---|---| +| `fonts.family` | Inter Variable, Inter, system sans-serif | Primary font family | +| `fonts.mono` | JetBrains Mono, Fira Code, Cascadia Code, monospace | Tabular/monospace family | +| `fonts.sizes.title` / `subtitle` | 26 / 14 | General title sizes | +| `fonts.sizes.body` / `small` / `axisTick` | 13 / 12 / 12 | Body, small, and tick sizes | +| `fonts.sizes.metricLabel` / `metricValue` | 11 / 22 | Metric row sizes | +| `fonts.weights.normal` / `medium` / `semibold` / `bold` | 400 / 500 / 600 / 700 | Weight ladder | +| `chrome.eyebrow` | 11px, 500, `#06b6d4`, 1.4 line-height | Eyebrow style | +| `chrome.title` | 26px, 600, `#09090b`, 1.15 line-height | Title style | +| `chrome.subtitle` | 14px, 400, `#71717a`, 1.45 line-height | Subtitle style | +| `chrome.source` / `byline` / `footer` | 12px, 400, `#71717a`, 1.4 line-height | Attribution styles | +| `spacing.padding` / `chromeGap` | 20 / 6 | Container padding / gap between chrome elements | +| `spacing.chromeToChart` / `chartToFooter` | 16 / 12 | Gaps above and below the chart | +| `spacing.axisMargin` | 6 | Axis margin | +| `borderRadius` | 8 | Containers and tooltips | ### The editorial rule -`rule` draws a short colored bar above the chrome block — the Economist/FT -masthead device. Omit it (the default on every preset but `broadsheet`) for no -rule. - -```ts -theme: { - rule: { color: { light: '#e3120b', dark: '#f4463f' }, width: 40, thickness: 3 }, -} -``` +`rule` draws a short colored bar above top chrome. Omission inherits a preset rule, or draws none on the default theme. `rule: null` removes an inherited rule; partial objects preserve unmodified fields. -| Field | Type | Default | Description | -| ---------------- | ------------ | ----------- | ------------------------------------------------------------------ | -| `rule.color` | `TokenValue` | (required) | Rule color. A `TokenValue` accepts a `{ light, dark }` pair, so it needs no dark-mode branch. | -| `rule.width` | `number` | (required) | Rule length in pixels. | -| `rule.thickness` | `number` | (required) | Rule height in pixels. `computeChrome` reserves `thickness + chromeGap` above the eyebrow/title. | +| Field | Fallback when a rule is authored without a preset | Description | +|---|---|---| +| `rule.color` | Theme text ink | TokenValue color, including light/dark pairs | +| `rule.width` | 40px | Rule length | +| `rule.thickness` | 3px | Rule height; chrome reserves thickness plus chromeGap | No top chrome means no rule. -### Other - -| Field | Type | Default | Description | -| -------------- | -------- | ------- | -------------------------------------------------------- | -| `borderRadius` | `number` | `4` | Border radius for the chart container and tooltips (px). | - -### DEFAULT_THEME reference - -The full default theme (from `core/src/theme/defaults.ts`): - -| Property | Value | -| ------------------------ | ------------------------------------------------------------------------ | -| `colors.background` | `#ffffff` | -| `colors.text` | `#1d1d1d` | -| `colors.gridline` | `#e8e8e8` | -| `colors.axis` | `#888888` | -| `colors.annotationFill` | `rgba(0,0,0,0.04)` | -| `colors.annotationText` | `#555555` | -| `fonts.family` | Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif | -| `fonts.mono` | "JetBrains Mono", "Fira Code", "Cascadia Code", monospace | -| `fonts.sizes.title` | 22 | -| `fonts.sizes.subtitle` | 15 | -| `fonts.sizes.body` | 13 | -| `fonts.sizes.small` | 11 | -| `fonts.sizes.axisTick` | 11 | -| `fonts.weights.normal` | 400 | -| `fonts.weights.medium` | 500 | -| `fonts.weights.semibold` | 600 | -| `fonts.weights.bold` | 700 | -| `spacing.padding` | 12 | -| `spacing.chromeGap` | 4 | -| `spacing.chromeToChart` | 8 | -| `spacing.chartToFooter` | 8 | -| `spacing.axisMargin` | 6 | -| `borderRadius` | 4 | -| `chrome.title` | 22px, 700 weight, `#333333`, 1.3 line-height | -| `chrome.subtitle` | 15px, 400 weight, `#666666`, 1.4 line-height | -| `chrome.source` | 12px, 400 weight, `#999999`, 1.3 line-height | -| `chrome.byline` | 12px, 400 weight, `#999999`, 1.3 line-height | -| `chrome.footer` | 12px, 400 weight, `#999999`, 1.3 line-height | +The named presets are `essay`, `wire`, `broadsheet`, and `terminal`. Omit `theme` or pass `{}` for the default; `editorial` is removed. `clarity` is deprecated and contains only its background, axis, and eyebrow colors. `legacyEditorial` retains v8 typography for migration. + +Only authored theme overrides and derived values are stamped inline. Omitted CSS tokens remain overridable by host styles, including `--oc-border-radius`; removing an override restores the host's previous inline value. Export captures live token values. --- @@ -1080,9 +1046,8 @@ Input for data table visualizations. Source: `core/src/types/spec.ts`. | `striped` | `boolean` | `false` | Zebra striping on body rows. Hairlines only by default. | | `sort` | `SortState` | first inline-bar column, descending | Initial sort applied before the user touches a header. `{ column: string, direction: 'asc' \| 'desc' }`. | | `totalRow` | `boolean \| { label?: string }` | `false` | Sticky footer summing every quantitative column over the filtered rows (the whole result set, not just the current page). `true` uses the label `'Total'`. | -| `compact` | `boolean` | `false` | **Deprecated.** Alias for `density: 'condensed'`; still honored, with a warning. | | `responsive` | `boolean` | `true` | Whether the table adapts to container width. | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. | ### ColumnConfig @@ -1198,8 +1163,11 @@ Pass these through `MountOptions` (vanilla) or `ChartProps` (React). | `onMarkClick` | `(event: MarkEvent) => void` | User clicks a data mark (bar, point, line, arc). | | `onMarkHover` | `(event: MarkEvent) => void` | Mouse enters a data mark. | | `onMarkLeave` | `() => void` | Mouse leaves a data mark. | +| `onEdit` | `(transaction: EditTransaction) => void` | One committed authored edit; apply atomically with `applyEditTransaction` and retain its inverse for undo. | | `onLegendToggle` | `(series: string, visible: boolean) => void` | User clicks a legend entry to show/hide a series. | -| `onAnnotationClick` | `(annotation: Annotation, event: MouseEvent) => void` | User clicks an annotation element. | +| `onAnnotationClick` | `(annotation: Annotation, event: MouseEvent, element: ElementRef) => void` | User clicks an annotation element. | + +See [Editing and keyboard navigation](migrating-v9.md#editing-and-keyboard-navigation) for transaction persistence and cancelled-gesture behavior. ### MarkEvent @@ -1242,7 +1210,7 @@ Graphs render force-directed network visualizations on canvas. They support node | `chrome` | `Chrome` | `undefined` | Editorial text. | | `annotations` | `Annotation[]` | `undefined` | Annotations. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. | ### GraphEncoding @@ -1305,7 +1273,6 @@ Sankey diagrams show flows between stages. Each data row represents a link from | `linkStyle` | `SankeyLinkColor` | `'gradient'`| Link coloring: `'gradient'`, `'source'`, `'target'`, `'neutral'`. | | `linkOpacity` | `number` | `0.5` | Link fill opacity (0-1). Defaults to 0.75 in dark mode. | | `nodeLabelAlign`| `string` | `'auto'` | Label placement: `'auto'`, `'left'`, `'right'`. | -| `valueFormat` | `string` | `undefined` | d3-format string for values in tooltips and ARIA labels (e.g. `"$,.0f"`, `"~s"`). | | `chrome` | `Chrome` | `undefined` | Editorial text (title, subtitle, source, byline, footer). | | `legend` | `LegendConfig` | `undefined` | Legend display configuration. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | @@ -1370,7 +1337,6 @@ Maps render TopoJSON geometries as SVG, join tabular data rows to features by id | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | | `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. | | `animation` | `AnimationSpec` | `undefined` | Entrance animation configuration. | -| `valueFormat` | `string` | `undefined` | Deprecated: use `encoding.color.format` instead. d3-format string for color values in tooltips and the legend (e.g. `".1f"`). `encoding.color.format` wins when both are set. | ### GeoMapGeo @@ -1509,7 +1475,7 @@ Every state renders as an equal-size square in a fixed 12x8 grid, so small state | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | | `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. | | `animation` | `AnimationSpec` | `undefined` | Entrance animation configuration. | -| `valueFormat` | `string` | `undefined` | Deprecated: use `encoding.value.format` instead. d3-format string for tile values, legend labels, and tooltips. | +| `valueFormat` | `string` | `undefined` | Tilemap fallback d3-format string for values, legend labels, and tooltips; `encoding.value.format` takes precedence. | ### TileMapEncoding @@ -1524,7 +1490,7 @@ Every state renders as an equal-size square in a fixed 12x8 grid, so small state - **Quantitative mode** renders a gradient legend bar with min/max labels below the grid. **Categorical mode** (string values, a `color` channel, or a `colors` map) renders swatch rows instead. - States missing from the data render as empty tiles, keeping their place on the grid. -- **Number formatting:** set `encoding.value.format` (d3-format string, e.g. `".1f"`, `"$,.0f"`, `"~s"`). It applies to tile values, legend labels, and tooltips, and wins over the deprecated top-level `valueFormat`. With record-map data and no explicit encoding, `valueFormat` still applies (with a deprecation warning). +- **Number formatting:** set `encoding.value.format` (d3-format string, e.g. `".1f"`, `"$,.0f"`, `"~s"`). It applies to tile values, legend labels, and tooltips, and wins over top-level `valueFormat`. With record-map data and no explicit encoding, `valueFormat` remains supported. - **`scale.scheme` fails validation on tilemap encoding channels.** The tilemap compile path never reads it; the top-level `palette` property is the mechanism tilemaps use for color. ### Tilemap example @@ -1677,7 +1643,7 @@ const spec: ChartSpec = { { category: "Tablet", share: 7 }, ], encoding: { - y: { field: "share", type: "quantitative" }, + theta: { field: "share", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, chrome: { title: "Traffic by device" }, @@ -1697,7 +1663,7 @@ const spec: ChartSpec = { { status: "Blocked", count: 5 }, ], encoding: { - y: { field: "count", type: "quantitative" }, + theta: { field: "count", type: "quantitative" }, color: { field: "status", type: "nominal" }, }, chrome: { title: "Task status" }, @@ -1837,9 +1803,9 @@ All builders accept field names as strings (auto-infer type from data) or full ` | `annotations` | `Annotation[]` | Annotations. | | `responsive` | `boolean` | Responsive behavior. | | `theme` | `ThemeConfig` | Theme overrides. | -| `watermark` | `boolean` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options; defaults to `true`. | +| `watermark` | `boolean` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over the spec; defaults to `true`. | -`FieldRef` is `string | EncodingChannel`. When a string is provided, `inferFieldType()` samples up to 20 data values to determine the encoding type (quantitative, temporal, or nominal). +`FieldRef` is `string | EncodingChannel`. When a string is provided, `inferFieldType()` uses the shared builder/compiler inference: sample up to 50 rows, ignore nulls, and require more than 80% agreement. Numeric strings are considered before dates, so a column of `'2020'` values is quantitative. Set the channel type explicitly when years should be ordinal or temporal. ### Builder example @@ -1901,9 +1867,9 @@ const result = validateSpec(spec); ## Authoring foundation additions -The separate [OpenStudio application](https://github.com/tryopendata/openstudio) consumes these additive interfaces. Existing v8 visual and animation defaults remain unchanged. +These library interfaces support authoring and reader applications. OpenStudio adoption is separate; see [Migrating to v9](migrating-v9.md) for the changed defaults and removed APIs. -- **Theme:** import `clarity` for white/ink surfaces, Inter titles and 12px axes; `legacyEditorial` restores documented v8 typography without freezing layout behavior. +- **Theme:** v9 defaults include 12px axes/small text and larger chrome gaps. `clarity` is deprecated and retains only background, axis, and eyebrow colors. `legacyEditorial` restores v8 typography; `migrateSpecV8` can materialize the wider v8 styling contract without freezing layout behavior. - **Motion:** `animation: 'fluid' | 'subtle' (omit the preset for v8 timing)`; explicit phases override their preset. A phase can use `spring: { stiffness: 320, damping: 36, mass: 1 }` or the existing duration/easing configuration. Combining a spring with duration/easing in the same phase is an error. Quantitative springs are bounded, with a finite terminal state. Omitted/false animation remains disabled. - **Responsiveness:** `responsive: { preserveEnd: true, labelSummary: true }` retains the final x-axis boundary and moves overflowing endpoint identities to a wrapped list. Explicit axis values and author choices remain authoritative. `chromeLayout: 'grow'` gives that content space. Tables may opt into `mobileLayout: 'priority'`; `cards` remains the default, with `scroll` also available. - **Field metadata:** chart, table and map documents accept `fields`, keyed by field name. Each entry may declare `type` (`text`, `number`, `date`), `role: 'identity'`, `label`, `unit`, `measure`, `provenance`, `representation` (`number`, `percent`, `proportion`), `interval: { lower: 'lowerField', upper: 'upperField' }`, or `statusField`. Supplied intervals are never synthesized. Percent means 0–100 and proportion means 0–1. @@ -1921,15 +1887,15 @@ Maps, tile maps, bar lists and Sankey support arrows for inspection and Enter/Sp Table `rowKey` values must be unique to support identity across revisions. Numeric `1` and string `"1"` are distinct. Missing/duplicate keys emit `TABLE_ROW_KEY_AMBIGUOUS` and use revision-local identities. Treat compiled row IDs as opaque. CSV export includes all filtered and sorted rows, not just the current pagination page. -### Compact callout limits and note sizing (8.9.1) +### Compact callout limits and note sizing `responsive.compactAnnotationLimit?: number | null` is an optional nonnegative -integer limit below 400px outer width. Omitted/null means unlimited; zero demotes -all eligible callouts. `responsive: false` callouts are exempt. Priority/source +integer limit below 400px outer width. Omitted means one; null means unlimited, +and zero demotes all eligible callouts. `responsive: false` callouts are exempt. Priority/source order precedes coverage/collision thinning. Existing disabled-response and short-height rules still apply. Facet panel width does not trigger this limit. Read `layout.chrome.noteBandHeight` for note space and `layout.dimensions.height` for the final rendered/exported height. `chromeLayout: 'grow'` also supports facets; `chrome.grownHeight` reports engine-added height for auto-height consumers. -See [annotation migration guidance](./migrating-v8.md#annotation-layout-opt-ins). +See [annotation migration guidance](./migrating-v9.md#changed-annotation-and-figure-defaults). diff --git a/docs/tables.md b/docs/tables.md index e3bd2433..bd527a79 100644 --- a/docs/tables.md +++ b/docs/tables.md @@ -185,7 +185,6 @@ All features are opt-in via top-level `TableSpec` properties. | Striping | `striped: true` | `false` | Zebra rows. Hairlines only by default | | Initial sort | `sort: { column, direction }` | first inline-bar column, desc | Sort applied before the user touches a header | | Total row | `totalRow: true` | `false` | Sticky footer summing every quantitative column with a summable cell type (text/bar/heatmap). Set `columns[].total: false` to leave an individual column's total cell blank | -| Compact mode | `compact: true` | `false` | **Deprecated.** Alias for `density: 'condensed'`; warns | | Responsive | `responsive: true` | `true` | Table adapts to container width | In controlled mode a `sort` of `null` means "the user cleared sorting" and diff --git a/docs/theming-tokens.md b/docs/theming-tokens.md index 3b189efc..92d1faaa 100644 --- a/docs/theming-tokens.md +++ b/docs/theming-tokens.md @@ -1,16 +1,12 @@ -# Migration Guide +# Theming Design Tokens -## Theming Design Tokens +`ThemeConfig` controls colors, typography, spacing, rules, and component surfaces. +Defaults are shared by the JavaScript theme and generated CSS tokens. v9 +consolidates authored deep merging and leaves omitted CSS tokens available to +host styles. For removed forms and legacy styling, see [Migrating to v9](migrating-v9.md). -This release widens `ThemeConfig` to expose every design-decision field and unifies CSS custom properties with the JS theme engine. All changes are backward-compatible. Existing theme configs continue to work without modification. +### ThemeConfig fields -### What changed - -**Before:** `ThemeConfig` covered colors, font family/sizes, 4 spacing fields, border radius, and chrome color (string only). Font weights, chrome typography, semantic colors, and additional spacing fields were hardcoded in `DEFAULT_THEME` with no override path. CSS custom properties in `tokens.css`/`dark.css` were hand-maintained separately from the JS theme. - -**After:** `ThemeConfig` covers everything. CSS custom properties are generated from the resolved JS theme at mount time. The static CSS files serve as SSR/pre-mount fallback defaults. - -### New ThemeConfig fields ```ts const theme: ThemeConfig = { @@ -146,11 +142,11 @@ fixed zinc ladder. A warm or cool custom theme gets warm or cool grays for free instead of zinc showing up uninvited beside the palette. The ramp lives on `ResolvedTheme.colors.neutral` (`100`/`200`/`300`/`400`/`600`/`800`, plus `secondary` = `800`, `faint` = `300`, `border` = `100`, plus `surface`) and is -computed once in `resolveTheme`/`adaptTheme`; mounts stamp the CSS tokens from -it. `surface` is the opaque color the theme paints on — the background itself +computed once in `resolveTheme`/`adaptTheme`. Mounts stamp derived tokens when +the relevant theme colors are authored; omitted tokens use the CSS cascade. `surface` is the opaque color the theme paints on — the background itself when it is opaque, otherwise the mode's `--oc-bg` token — and is the single source for knockout rings, stacked-segment seams, table cell fills, graph node -rings and the stamped `--oc-bg`. When the +rings, annotation marker/plate fills, and authored background tokens. When the background is transparent (the default) the ramp falls back to the static token defaults, which is why the default theme and the generated `tokens.css` never disagree. @@ -179,7 +175,7 @@ radius. `token-theme-parity` test asserts this, along with the four weight tokens, `--oc-gridline`, `--oc-axis`, `--oc-border-radius`, `colors.neutral`, and `--oc-animation-stagger`, so the JS theme and the generated CSS tokens can't -drift apart the way they did before this release. +drift apart. ### The editorial rule diff --git a/e2e/annotation-invariants/curves.spec.ts b/e2e/annotation-invariants/curves.spec.ts index 55359742..26edbb6d 100644 --- a/e2e/annotation-invariants/curves.spec.ts +++ b/e2e/annotation-invariants/curves.spec.ts @@ -13,6 +13,9 @@ for (const width of [320, 390, 768, 1280]) const charts = page.getByTestId('annotation-curve'); await expect(charts).toHaveCount(10); for (const chart of await charts.all()) { + await expect(chart).toHaveAttribute('data-oc-fonts-state', 'ready'); + if (colorScheme === 'dark') await expect(chart).toHaveClass(/oc-dark/); + else await expect(chart).not.toHaveClass(/oc-dark/); await expect(chart.locator('.oc-annotation-label')).toBeVisible(); await chart.dispatchEvent('fixture-layout'); const layout: ChartLayout = JSON.parse((await chart.getAttribute('data-layout'))!); diff --git a/e2e/annotation-invariants/layout.spec.ts b/e2e/annotation-invariants/layout.spec.ts index cfb023c9..b2e6185f 100644 --- a/e2e/annotation-invariants/layout.spec.ts +++ b/e2e/annotation-invariants/layout.spec.ts @@ -1,99 +1,153 @@ import type { ChartLayout, ResolvedAnnotation } from '@opendata-ai/openchart-core'; -import { expect, test } from '@playwright/test'; +import { expect, test, type Page } from '@playwright/test'; -for (const width of [320, 390, 768, 1280]) for (const colorScheme of ['light', 'dark'] as const) { - for (const story of ['reference-labels', 'crowded-callouts', 'notes-sizing']) { - test(`${width} ${colorScheme}: ${story} layout and export`, async ({ page }) => { - await page.setViewportSize({ width, height: 900 }); - await page.emulateMedia({ colorScheme }); - await page.goto(`/?story=features--annotation-${story}&mode=preview`); - const charts = page.getByTestId('annotation-layout'); - await expect(charts.first().locator('svg')).toBeVisible(); - await page.evaluate(() => document.fonts.ready); - for (const chart of await charts.all()) { - await chart.dispatchEvent('fixture-layout'); - const layout: ChartLayout = JSON.parse((await chart.getAttribute('data-layout'))!); - expect(layout.theme.isDark).toBe(colorScheme === 'dark'); - const groups = layout.facet?.panels.map(p => p.annotations) ?? [layout.annotations]; - for (const annotations of groups) { - const visible = annotations.filter(a => a.label?.visible && !a.footnoteIndex); - for (const annotation of visible) { - const box = annotation.bounds ?? annotation.label!.bounds; - if (!box) continue; - expect(box.x, annotation.id).toBeGreaterThanOrEqual(-1); - expect(box.y, annotation.id).toBeGreaterThanOrEqual(-1); - expect(box.x + box.width, annotation.id).toBeLessThanOrEqual(layout.dimensions.width + 1); - expect(box.y + box.height, annotation.id).toBeLessThanOrEqual(layout.dimensions.height + 1); +for (const width of [320, 390, 768, 1280]) + for (const colorScheme of ['light', 'dark'] as const) { + for (const story of ['reference-labels', 'crowded-callouts', 'notes-sizing']) { + test(`${width} ${colorScheme}: ${story} layout and export`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ colorScheme }); + await page.goto(`/?story=features--annotation-${story}&mode=preview`); + const charts = page.getByTestId('annotation-layout'); + await expect(charts.first().locator('svg')).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + for (const chart of await charts.all()) { + await expect(chart).toHaveAttribute('data-oc-fonts-state', 'ready'); + if (colorScheme === 'dark') await expect(chart).toHaveClass(/oc-dark/); + else await expect(chart).not.toHaveClass(/oc-dark/); + await chart.dispatchEvent('fixture-layout'); + const layout: ChartLayout = JSON.parse((await chart.getAttribute('data-layout'))!); + expect(layout.theme.isDark).toBe(colorScheme === 'dark'); + const groups = layout.facet?.panels.map((p) => p.annotations) ?? [layout.annotations]; + for (const annotations of groups) { + const visible = annotations.filter((a) => a.label?.visible && !a.footnoteIndex); + for (const annotation of visible) { + const box = annotation.bounds ?? annotation.label!.bounds; + if (!box) continue; + expect(box.x, annotation.id).toBeGreaterThanOrEqual(-1); + expect(box.y, annotation.id).toBeGreaterThanOrEqual(-1); + expect(box.x + box.width, annotation.id).toBeLessThanOrEqual( + layout.dimensions.width + 1, + ); + expect(box.y + box.height, annotation.id).toBeLessThanOrEqual( + layout.dimensions.height + 1, + ); + } + const unpinned = visible.filter((a) => !a.id?.match(/^crowded-1[23]$/)); + for (let i = 0; i < unpinned.length; i++) + for (let j = i + 1; j < unpinned.length; j++) { + const a = unpinned[i].bounds, + b = unpinned[j].bounds; + if (!a || !b) continue; + const overlap = + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 1 && + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 1; + expect(overlap, `${unpinned[i].id}/${unpinned[j].id}`).toBe(false); + } + if (story === 'crowded-callouts') { + expect(visible.filter((a) => a.id?.match(/^crowded-1[23]$/))).toHaveLength(2); + expect(visible.length + (layout.chrome.footnotes?.length ?? 0)).toBe(14); + } + if (story === 'notes-sizing') + expect(visible.length + (layout.chrome.footnotes?.length ?? 0)).toBe(2); + for (const a of visible) assertConnector(a); } - const unpinned = visible.filter(a => !a.id?.match(/^crowded-1[23]$/)); - for (let i = 0; i < unpinned.length; i++) for (let j = i + 1; j < unpinned.length; j++) { - const a = unpinned[i].bounds, b = unpinned[j].bounds; - if (!a || !b) continue; - const overlap = Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 1 && Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 1; - expect(overlap, `${unpinned[i].id}/${unpinned[j].id}`).toBe(false); - } - if (story === 'crowded-callouts') { - expect(visible.filter(a => a.id?.match(/^crowded-1[23]$/))).toHaveLength(2); - expect(visible.length + (layout.chrome.footnotes?.length ?? 0)).toBe(14); - } - if (story === 'notes-sizing') expect(visible.length + (layout.chrome.footnotes?.length ?? 0)).toBe(2); - for (const a of visible) assertConnector(a); + expect( + await chart + .locator('svg') + .first() + .evaluate((svg) => svg.getBoundingClientRect().height), + ).toBeCloseTo(layout.dimensions.height, 1); + await chart.dispatchEvent('fixture-export'); + const exported = (await chart.getAttribute('data-export'))!; + expect(exported).not.toMatch(/oc-edit-handle|oc-selection-handle/); + await assertExportNotes(page, exported, layout); } - expect(await chart.locator('svg').first().evaluate(svg => svg.getBoundingClientRect().height)).toBeCloseTo(layout.dimensions.height, 1); - await chart.dispatchEvent('fixture-export'); - const exported = (await chart.getAttribute('data-export'))!; - expect(exported).not.toMatch(/oc-edit-handle|oc-selection-handle/); - for (const note of layout.chrome.footnotes ?? []) { - // Plain-text notes remain intentional in Track A, including literal bold markers. - expect(exported).toContain((note.lines?.[0] ?? `${note.index}. ${note.text}`).replaceAll('&', '&').replaceAll('<', '<')); + if (story === 'notes-sizing') { + const canvas = page.getByTestId('annotation-canvas'); + await expect(canvas.locator('canvas.oc-mark-canvas')).toBeAttached(); + await canvas.dispatchEvent('fixture-layout'); + await canvas.dispatchEvent('fixture-export'); + const canvasLayout: ChartLayout = JSON.parse((await canvas.getAttribute('data-layout'))!); + await assertExportNotes(page, (await canvas.getAttribute('data-export'))!, canvasLayout); + // Canvas changes only the mark renderer; annotation geometry and the + // figure-owned note band must match the equivalent SVG fixture. + const svgLayout: ChartLayout = JSON.parse( + (await charts.nth(4).getAttribute('data-layout'))!, + ); + expect(canvasLayout.annotations).toEqual(svgLayout.annotations); + expect(canvasLayout.chrome.footnotes).toEqual(svgLayout.chrome.footnotes); + expect(canvasLayout.dimensions).toEqual(svgLayout.dimensions); + await canvas.dispatchEvent('fixture-png'); + await expect(canvas).toHaveAttribute('data-png', /width/); + const png = JSON.parse((await canvas.getAttribute('data-png'))!); + expect(png.width).toBe(Math.floor(canvasLayout.dimensions.width)); + expect(png.height).toBe(Math.floor(canvasLayout.dimensions.height)); + const auto = page.getByTestId('annotation-auto-height'); + await auto.dispatchEvent('fixture-layout'); + const first: ChartLayout = JSON.parse((await auto.getAttribute('data-layout'))!); + expect(first.dimensions.height).toBeCloseTo(400 + (first.chrome.grownHeight ?? 0), 1); + await auto.dispatchEvent('fixture-png'); + await expect(auto).toHaveAttribute('data-png', /width/); + expect(JSON.parse((await auto.getAttribute('data-png'))!).height).toBe( + Math.floor(first.dimensions.height), + ); + await page.waitForTimeout(250); + await auto.dispatchEvent('fixture-layout'); + expect(JSON.parse((await auto.getAttribute('data-layout'))!).dimensions.height).toBe( + first.dimensions.height, + ); } - } - if (story === 'notes-sizing') { - const canvas = page.getByTestId('annotation-canvas'); - await expect(canvas.locator('canvas.oc-mark-canvas')).toBeAttached(); - await canvas.dispatchEvent('fixture-layout'); - await canvas.dispatchEvent('fixture-export'); - const canvasLayout: ChartLayout = JSON.parse((await canvas.getAttribute('data-layout'))!); - for (const note of canvasLayout.chrome.footnotes ?? []) expect((await canvas.getAttribute('data-export'))!).toContain(note.text.replaceAll('&', '&').replaceAll('<', '<').split(' ').slice(0, 2).join(' ')); - // Canvas changes only the mark renderer; annotation geometry and the - // figure-owned note band must match the equivalent SVG fixture. - const svgLayout: ChartLayout = JSON.parse((await charts.nth(4).getAttribute('data-layout'))!); - expect(canvasLayout.annotations).toEqual(svgLayout.annotations); - expect(canvasLayout.chrome.footnotes).toEqual(svgLayout.chrome.footnotes); - expect(canvasLayout.dimensions).toEqual(svgLayout.dimensions); - await canvas.dispatchEvent('fixture-png'); - await expect(canvas).toHaveAttribute('data-png', /width/); - const png = JSON.parse((await canvas.getAttribute('data-png'))!); - expect(png.width).toBe(Math.floor(canvasLayout.dimensions.width)); - expect(png.height).toBe(Math.floor(canvasLayout.dimensions.height)); - const auto = page.getByTestId('annotation-auto-height'); - await auto.dispatchEvent('fixture-layout'); - const first: ChartLayout = JSON.parse((await auto.getAttribute('data-layout'))!); - expect(first.dimensions.height).toBeCloseTo(400 + (first.chrome.grownHeight ?? 0), 1); - await auto.dispatchEvent('fixture-png'); - await expect(auto).toHaveAttribute('data-png', /width/); - expect(JSON.parse((await auto.getAttribute('data-png'))!).height).toBe(Math.floor(first.dimensions.height)); - await page.waitForTimeout(250); - await auto.dispatchEvent('fixture-layout'); - expect(JSON.parse((await auto.getAttribute('data-layout'))!).dimensions.height).toBe(first.dimensions.height); - } - }); + }); + } } -} function assertConnector(annotation: ResolvedAnnotation) { const connector = annotation.label?.connector; const bounds = annotation.bounds; if (!connector?.endpoint || !bounds || connector.style === 'drop-line') return; - const cx = bounds.x + bounds.width / 2, cy = bounds.y + bounds.height / 2; - const dx = connector.endpoint.x - cx, dy = connector.endpoint.y - cy; + const cx = bounds.x + bounds.width / 2, + cy = bounds.y + bounds.height / 2; + const dx = connector.endpoint.x - cx, + dy = connector.endpoint.y - cy; const t = Math.min((bounds.width / 2 + 2) / Math.abs(dx), (bounds.height / 2 + 2) / Math.abs(dy)); if (connector.labelAttachment === 'left-center') { expect(connector.from.x).toBeCloseTo(bounds.x - 8, 1); expect(connector.from.y).toBeCloseTo(cy, 1); } else { - expect(Math.hypot(connector.from.x - (cx + dx * t), connector.from.y - (cy + dy * t))).toBeCloseTo(6, 1); + expect( + Math.hypot(connector.from.x - (cx + dx * t), connector.from.y - (cy + dy * t)), + ).toBeCloseTo(6, 1); } const radius = annotation.dot ? annotation.dot.radius + annotation.dot.strokeWidth / 2 : 0; - expect(Math.hypot(connector.to.x - connector.endpoint.x, connector.to.y - connector.endpoint.y)).toBeCloseTo(connector.arrow ? Math.max(radius + 2, 2) : Math.max(radius + 3, 4), 1); + expect( + Math.hypot(connector.to.x - connector.endpoint.x, connector.to.y - connector.endpoint.y), + ).toBeCloseTo(connector.arrow ? Math.max(radius + 2, 2) : Math.max(radius + 3, 4), 1); +} + +async function assertExportNotes(page: Page, source: string, layout: ChartLayout) { + const notes = await page.evaluate((svg) => { + const doc = new DOMParser().parseFromString(svg, 'image/svg+xml'); + return { + controls: doc.querySelectorAll( + '.oc-note-active, .oc-selection-overlay, .oc-anchor-handle, .oc-connector-handle', + ).length, + notes: [...doc.querySelectorAll('[data-note-spec-index]')].map((note) => ({ + label: note.getAttribute('aria-label'), + lines: [...note.children].map((line) => line.textContent), + bold: [...note.querySelectorAll('[font-weight="700"]')].map((span) => span.textContent), + })), + }; + }, source); + expect(notes.controls).toBe(0); + expect(notes.notes).toHaveLength(layout.chrome.footnotes?.length ?? 0); + for (const [index, note] of (layout.chrome.footnotes ?? []).entries()) { + const clean = (text: string) => text.replaceAll('**', ''); + expect(notes.notes[index].label).toBe( + `${note.index}. ${clean(note.text)}${note.subtitle ? `. ${clean(note.subtitle)}` : ''}`, + ); + expect(notes.notes[index].lines).toEqual((note.lines ?? []).map(clean)); + if (note.text.includes('**') || note.subtitle?.includes('**')) + expect(notes.notes[index].bold.length).toBeGreaterThan(0); + } } diff --git a/e2e/annotation-invariants/notes.spec.ts b/e2e/annotation-invariants/notes.spec.ts new file mode 100644 index 00000000..b7e86e1b --- /dev/null +++ b/e2e/annotation-invariants/notes.spec.ts @@ -0,0 +1,71 @@ +import { expect, test } from '@playwright/test'; + +for (const width of [320, 390, 768, 1280]) + for (const colorScheme of ['light', 'dark'] as const) { + test(`${width} ${colorScheme}: notes and markers share keyboard focus and clean export`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ colorScheme }); + await page.goto('/?story=features--annotation-crowded-callouts&mode=preview'); + const chart = page.getByTestId('annotation-layout').first(); + const firstNote = chart.locator('[data-note-spec-index]').first(); + await expect(chart.locator('svg')).toBeVisible(); + await page.evaluate(() => document.fonts.ready); + await expect(chart).toHaveAttribute('data-oc-fonts-state', 'ready'); + if (colorScheme === 'dark') await expect(chart).toHaveClass(/oc-dark/); + else await expect(chart).not.toHaveClass(/oc-dark/); + // This interaction fixture needs notes even when the viewport can fit all + // callouts. Full-width geometry remains covered by layout.spec.ts. + if (width === 1280) { + const chartWidth = await chart.evaluate((host) => { + host.style.maxWidth = '390px'; + return host.clientWidth; + }); + await expect + .poll(async () => + chart.locator('svg').evaluate((svg) => (svg as SVGSVGElement).viewBox.baseVal.width), + ) + .toBe(chartWidth); + } + // Like the visual capture harness, let the initial debounced resize and + // font measurement frame finish before testing a user's focus action. + await page.waitForTimeout(100); + await expect(firstNote).toBeVisible(); + const source = await firstNote.getAttribute('data-note-spec-index'); + const note = chart.locator(`[data-note-spec-index="${source}"]`); + const marker = chart.locator(`[data-note-marker-index="${source}"]`).first(); + await expect(chart.locator('svg')).toHaveAttribute('role', 'group'); + await expect(note).toHaveAttribute('role', 'listitem'); + await expect(marker).toHaveAttribute('role', 'button'); + await expect(note).not.toContainText('**'); + await note.focus(); + await expect(note).toHaveClass(/oc-note-active/); + await expect(marker).toHaveClass(/oc-note-active/); + await note.press('Enter'); + await expect(marker).toBeFocused(); + await marker.press('Space'); + await expect(note).toBeFocused(); + await expect(chart.locator('textarea, input, [contenteditable]')).toHaveCount(0); + await chart.dispatchEvent('fixture-export'); + const result = await chart.evaluate((host) => { + const doc = new DOMParser().parseFromString( + (host as HTMLElement).dataset.export!, + 'image/svg+xml', + ); + return { + active: doc.querySelectorAll( + '.oc-note-active, .oc-anchor-handle, .oc-connector-handle, .oc-selection-overlay', + ).length, + notes: [...doc.querySelectorAll('[data-note-spec-index]')].map((note) => + note.getAttribute('aria-label'), + ), + }; + }); + expect(result.active).toBe(0); + expect(result.notes).toContain(await note.getAttribute('aria-label')); + await expect(note).toHaveClass(/oc-note-active/); + await note.press('Escape'); + await expect(chart.locator('.oc-note-active')).toHaveCount(0); + }); + } diff --git a/e2e/visual/stories-mobile.spec.ts b/e2e/visual/stories-mobile.spec.ts index 352db986..8c7fa8a8 100644 --- a/e2e/visual/stories-mobile.spec.ts +++ b/e2e/visual/stories-mobile.spec.ts @@ -15,6 +15,11 @@ import { captureStory } from './capture'; */ const stories = [ + { name: 'v9-reference-labels', slug: 'features--annotation-reference-labels' }, + { name: 'v9-crowded-callouts', slug: 'features--annotation-crowded-callouts' }, + { name: 'v9-notes-sizing', slug: 'features--annotation-notes-sizing' }, + { name: 'v9-curves', slug: 'features--annotation-curves' }, + { name: 'mr-long-title', slug: 'testing--mobile-regression--long-title-mobile' }, { name: 'mr-grouped-columns-labels-all', slug: 'testing--mobile-regression--grouped-columns-labels-all' }, { name: 'mr-grouped-bars-many-rows', slug: 'testing--mobile-regression--grouped-bars-many-rows' }, diff --git a/e2e/visual/stories.spec.ts b/e2e/visual/stories.spec.ts index 8b7cc655..3d4a6115 100644 --- a/e2e/visual/stories.spec.ts +++ b/e2e/visual/stories.spec.ts @@ -13,6 +13,11 @@ import { captureStory } from './capture'; * compress the chart area. */ const stories: Array<{ name: string; slug: string; query?: string; note?: string }> = [ + { name: 'v9-reference-labels', slug: 'features--annotation-reference-labels' }, + { name: 'v9-crowded-callouts', slug: 'features--annotation-crowded-callouts' }, + { name: 'v9-notes-sizing', slug: 'features--annotation-notes-sizing' }, + { name: 'v9-curves', slug: 'features--annotation-curves' }, + { name: 'bar-vertical', slug: 'testing--fixtures--simple-columns' }, { name: 'bar-horizontal-gradient', slug: 'testing--fixtures--simple-bars' }, { name: 'line-multi-series', slug: 'testing--fixtures--gdp-growth' }, diff --git a/examples/src/__tests__/migration-parity.test.ts b/examples/src/__tests__/migration-parity.test.ts index 00cf593b..67ee0cfe 100644 --- a/examples/src/__tests__/migration-parity.test.ts +++ b/examples/src/__tests__/migration-parity.test.ts @@ -56,20 +56,22 @@ function compile(spec: VizSpec, width: number) { } } -describe('authored example migration parity', () => { +describe('authored example v8 migration compatibility', () => { it('discovers examples across the gallery and testing stories', () => { expect(fixtures.length).toBeGreaterThan(100); }); for (const { name, spec } of fixtures) { it(name, () => { - const { spec: migrated, repairs } = migrateSpecV8(spec); + const before = JSON.stringify(spec); + const { spec: migrated } = migrateSpecV8(spec); expect(migrateSpecV8(migrated)).toEqual({ spec: migrated, repairs: [] }); - // The approved end-of-line rewrite deliberately changes placement. - if (repairs.some((repair) => repair.reason.includes('end-of-line'))) return; + expect(JSON.stringify(spec)).toBe(before); for (const width of [390, 1280]) { - // Layouts contain formatter/scale closures with fresh identities. - // Compare all serialized geometry, text, colors and layout metadata. - expect(JSON.stringify(compile(migrated, width))).toBe(JSON.stringify(compile(spec, width))); + // v9 deliberately retunes placement. Migration preserves authored styles, + // not pixel geometry; every collected legacy example must still compile. + const result = compile(migrated, width); + expect(result).toBeDefined(); + expect(JSON.stringify(result)).toBe(JSON.stringify(compile(migrated, width))); } }); } diff --git a/examples/src/__tests__/v9-editorial-examples.test.ts b/examples/src/__tests__/v9-editorial-examples.test.ts new file mode 100644 index 00000000..7f5bd2bd --- /dev/null +++ b/examples/src/__tests__/v9-editorial-examples.test.ts @@ -0,0 +1,100 @@ +/// +import type { ChartSpec, LineMark, RectMark } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../../../packages/engine/src/compile'; + +const modules = import.meta.glob( + [ + '../gallery/charts-bar-column.stories.tsx', + '../gallery/charts-line-area.stories.tsx', + '../gallery/features-annotations.stories.tsx', + '../gallery/showcase.stories.tsx', + '../gallery/welcome.stories.tsx', + '../testing/fixtures-bars.stories.tsx', + ], + { eager: true }, +) as Record }>; +const specFor = (file: string, name: string) => + modules[`../${file}.stories.tsx`].__authoredValues[name]; + +const populationExamples = [ + ['gallery/charts-bar-column', 'simpleBarsSpec'], + ['gallery/showcase', 'fullChromeSpec'], + ['testing/fixtures-bars', 'populationBarSpec'], +] as const; +const amazonExamples = [ + ['gallery/charts-line-area', 'fiveSeriesSpec'], + ['gallery/showcase', 'multiLineSpec'], + ['gallery/welcome', 'heroSpec'], +] as const; + +describe('v9 authored editorial choices', () => { + it.each( + populationExamples, + )('%s uses flat ranked population bars with explicit India/China emphasis', (file, name) => { + const original = specFor(file, name); + const spec = { ...original, data: [...original.data].reverse() } as ChartSpec; + const before = JSON.stringify(spec.data); + expect(spec.encoding.color).toMatchObject({ field: 'country', highlight: ['India', 'China'] }); + const layout = compileChart(spec, { width: 700, height: 500, onWarn: () => {} }); + expect(JSON.stringify(spec.data)).toBe(before); + const bars = layout.marks + .filter((mark): mark is RectMark => mark.type === 'rect') + .sort((a, b) => a.y - b.y); + expect(bars.map((bar) => bar.data.population)).toEqual( + [...bars.map((bar) => Number(bar.data.population))].sort((a, b) => b - a), + ); + expect(bars.map((bar) => bar.data.country).slice(0, 2)).toEqual(['India', 'China']); + expect(bars.every((bar) => typeof bar.fill === 'string')).toBe(true); + expect(bars[0].fill).not.toBe(bars[2].fill); + expect(bars[1].fill).not.toBe(bars[2].fill); + }); + + it.each( + amazonExamples, + )('%s explicitly focuses Amazon and labels every comparison series', (file, name) => { + const spec = specFor(file, name); + expect(spec.encoding.color).toMatchObject({ + field: 'company', + scale: { domain: ['Amazon', 'Apple', 'Alphabet', 'Microsoft', 'Meta'] }, + }); + expect(spec.seriesStyles).toMatchObject({ Amazon: { strokeWidth: 3 } }); + for (const width of [390, 1280]) { + const layout = compileChart(spec, { width, height: 460, onWarn: () => {} }); + const lines = layout.marks.filter((mark): mark is LineMark => mark.type === 'line'); + const amazon = lines.find((line) => line.seriesKey === 'Amazon')!; + expect(lines).toHaveLength(5); + expect( + lines + .filter((line) => line.seriesKey !== 'Amazon') + .every((line) => line.stroke !== amazon.stroke && line.strokeWidth < amazon.strokeWidth), + ).toBe(true); + expect(layout.endpointLabels?.entries.map((entry) => entry.seriesKey).sort()).toEqual([ + 'Alphabet', + 'Amazon', + 'Apple', + 'Meta', + 'Microsoft', + ]); + } + }); + + it('uses rule-relative placement instead of pixel offsets in reference-line examples', () => { + for (const spec of [ + specFor('gallery/charts-bar-column', 'simpleBarsSpec'), + specFor('gallery/showcase', 'fullChromeSpec'), + specFor('testing/fixtures-bars', 'populationBarSpec'), + specFor('gallery/charts-bar-column', 'columnsSpec'), + specFor('gallery/features-annotations', 'reflineSpec'), + ]) { + const rules = spec.annotations!.filter( + (annotation) => annotation.type === 'refline' && annotation.label, + ); + expect(rules.length).toBeGreaterThan(0); + for (const rule of rules) { + expect(rule).not.toHaveProperty('labelOffset'); + expect(rule).toHaveProperty('labelPlacement'); + } + } + }); +}); diff --git a/examples/src/gallery/charts-bar-column.stories.tsx b/examples/src/gallery/charts-bar-column.stories.tsx index 11a6f427..56c854ec 100644 --- a/examples/src/gallery/charts-bar-column.stories.tsx +++ b/examples/src/gallery/charts-bar-column.stories.tsx @@ -30,14 +30,24 @@ const ACCENT = '#0e7490'; const simpleBarsSpec: ChartSpec = { animation: true, - mark: { type: 'bar', fill: hBarGradient(ACCENT) }, + mark: 'bar', data: [...populationByCountry.data], encoding: { x: { field: 'population', type: 'quantitative', axis: { title: 'Population', format: '.2~s' } }, - y: { field: 'country', type: 'nominal' }, + y: { field: 'country', type: 'nominal', sort: 'x' }, + color: { field: 'country', type: 'nominal', highlight: ['India', 'China'] }, }, + legend: { show: false }, + labels: { color: 'var(--oc-text)' }, annotations: [ - { type: 'refline', x: 1_000_000_000, label: '1 billion', style: 'dashed', stroke: '#cc4444' }, + { + type: 'refline', + x: 1_000_000_000, + label: '1 billion', + labelPlacement: 'outside', + style: 'dashed', + stroke: '#cc4444', + }, ], chrome: { title: 'India Has Overtaken China as the Most Populous Country', @@ -76,7 +86,7 @@ const columnsSpec: ChartSpec = { style: 'dashed', stroke: '#64748b', strokeWidth: 1, - labelOffset: { dx: -560, dy: -10 }, + labelPlacement: 'outside', }, ], labels: { density: 'all', format: ',.0f' }, diff --git a/examples/src/gallery/charts-line-area.stories.tsx b/examples/src/gallery/charts-line-area.stories.tsx index f410dc87..59089f4a 100644 --- a/examples/src/gallery/charts-line-area.stories.tsx +++ b/examples/src/gallery/charts-line-area.stories.tsx @@ -156,7 +156,7 @@ const multiSeriesSpec: ChartSpec = { }; // --------------------------------------------------------------------------- -// 3. Five series with legend — when direct labels stop working +// 3. Five series with explicit focus and readable comparison labels // --------------------------------------------------------------------------- const fiveSeriesSpec: ChartSpec = { @@ -170,13 +170,19 @@ const fiveSeriesSpec: ChartSpec = { type: 'quantitative', axis: { title: 'Annual revenue ($B)', format: ',.0f', grid: true }, }, - color: { field: 'company', type: 'nominal' }, + color: { + field: 'company', + type: 'nominal', + scale: { + domain: ['Amazon', 'Apple', 'Alphabet', 'Microsoft', 'Meta'], + range: ['#0e7490', '#7a858f', '#7a858f', '#7a858f', '#7a858f'], + }, + }, }, - // legend.position implies show:true; endpointLabels:false hands series - // identification to the legend alone (see the suppression truth table in - // packages/core/src/types/spec.ts) so the two keys don't duplicate. - legend: { position: 'top' }, - endpointLabels: false, + // Explicit focus mutes comparison strokes; direct labels keep every company identifiable. + legend: { show: false }, + endpointLabels: { content: 'label', showMarker: true }, + seriesStyles: { Amazon: { strokeWidth: 3 } }, labels: { density: 'none' }, chrome: { title: 'Amazon Pulls Away at the Top of Big Tech', @@ -465,7 +471,7 @@ export const LineAndArea = () => (
( /> diff --git a/examples/src/gallery/features-annotations.stories.tsx b/examples/src/gallery/features-annotations.stories.tsx index e287fa34..d098ca56 100644 --- a/examples/src/gallery/features-annotations.stories.tsx +++ b/examples/src/gallery/features-annotations.stories.tsx @@ -184,8 +184,7 @@ const richTextSpec: ChartSpec = { }, }; -// The lede stack: set `subtitle` and the primary line takes bold automatically, -// with the subtitle in muted regular below it. No fontWeight authoring. +// Primary text and subtitle are regular by default; author emphasis explicitly. const ledeSpec: ChartSpec = { animation: true, mark: 'line', @@ -220,7 +219,7 @@ const ledeSpec: ChartSpec = { ], chrome: { title: 'A Lede, Then the Context', - subtitle: 'Global surface temperature anomaly — subtitle promotes the first line to bold', + subtitle: 'Global surface temperature anomaly — a primary line with supporting context', source: temperatureAnomaly.source, byline: 'Chart: OpenChart', }, @@ -328,9 +327,7 @@ const reflineSpec: ChartSpec = { }, labels: { density: 'none' }, annotations: [ - // Horizontal target line, dashed. At the right edge the label sat on the - // Nov/Dec bars; slide it into the Jun–Aug valley, where the line runs - // clear above every bar top. + // Auto placement keeps the average label beside its rule as the chart resizes. { type: 'refline', y: 216, @@ -338,14 +335,9 @@ const reflineSpec: ChartSpec = { style: 'dashed', stroke: '#64748b', strokeWidth: 1, - labelAnchor: 'right', - labelOffset: { dx: -245 }, + labelPlacement: 'auto', }, - // Horizontal threshold, dotted, styled as a soft floor. Every bar except - // October crosses the 100K line, and October's gap has the storm-marker - // line running through it, so there is no bar-free stretch at label height. - // Park the label in the Jun–Aug valley above the bar tops instead; the red - // text ties it to the red dotted line below it. + // A reserved right gutter keeps the low threshold label clear of the bars. { type: 'refline', y: 100, @@ -353,10 +345,9 @@ const reflineSpec: ChartSpec = { style: 'dotted', stroke: '#d1495b', strokeWidth: 1, - labelAnchor: 'left', - labelOffset: { dx: 381, dy: -30 }, + labelPlacement: 'outside', }, - // Vertical marker with a raw strokeDash override (takes precedence over style). + // The event label uses the top lane; strokeDash takes precedence over style. { type: 'refline', x: 'Oct', @@ -364,7 +355,7 @@ const reflineSpec: ChartSpec = { strokeDash: [2, 3], stroke: '#94a3b8', strokeWidth: 1, - labelAnchor: 'top', + labelPlacement: 'outside', }, ], chrome: { @@ -629,7 +620,7 @@ export const Annotations = () => (
( diff --git a/examples/src/gallery/features-edit-mode.stories.tsx b/examples/src/gallery/features-edit-mode.stories.tsx index ca07d0bc..c93e3144 100644 --- a/examples/src/gallery/features-edit-mode.stories.tsx +++ b/examples/src/gallery/features-edit-mode.stories.tsx @@ -276,9 +276,7 @@ function makePristineState(): EditableState { y: 71.42, text: 'Holiday peak:\n$71.4B devices', fontSize: 11, - anchor: 'left', connector: true, - offset: { dx: -110, dy: -50 }, }, { type: 'text', @@ -286,9 +284,8 @@ function makePristineState(): EditableState { y: 22.17, text: 'Cloud overtakes\n2022 devices floor', fontSize: 11, - anchor: 'left', + anchor: 'top', connector: true, - offset: { dx: -30, dy: -34 }, }, ], chrome: { @@ -494,7 +491,7 @@ function EditModeStudio() {
{/* Editor */}
-
+
( title="Horizontal bar with full chrome" description="Every editorial chrome element at once: eyebrow, title, subtitle, source, byline, and footer wrapped around a ranked horizontal bar." spec={fullChromeSpec} - height={520} + height={600} />
diff --git a/examples/src/gallery/welcome.stories.tsx b/examples/src/gallery/welcome.stories.tsx index 70865b50..fd186270 100644 --- a/examples/src/gallery/welcome.stories.tsx +++ b/examples/src/gallery/welcome.stories.tsx @@ -367,10 +367,18 @@ const heroSpec: ChartSpec = { type: 'quantitative', axis: { title: 'Annual revenue ($B)', format: ',.0f', grid: true }, }, - color: { field: 'company', type: 'nominal' }, + color: { + field: 'company', + type: 'nominal', + scale: { + domain: ['Amazon', 'Apple', 'Alphabet', 'Microsoft', 'Meta'], + range: ['#0e7490', '#7a858f', '#7a858f', '#7a858f', '#7a858f'], + }, + }, }, - legend: { position: 'top' }, - endpointLabels: false, + legend: { show: false }, + endpointLabels: { content: 'label', showMarker: true }, + seriesStyles: { Amazon: { strokeWidth: 3 } }, labels: { density: 'none' }, annotations: [ { diff --git a/examples/src/testing/fixtures-bars.stories.tsx b/examples/src/testing/fixtures-bars.stories.tsx index 3bcac1f6..7cf0f58d 100644 --- a/examples/src/testing/fixtures-bars.stories.tsx +++ b/examples/src/testing/fixtures-bars.stories.tsx @@ -142,6 +142,8 @@ export const LongAxisLabels = () => ( // --------------------------------------------------------------------------- const simpleBarSpec: ChartSpec = { + // This fixture has an exact-height host, including its note reservation. + chromeLayout: 'subtract', animation: true, mark: { type: 'bar', fill: hBarGradient('#1b7fa3') }, data: [ @@ -194,7 +196,7 @@ const simpleBarSpec: ChartSpec = { }; export const SimpleBars = () => ( -
+
); @@ -356,7 +358,7 @@ export const TemperatureAnomaly = () => ( const populationBarSpec: ChartSpec = { animation: true, - mark: { type: 'bar', fill: hBarGradient('#1b7fa3') }, + mark: 'bar', data: [ { country: 'India', population: 1_463_000_000 }, { country: 'China', population: 1_410_000_000 }, @@ -371,13 +373,17 @@ const populationBarSpec: ChartSpec = { ], encoding: { x: { field: 'population', type: 'quantitative', axis: { title: 'Population' } }, - y: { field: 'country', type: 'nominal' }, + y: { field: 'country', type: 'nominal', sort: 'x' }, + color: { field: 'country', type: 'nominal', highlight: ['India', 'China'] }, }, + legend: { show: false }, + labels: { color: 'var(--oc-text)' }, annotations: [ { type: 'refline', x: 1_000_000_000, label: '1 billion', + labelPlacement: 'outside', style: 'dashed', stroke: '#cc4444', }, diff --git a/examples/src/testing/fixtures-lines.stories.tsx b/examples/src/testing/fixtures-lines.stories.tsx index bcf77193..f16f6d28 100644 --- a/examples/src/testing/fixtures-lines.stories.tsx +++ b/examples/src/testing/fixtures-lines.stories.tsx @@ -294,7 +294,7 @@ const fiveSeriesSpec: ChartSpec = { }; export const FiveSeries = () => ( -
+
); @@ -603,6 +603,8 @@ export const EditorialSingleLine = () => ( // --------------------------------------------------------------------------- const lineSpec: ChartSpec = { + // The fixed-height fixture includes chrome and the note reservation. + chromeLayout: 'subtract', mark: 'line', data: [ // YouTube diff --git a/llms-full.txt b/llms-full.txt index 068680d8..87b43598 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -362,7 +362,7 @@ const spec = { The `encoding` object maps data fields to visual channels. `type` tells the engine how to interpret the values: `temporal` for dates, `quantitative` for numbers, `nominal` for categories. -The chart component fills its parent container. Set width and height on the wrapper element. +Set the wrapper width to the desired embed width. An explicit height supplies the figure budget; omitted `chromeLayout` can grow it by the note band. Set `chromeLayout: 'subtract'` for an exact-height slot. Without a height constraint, the mount uses a 400px visualization budget and adds chrome. See this running: [live example](https://tryopendata.github.io/openchart/?story=charts--line-and-area#single-line) @@ -405,7 +405,7 @@ chrome: { ## Change mark type -Swap `mark: 'line'` for any supported mark type. The encoding channels stay the same, the engine handles the rest. +Swap `mark: 'line'` for any supported mark type. Use the channels required by the new mark: arc, waffle, and parliament use `theta` for values; positional marks use `x`/`y`. Mark-specific options must belong to the chosen mark. ```tsx const spec = { @@ -538,7 +538,7 @@ Dark mode adapts the theme automatically: background, text colors, gridlines, ax ## Custom theme -Override any part of the default theme with a `ThemeConfig` object. The engine deep-merges your overrides onto the defaults, so you only specify what you want to change. +Override any part of the default theme with a `ThemeConfig` object. Spec, provider, and component themes deep-merge in that order; nested fields survive and arrays/color pairs replace atomically. Mount options have highest precedence. Only authored overrides are stamped inline, so omitted CSS tokens remain available to host styles. ```tsx import { Chart, VizThemeProvider } from "@opendata-ai/openchart-react"; @@ -848,7 +848,7 @@ const chart = createChart( }, { darkMode: "auto", - responsive: true, + autoResize: true, }, ); @@ -872,7 +872,7 @@ chart.destroy(); | `export(format)` | Export as `'svg'`, `'png'`, `'jpg'`, `'gif'` (animated, needs the optional `gifenc` peer), or `'csv'` | | `destroy()` | Remove DOM elements, disconnect observers | -Responsive mode (enabled by default) uses a `ResizeObserver` on the container, so charts resize automatically when the container changes size. +`autoResize` (enabled by default) uses `ResizeObserver` so charts resize with their container. The spec `responsive` field controls layout adaptation, not observation. For transaction-based editing and changed defaults, see [Migrating to v9](migrating-v9.md). ### Table @@ -993,12 +993,12 @@ The primary input for standard chart types. Source: `core/src/types/spec.ts`. | `data` | `DataRow[]` | (required) | Array of data rows. Each row is a `Record`. Must be non-empty. | | `encoding` | `Encoding` | (required) | Maps data fields to visual channels. See [Encoding](#encoding). | | `chrome` | `Chrome` | `undefined` | Editorial text: title, subtitle, source, byline, footer. See [Chrome](#chrome). | -| `chromeLayout`| `'subtract' \| 'grow'` | `'subtract'` | How chrome text interacts with the container height budget. See [Chrome layout](#chrome-layout). | +| `chromeLayout`| `'subtract' \| 'grow'` | note-band growth | How chrome text interacts with the container height budget. See [Chrome layout](#chrome-layout). | | `annotations` | `Annotation[]` | `undefined` | Text callouts, highlighted ranges, reference lines. See [Annotations](#annotations). | | `labels` | `LabelConfig` | `undefined` | Data label display controls. See [Labels](#labels). | -| `responsive` | `boolean` | `true` | Whether the chart adapts to container width via ResizeObserver. | +| `responsive` | `boolean \| ResponsiveSpec` | `true` | Layout adaptations at compact sizes; mount `autoResize` controls ResizeObserver. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. Deep-merged onto the default theme. See [ThemeConfig](#themeconfig). | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. Defaults to `true`; auto-hides when the container is under 200px tall unless explicitly set. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. Defaults to `true`; auto-hides when the container is under 200px tall unless explicitly set. | | `animation` | `AnimationSpec`| `undefined` | Animation configuration. `true` enables entrance + update/exit animations. See [Animation](#animation). | | `description` | `string` | `undefined` | Alt text for the chart (Vega-Lite aligned). Sugar for `a11y.description`. Auto-generated when absent. See the [accessibility guide](accessibility.md). | | `a11y` | `A11yConfig` | `undefined` | Accessibility overrides: `description` (custom alt text, wins over top-level `description`) and `hidden` (hide from assistive technology). | @@ -1013,6 +1013,8 @@ A plain object with string keys. Values can be numbers, strings, dates, nulls, a ### Mark properties +`MarkDef` is a discriminated union: each object accepts only options for its mark type. TypeScript, JSON Schema, and runtime validation enforce the same contract before sugar expansion. Extend it with an intersection type rather than an interface. + The `mark` field on ChartSpec accepts either a string (`'line'`) or an object with additional mark configuration. LayerSpec children are ChartSpecs, so they use `mark` too. ```ts @@ -1159,6 +1161,7 @@ interface Encoding { y?: EncodingChannel; color?: EncodingChannel; size?: EncodingChannel; + theta?: EncodingChannel; // arc/waffle/parliament quantitative value detail?: EncodingChannel; facet?: FacetChannel; // small multiples -- see Faceting row?: FacetChannel; // vertical facet stack -- see Faceting @@ -1236,14 +1239,14 @@ The engine validates encoding channels at runtime using `MARK_ENCODING_RULES`. S | `area` | temporal, ordinal (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Same encoding as line, filled region | | `bar` | quantitative (req) | nominal, ordinal (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Horizontal bars. x = values, y = categories | | `column` | nominal, ordinal, temporal (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Vertical bars. x = categories, y = values | -| `pie` | -- (opt, unused) | quantitative (req) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | color = slice categories, y = slice values | -| `donut` | -- (opt, unused) | quantitative (req) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | Same as pie with inner radius | +| `pie` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | color = slice categories, theta = slice values | +| `donut` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | quantitative (opt) | nominal (opt) | theta = slice values; same as pie with inner radius | | `dot` | quantitative (req) | nominal, ordinal (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Dot plot. x = values, y = categories | | `scatter` | quantitative (req) | quantitative (req) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Both axes quantitative. size creates bubble chart | | `beeswarm` | quantitative or nominal (opt) | quantitative or nominal (opt) | nominal, ordinal, quantitative (opt) | quantitative (opt) | nominal (opt) | One position is the quantitative value axis; the other (nominal/ordinal) splits into grouped lanes | | `range` | quantitative or nominal (req) | quantitative or nominal (req) | nominal, ordinal (opt) | -- | nominal (opt) | Dumbbell/arrow/range-bar. x+x2 (horizontal) or y+y2 (vertical); orientation-dependent second endpoint | -| `waffle` | -- (opt, unused) | quantitative (req, via `theta`) | nominal, ordinal (req) | -- | nominal (opt) | Unit grid. `theta` (alias for y) = share, color = category | -| `parliament` | -- (opt, unused) | quantitative (req, via `theta`) | nominal, ordinal (req) | -- | nominal (opt) | Hemicycle seats. `theta` (alias for y) = seat count, color = party | +| `waffle` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | -- | nominal (opt) | Unit grid. `theta` = share, color = category | +| `parliament` | -- (opt, unused) | -- (use theta) | nominal, ordinal (req) | -- | nominal (opt) | Hemicycle seats. `theta` = seat count, color = party | | `histogram` | quantitative (req) | quantitative (opt) | nominal, ordinal (opt) | quantitative (opt) | nominal (opt) | Raw values on x; y defaults to the per-bin count. Color groups overlap rather than dodge | | `density` | quantitative (req) | -- (computed) | nominal, ordinal (opt) | -- | nominal (opt) | KDE over raw x values; y is the estimated density | | `calendar` | temporal (req) | -- (not allowed) | quantitative (req) | -- | nominal (opt) | Calendar heatmap. x = daily date, color = per-day value; owns its own geometry | @@ -1254,6 +1257,8 @@ The engine validates encoding channels at runtime using `MARK_ENCODING_RULES`. S **y channel**: Vertical position. Quantitative values for most types. Nominal/ordinal categories for bar and dot (horizontal layout). +**theta channel**: Quantitative part-to-whole values for arc, waffle, and parliament. Their former authored `y` alias is rejected; use `theta`. + **color channel**: Series differentiation. Assigns colors from the categorical palette. Required for pie/donut (defines slices). Optional for all others (creates multi-series with legend). **size channel**: Mark size. Creates bubble charts when applied to scatter. Maps a quantitative field to the dot **radius in pixels**, area-proportionally (sqrt curve); the default range is [3, 30] and `scale.range` overrides it in radius px. A constant lives at `mark.size`, also a radius (default 5, typical 2-12). Note this differs from Vega-Lite, where point `size` is an **area** in px² — a VL size of 110 is `mark.size: 6` here (r = √(area/π)); validation rejects radii above 50 with the conversion. @@ -1347,12 +1352,12 @@ Each field accepts either a plain string or a `ChromeText` object for style over | Field | Position | Default style | | ---------- | ------------------------------- | ------------------------------------------------------------ | -| `eyebrow` | Above title | 11px, semibold, uppercase, theme accent — leading accent dot | -| `title` | Top, above chart | 22px, bold (700), `#333333` | -| `subtitle` | Below title | 15px, normal (400), `#666666` | -| `source` | Below chart area | 12px, normal (400), `#999999` | -| `byline` | Below source | 12px, normal (400), `#999999` | -| `footer` | Below byline | 12px, normal (400), `#999999` | +| `eyebrow` | Above title | 11px, medium (500), uppercase, theme accent — leading accent dot | +| `title` | Top, above chart | 26px, semibold (600), `#09090b` | +| `subtitle` | Below title | 14px, normal (400), `#71717a` | +| `source` | Below chart area | 12px, normal (400), `#71717a` | +| `byline` | Below source | 12px, normal (400), `#71717a` | +| `footer` | Below byline | 12px, normal (400), `#71717a` | | `brand` | Right-aligned on the source row | 12px, suppresses the default watermark | **KPI metric row** is a separate top-level field on chart specs, not part of `Chrome`. Use `metrics: Metric[]` on the chart spec for a horizontal row of label+value cells rendered between the subtitle and the chart area. Each cell can carry a delta and a secondary value. See `Metric` in `core/src/types/spec.ts` for the full shape. @@ -1406,7 +1411,8 @@ The top-level `chromeLayout` property controls how chrome text height interacts | Value | Behavior | | ------------ | ---------------------------------------------------------------------------------------------------------------------------------------------- | -| `'subtract'` | Default. The container height is fixed and chrome text (title, subtitle, source) is subtracted from it, shrinking the plot. Historical behavior. | +| omitted | Notes and the series summary grow the figure; title, subtitle, source, and other chrome subtract from the container height budget. | +| `'subtract'` | Keep the exact outer height; all chrome and notes subtract from it, shrinking the plot. | | `'grow'` | The container height is treated as the plot budget. The rendered SVG grows taller by the measured chrome height, so the plot keeps its full budget (minus axis and padding overhead) no matter how many lines the title wraps to. | Reach for `'grow'` on fixed-height article or blog charts where a long title on a narrow phone must not compress the plot. @@ -1421,7 +1427,7 @@ Reach for `'grow'` on fixed-height article or blog charts where a long title on } ``` -`chromeLayout: 'grow'` is a no-op for faceted (small-multiples) specs in this version and falls back to `'subtract'`. It is also honored by bar list, sankey, map, and tilemap specs via the spec or compile option. +`chromeLayout: 'grow'` supports faceted charts and layers; engine-added height is counted once. Read `layout.dimensions.height` for the final height and `layout.chrome.grownHeight` for the added height. The default note-growth policy is internal: `'notes'` is not a public spec value. Bar list, Sankey, map, and tilemap also honor the public spec/compile policies. --- @@ -1458,6 +1464,8 @@ Carrying both bin edges matters: the end edge goes to `x2`, which is what puts t Data-positioned overlays. Source: `core/src/types/spec.ts`. +Use unique annotation `id` values for durable selection and editing. Notes preserve complete primary/subtitle text and rich spans; their numbers follow source text-annotation order before filtering, so gaps are intentional. Notes and markers support linked focus and click/tap emphasis. + Three annotation types are available, discriminated by the `type` field: ### TextAnnotation @@ -1471,15 +1479,17 @@ A callout label positioned at a data coordinate. | `y` | `string \| number` | (required) | Y-axis data value or position. | | `text` | `string` | (required) | The annotation text. Supports `\n` for line breaks and `**bold**` spans. | | `subtitle` | `string` | `undefined` | Optional muted second-tone text rendered below `text`. Use for supporting context (methodology, source, etc.). Supports `**bold**` too. | -| `maxWidth` | `number \| null` | unlimited | Optional positive wrapping width in pixels; null disables wrapping. | -| `subtitleFontSize` | `number` | `round(fontSize * 0.85)` | Optional positive subtitle size in pixels. | +| `maxWidth` | `number \| null` | `min(240, plotWidth - 2 * padding)`, at least 1px | Positive display wrapping width in pixels; null disables wrapping. | +| `subtitleFontSize` | `number` | `round(fontSize * 12 / 13)` | Optional positive subtitle size in pixels. | | `dot` | `boolean \| AnnotationDot` | see below | Open-ring marker on the data point. Appears by default on non-arrowed connectors. `false` for none, an object to restyle. | | `label` | `string` | `undefined` | Additional label text. | | `fontSize` | `number` | `13` | Font size override in pixels. | -| `fontWeight` | `number` | `400` (`700` with a `subtitle`) | Font weight override. | +| `priority` | `number` | source order | Lower values get placement and compact-inline preference; ties retain source order. | +| `responsive` | `boolean` | `true` | `false` pins the annotation inline and exempts it from thinning/count limits. | +| `fontWeight` | `number` | `400` | Font weight override. | | `offset` | `AnnotationOffset` | `undefined` | Pixel offset from computed position. `{ dx?: number, dy?: number }`. | | `anchor` | `AnnotationAnchor` | `'auto'` | Label placement direction: `'top'`, `'bottom'`, `'left'`, `'right'`, `'auto'`. | -| `connector` | `boolean \| ConnectorType \| ConnectorConfig` | `true` | `'straight'`, `'curve'`, or `'drop-line'`; `{ type, arrow? }` for explicit arrow control; `false` disables it. | +| `connector` | `boolean \| ConnectorType \| ConnectorConfig` | `true` | `'straight'`, `'curve'`, or `'drop-line'`; `{ type, arrow?, bend? }` for explicit control; `false` disables it. | | `connectorOffset` | `{ from?, to? }` | `undefined` | Per-endpoint pixel offsets for the connector line. | | `background` | `string \| true` | `undefined` | Background plate behind the text, for readability over chart lines. `true` follows the theme surface. | | `fill` | `string` | theme `annotationFill` | Fill color. | @@ -1487,7 +1497,7 @@ A callout label positioned at a data coordinate. | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. Higher values render on top. | -#### Text: line breaks, bold spans, and the lede rule +#### Text: wrapping, line breaks, and bold spans Curve connectors accept optional `bend: -1..1`; omitted or zero preserves the existing automatic curve. Other connector types ignore `bend` with a warning. @@ -1503,14 +1513,14 @@ An authored `offset` object pins placement even when both values are zero. Only matched pairs turn bold. An unmatched `**` (and an empty `****`) renders literally, so specs with stray asterisks aren't mangled. Bold spans are measured at weight 700, so bounds, collision nudging, and connector exits all account for them. -When a `subtitle` is present and you set no `fontWeight`, the primary text resolves to weight 700 and the subtitle stays 400. That's the lede + context stack ("Feb. 25" over "2015 maximum"). Setting `fontWeight` explicitly opts out. +Primary text and subtitles default to weight 400. Set `fontWeight: 700` for a bold primary line or use `**bold**` for selective emphasis; adding a subtitle does not change the primary weight. #### Connectors The connector leaves the text block (primary text plus subtitle, treated as one box) on the side facing the data point, with a 6px standoff gap, and stops short of the marker at the other end. Two voices: -- **Emphasis.** An arrowed connector (`'curve'` defaults to `arrow: true`) is drawn in the label's text ink with a small open-V arrowhead, so the gesture reads as part of the sentence. -- **Quiet.** A non-arrowed straight leader or a `'drop-line'` is a gray hairline. It points without competing with the words. +- **Emphasis.** An explicitly arrowed connector (`{ type: 'curve', arrow: true }`) is drawn in the label's text ink with a small open-V arrowhead, so the gesture reads as part of the sentence. +- **Quiet.** A non-arrowed curve, straight leader, or `'drop-line'` is a gray hairline. It points without competing with the words. Connectors are dropped when they'd be noise rather than signal: a leader shorter than 8px, or one whose data point sits inside the text block. The marker still renders. A default annotation sits far enough off its point (28px) to draw a real leader without any `offset` authoring; you only lose the line by pulling the label back onto its own marker. @@ -1519,7 +1529,7 @@ Connectors are dropped when they'd be noise rather than signal: a leader shorter | Field | Type | Default | Description | | ------------- | -------- | --------------------------- | --------------------------------- | | `radius` | `number` | `4` | Circle radius in pixels. | -| `fill` | `string` | theme background | Interior fill (the "open ring"). | +| `fill` | `string` | theme `annotationMarkerFill` (neutral surface by default) | Interior fill (the "open ring"). | | `stroke` | `string` | the connector's stroke | Ring color. | | `strokeWidth` | `number` | `1.5` | Ring width in pixels. | @@ -1540,9 +1550,9 @@ A highlighted band or rectangle. | `label` | `string` | `undefined` | Range label text. | | `labelOffset` | `AnnotationOffset` | `undefined` | Pixel offset for the label. `{ dx?: number, dy?: number }`. | | `labelAnchor` | `AnnotationAnchor` | `'auto'` | Label placement direction. | -| `fontSize` | `number` | `11` | Font size override for the range label (px). | +| `fontSize` | `number` | theme `small` (12px) | Font size override for the range label (px). | | `fontWeight` | `number` | `500` | Font weight override for the range label. | -| `fill` | `string` | theme `annotationFill` | Fill color. | +| `fill` | `string` | theme `annotationRangeFill` (`#f0c040`) | Fill color. | | `stroke` | `string` | `undefined` | Stroke color. | | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. | @@ -1567,11 +1577,11 @@ A horizontal or vertical reference line. | `strokeWidth` | `number` | `1` | Line width in pixels. | | `labelOffset` | `AnnotationOffset` | `undefined` | Pixel offset for the label. | | `labelAnchor` | `AnnotationAnchor` | `'auto'` | Label placement direction. | -| `labelPlacement` | `'auto' \| 'start' \| 'end' \| 'outside'` | legacy anchor behavior | Opt-in screen-space end selection, automatic containment, or a reserved outside gutter/lane. `labelAnchor` selects the side; an authored offset pins the base. | -| `fontSize` | `number` | `11` | Font size override for the label (px). | +| `labelPlacement` | `'auto' \| 'start' \| 'end' \| 'outside'` | `'auto'` unless `labelAnchor` is authored | Screen-space end selection, automatic containment, or a reserved outside gutter/lane. `labelAnchor` selects the side; an authored offset pins the base. | +| `fontSize` | `number` | theme `small` (12px) | Font size override for the label (px). | | `fontWeight` | `number` | `400` | Font weight override for the label. | | `fill` | `string` | `undefined` | Fill color. | -| `stroke` | `string` | theme `annotationText` | Stroke/line color. | +| `stroke` | `string` | theme `annotationRule` | Stroke/line color. | | `opacity` | `number` | `1` | Opacity (0 to 1). | | `zIndex` | `number` | `0` | Render ordering. | @@ -1845,124 +1855,80 @@ For line/area marks, mid-morph interruption uses a crossfade from the frozen int ## ThemeConfig -User-facing theme overrides. Source: `core/src/types/spec.ts`. +Public authored theme overrides live in `core/src/types/spec.ts`. Every field is optional. Use `ThemeConfig` for input and `ResolvedTheme` for compiled output; the former public `Theme` alias is removed. -All fields are optional. The engine deep-merges these onto `DEFAULT_THEME`. You only specify what you want to change. +`mergeThemeConfig()` deep-merges authored layers before `resolveTheme()` supplies defaults. Component theme props override provider themes; mount/compile options override the spec. Layer leaf themes override their parent first. Nested objects merge, arrays and `{ light, dark }` TokenValues replace atomically, and null replaces. `colors: string[]` is shorthand for `colors.categorical`. ```ts -interface ThemeConfig { - colors?: { - categorical?: string[]; - categoricalFill?: string[]; - sequential?: Record; - diverging?: Record; - background?: string; - text?: string; - gridline?: string; - hairline?: string; - axis?: string; - }; - fonts?: { - family?: string; - mono?: string; - }; - spacing?: { - padding?: number; - chromeGap?: number; - }; - rule?: { color: TokenValue; width: number; thickness: number }; - borderRadius?: number; -} -``` - -### Color overrides +import { broadsheet, mergeThemeConfig } from '@opendata-ai/openchart-core'; +import type { ThemeConfig } from '@opendata-ai/openchart-core'; -| Field | Type | Default | Description | -| -------------------- | -------------------------- | --------------------------------- | ----------------------------------------------------------------- | -| `colors.categorical` | `string[]` | 8-color palette | Categorical palette for nominal data. Array of CSS color strings. | -| `colors.categoricalFill` | `string[]` | copied from `categorical` | Quieter palette used for area-filling marks (bar, area, arc, waffle, calendar, rect). Set it only to control fills and strokes independently; leaving it unset copies `categorical` so a custom palette never drifts between the two. | -| `colors.sequential` | `Record` | `{ blue, green, orange, purple }` | Sequential palettes keyed by name. | -| `colors.diverging` | `Record` | `{ redBlue, brownTeal }` | Diverging palettes keyed by name. | -| `colors.background` | `string` | `'#ffffff'` | Visualization background color. | -| `colors.text` | `string` | `'#1d1d1d'` | Default text color. | -| `colors.gridline` | `string` | `'#e8e8e8'` | Gridline color. | -| `colors.hairline` | `string` | `'rgba(0,0,0,0.14)'` | Structural line color for axis lines and separators. Distinct from `gridline`, which is lighter. | -| `colors.axis` | `string` | `'#888888'` | Axis line and tick color. | +const theme: ThemeConfig = mergeThemeConfig(broadsheet, { + fonts: { sizes: { small: 12, axisTick: 12 } }, + chrome: { title: { fontSize: 28 } }, + rule: { width: 80 }, +}); +``` -### Font overrides +Authored `chrome..fontSize` takes precedence over propagation from `fonts.sizes`, including across theme layers. Theme chrome values are objects such as `{ color: '#333' }`; string color shorthand is removed. Spec chrome text shorthand (`chrome: { title: 'A title' }`) remains valid. -| Field | Type | Default | Description | -| -------------- | -------- | ----------------------------------------- | ----------------------------------- | -| `fonts.family` | `string` | `'Inter, -apple-system, ..., sans-serif'` | Primary font family. | -| `fonts.mono` | `string` | `'"JetBrains Mono", ..., monospace'` | Monospace font for tabular numbers. | +### Color overrides -### Spacing overrides +Single colors accept `TokenValue` (`string | { light: string; dark: string }`). Palette arrays contain color strings. These are light-mode defaults unless a pair is shown: -| Field | Type | Default | Description | -| ------------------- | -------- | ------- | ---------------------------------------- | -| `spacing.padding` | `number` | `12` | Padding inside the chart container (px). | -| `spacing.chromeGap` | `number` | `4` | Gap between chrome elements (px). | +| Field | Default | Description | +|---|---|---| +| `colors.categorical` | Six-hue palette | Nominal-series stroke palette | +| `colors.categoricalFill` | Default fill palette; inherits an authored categorical palette when omitted | Quieter fill colors for area-filling marks | +| `colors.sequential` / `diverging` | Named palette maps | Numeric color scales | +| `colors.background` | `transparent` | Visualization background; opaque surfaces derive from the neutral surface | +| `colors.text` | `#09090b` | Main text ink | +| `colors.gridline` | `rgba(0,0,0,0.08)` | Grid lines | +| `colors.hairline` | `rgba(0,0,0,0.14)` | Structural separators | +| `colors.axis` | `#71717a` | Axis lines and tick text | +| `colors.annotationLabel` | `#333333` | Primary callout ink | +| `colors.annotationMuted` | `#6b7280` | Subtitle and note ink | +| `colors.annotationLeader` | `#8f8f8f` | Quiet connector ink | +| `colors.annotationRule` | `#888888` / `#9ca3af` | Reference line ink, light/dark | +| `colors.annotationRangeFill` | `#f0c040` | Range fill before opacity | +| `colors.annotationMarkerFill` / `annotationPlate` | Resolved neutral surface | Marker interior and label plate; explicit colors win | + +Generic `annotationFill` and `annotationText` retain their other uses. See [Theming tokens](theming-tokens.md) for the complete token and dark-mode mapping. + +### Fonts, chrome, and spacing + +| Field | Default | Description | +|---|---|---| +| `fonts.family` | Inter Variable, Inter, system sans-serif | Primary font family | +| `fonts.mono` | JetBrains Mono, Fira Code, Cascadia Code, monospace | Tabular/monospace family | +| `fonts.sizes.title` / `subtitle` | 26 / 14 | General title sizes | +| `fonts.sizes.body` / `small` / `axisTick` | 13 / 12 / 12 | Body, small, and tick sizes | +| `fonts.sizes.metricLabel` / `metricValue` | 11 / 22 | Metric row sizes | +| `fonts.weights.normal` / `medium` / `semibold` / `bold` | 400 / 500 / 600 / 700 | Weight ladder | +| `chrome.eyebrow` | 11px, 500, `#06b6d4`, 1.4 line-height | Eyebrow style | +| `chrome.title` | 26px, 600, `#09090b`, 1.15 line-height | Title style | +| `chrome.subtitle` | 14px, 400, `#71717a`, 1.45 line-height | Subtitle style | +| `chrome.source` / `byline` / `footer` | 12px, 400, `#71717a`, 1.4 line-height | Attribution styles | +| `spacing.padding` / `chromeGap` | 20 / 6 | Container padding / gap between chrome elements | +| `spacing.chromeToChart` / `chartToFooter` | 16 / 12 | Gaps above and below the chart | +| `spacing.axisMargin` | 6 | Axis margin | +| `borderRadius` | 8 | Containers and tooltips | ### The editorial rule -`rule` draws a short colored bar above the chrome block — the Economist/FT -masthead device. Omit it (the default on every preset but `broadsheet`) for no -rule. +`rule` draws a short colored bar above top chrome. Omission inherits a preset rule, or draws none on the default theme. `rule: null` removes an inherited rule; partial objects preserve unmodified fields. -```ts -theme: { - rule: { color: { light: '#e3120b', dark: '#f4463f' }, width: 40, thickness: 3 }, -} -``` - -| Field | Type | Default | Description | -| ---------------- | ------------ | ----------- | ------------------------------------------------------------------ | -| `rule.color` | `TokenValue` | (required) | Rule color. A `TokenValue` accepts a `{ light, dark }` pair, so it needs no dark-mode branch. | -| `rule.width` | `number` | (required) | Rule length in pixels. | -| `rule.thickness` | `number` | (required) | Rule height in pixels. `computeChrome` reserves `thickness + chromeGap` above the eyebrow/title. | +| Field | Fallback when a rule is authored without a preset | Description | +|---|---|---| +| `rule.color` | Theme text ink | TokenValue color, including light/dark pairs | +| `rule.width` | 40px | Rule length | +| `rule.thickness` | 3px | Rule height; chrome reserves thickness plus chromeGap | No top chrome means no rule. -### Other - -| Field | Type | Default | Description | -| -------------- | -------- | ------- | -------------------------------------------------------- | -| `borderRadius` | `number` | `4` | Border radius for the chart container and tooltips (px). | - -### DEFAULT_THEME reference - -The full default theme (from `core/src/theme/defaults.ts`): - -| Property | Value | -| ------------------------ | ------------------------------------------------------------------------ | -| `colors.background` | `#ffffff` | -| `colors.text` | `#1d1d1d` | -| `colors.gridline` | `#e8e8e8` | -| `colors.axis` | `#888888` | -| `colors.annotationFill` | `rgba(0,0,0,0.04)` | -| `colors.annotationText` | `#555555` | -| `fonts.family` | Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif | -| `fonts.mono` | "JetBrains Mono", "Fira Code", "Cascadia Code", monospace | -| `fonts.sizes.title` | 22 | -| `fonts.sizes.subtitle` | 15 | -| `fonts.sizes.body` | 13 | -| `fonts.sizes.small` | 11 | -| `fonts.sizes.axisTick` | 11 | -| `fonts.weights.normal` | 400 | -| `fonts.weights.medium` | 500 | -| `fonts.weights.semibold` | 600 | -| `fonts.weights.bold` | 700 | -| `spacing.padding` | 12 | -| `spacing.chromeGap` | 4 | -| `spacing.chromeToChart` | 8 | -| `spacing.chartToFooter` | 8 | -| `spacing.axisMargin` | 6 | -| `borderRadius` | 4 | -| `chrome.title` | 22px, 700 weight, `#333333`, 1.3 line-height | -| `chrome.subtitle` | 15px, 400 weight, `#666666`, 1.4 line-height | -| `chrome.source` | 12px, 400 weight, `#999999`, 1.3 line-height | -| `chrome.byline` | 12px, 400 weight, `#999999`, 1.3 line-height | -| `chrome.footer` | 12px, 400 weight, `#999999`, 1.3 line-height | +The named presets are `essay`, `wire`, `broadsheet`, and `terminal`. Omit `theme` or pass `{}` for the default; `editorial` is removed. `clarity` is deprecated and contains only its background, axis, and eyebrow colors. `legacyEditorial` retains v8 typography for migration. + +Only authored theme overrides and derived values are stamped inline. Omitted CSS tokens remain overridable by host styles, including `--oc-border-radius`; removing an override restores the host's previous inline value. Export captures live token values. --- @@ -2005,9 +1971,8 @@ Input for data table visualizations. Source: `core/src/types/spec.ts`. | `striped` | `boolean` | `false` | Zebra striping on body rows. Hairlines only by default. | | `sort` | `SortState` | first inline-bar column, descending | Initial sort applied before the user touches a header. `{ column: string, direction: 'asc' \| 'desc' }`. | | `totalRow` | `boolean \| { label?: string }` | `false` | Sticky footer summing every quantitative column over the filtered rows (the whole result set, not just the current page). `true` uses the label `'Total'`. | -| `compact` | `boolean` | `false` | **Deprecated.** Alias for `density: 'condensed'`; still honored, with a warning. | | `responsive` | `boolean` | `true` | Whether the table adapts to container width. | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. | ### ColumnConfig @@ -2123,8 +2088,11 @@ Pass these through `MountOptions` (vanilla) or `ChartProps` (React). | `onMarkClick` | `(event: MarkEvent) => void` | User clicks a data mark (bar, point, line, arc). | | `onMarkHover` | `(event: MarkEvent) => void` | Mouse enters a data mark. | | `onMarkLeave` | `() => void` | Mouse leaves a data mark. | +| `onEdit` | `(transaction: EditTransaction) => void` | One committed authored edit; apply atomically with `applyEditTransaction` and retain its inverse for undo. | | `onLegendToggle` | `(series: string, visible: boolean) => void` | User clicks a legend entry to show/hide a series. | -| `onAnnotationClick` | `(annotation: Annotation, event: MouseEvent) => void` | User clicks an annotation element. | +| `onAnnotationClick` | `(annotation: Annotation, event: MouseEvent, element: ElementRef) => void` | User clicks an annotation element. | + +See [Editing and keyboard navigation](migrating-v9.md#editing-and-keyboard-navigation) for transaction persistence and cancelled-gesture behavior. ### MarkEvent @@ -2167,7 +2135,7 @@ Graphs render force-directed network visualizations on canvas. They support node | `chrome` | `Chrome` | `undefined` | Editorial text. | | `annotations` | `Annotation[]` | `undefined` | Annotations. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | -| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options. | +| `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over spec and active breakpoint values. | ### GraphEncoding @@ -2230,7 +2198,6 @@ Sankey diagrams show flows between stages. Each data row represents a link from | `linkStyle` | `SankeyLinkColor` | `'gradient'`| Link coloring: `'gradient'`, `'source'`, `'target'`, `'neutral'`. | | `linkOpacity` | `number` | `0.5` | Link fill opacity (0-1). Defaults to 0.75 in dark mode. | | `nodeLabelAlign`| `string` | `'auto'` | Label placement: `'auto'`, `'left'`, `'right'`. | -| `valueFormat` | `string` | `undefined` | d3-format string for values in tooltips and ARIA labels (e.g. `"$,.0f"`, `"~s"`). | | `chrome` | `Chrome` | `undefined` | Editorial text (title, subtitle, source, byline, footer). | | `legend` | `LegendConfig` | `undefined` | Legend display configuration. | | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | @@ -2295,7 +2262,6 @@ Maps render TopoJSON geometries as SVG, join tabular data rows to features by id | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | | `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. | | `animation` | `AnimationSpec` | `undefined` | Entrance animation configuration. | -| `valueFormat` | `string` | `undefined` | Deprecated: use `encoding.color.format` instead. d3-format string for color values in tooltips and the legend (e.g. `".1f"`). `encoding.color.format` wins when both are set. | ### GeoMapGeo @@ -2434,7 +2400,7 @@ Every state renders as an equal-size square in a fixed 12x8 grid, so small state | `theme` | `ThemeConfig` | `undefined` | Theme overrides. | | `watermark` | `boolean` | `true` | Whether to show the tryOpenData.ai watermark. | | `animation` | `AnimationSpec` | `undefined` | Entrance animation configuration. | -| `valueFormat` | `string` | `undefined` | Deprecated: use `encoding.value.format` instead. d3-format string for tile values, legend labels, and tooltips. | +| `valueFormat` | `string` | `undefined` | Tilemap fallback d3-format string for values, legend labels, and tooltips; `encoding.value.format` takes precedence. | ### TileMapEncoding @@ -2449,7 +2415,7 @@ Every state renders as an equal-size square in a fixed 12x8 grid, so small state - **Quantitative mode** renders a gradient legend bar with min/max labels below the grid. **Categorical mode** (string values, a `color` channel, or a `colors` map) renders swatch rows instead. - States missing from the data render as empty tiles, keeping their place on the grid. -- **Number formatting:** set `encoding.value.format` (d3-format string, e.g. `".1f"`, `"$,.0f"`, `"~s"`). It applies to tile values, legend labels, and tooltips, and wins over the deprecated top-level `valueFormat`. With record-map data and no explicit encoding, `valueFormat` still applies (with a deprecation warning). +- **Number formatting:** set `encoding.value.format` (d3-format string, e.g. `".1f"`, `"$,.0f"`, `"~s"`). It applies to tile values, legend labels, and tooltips, and wins over top-level `valueFormat`. With record-map data and no explicit encoding, `valueFormat` remains supported. - **`scale.scheme` fails validation on tilemap encoding channels.** The tilemap compile path never reads it; the top-level `palette` property is the mechanism tilemaps use for color. ### Tilemap example @@ -2602,7 +2568,7 @@ const spec: ChartSpec = { { category: "Tablet", share: 7 }, ], encoding: { - y: { field: "share", type: "quantitative" }, + theta: { field: "share", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, chrome: { title: "Traffic by device" }, @@ -2622,7 +2588,7 @@ const spec: ChartSpec = { { status: "Blocked", count: 5 }, ], encoding: { - y: { field: "count", type: "quantitative" }, + theta: { field: "count", type: "quantitative" }, color: { field: "status", type: "nominal" }, }, chrome: { title: "Task status" }, @@ -2762,9 +2728,9 @@ All builders accept field names as strings (auto-infer type from data) or full ` | `annotations` | `Annotation[]` | Annotations. | | `responsive` | `boolean` | Responsive behavior. | | `theme` | `ThemeConfig` | Theme overrides. | -| `watermark` | `boolean` | Whether to show the tryOpenData.ai watermark. Spec-level value takes precedence over mount/compile options; defaults to `true`. | +| `watermark` | `boolean` | Whether to show the tryOpenData.ai watermark. Mount/compile options take precedence over the spec; defaults to `true`. | -`FieldRef` is `string | EncodingChannel`. When a string is provided, `inferFieldType()` samples up to 20 data values to determine the encoding type (quantitative, temporal, or nominal). +`FieldRef` is `string | EncodingChannel`. When a string is provided, `inferFieldType()` uses the shared builder/compiler inference: sample up to 50 rows, ignore nulls, and require more than 80% agreement. Numeric strings are considered before dates, so a column of `'2020'` values is quantitative. Set the channel type explicitly when years should be ordinal or temporal. ### Builder example @@ -2826,9 +2792,9 @@ const result = validateSpec(spec); ## Authoring foundation additions -The separate [OpenStudio application](https://github.com/tryopendata/openstudio) consumes these additive interfaces. Existing v8 visual and animation defaults remain unchanged. +These library interfaces support authoring and reader applications. OpenStudio adoption is separate; see [Migrating to v9](migrating-v9.md) for the changed defaults and removed APIs. -- **Theme:** import `clarity` for white/ink surfaces, Inter titles and 12px axes; `legacyEditorial` restores documented v8 typography without freezing layout behavior. +- **Theme:** v9 defaults include 12px axes/small text and larger chrome gaps. `clarity` is deprecated and retains only background, axis, and eyebrow colors. `legacyEditorial` restores v8 typography; `migrateSpecV8` can materialize the wider v8 styling contract without freezing layout behavior. - **Motion:** `animation: 'fluid' | 'subtle' (omit the preset for v8 timing)`; explicit phases override their preset. A phase can use `spring: { stiffness: 320, damping: 36, mass: 1 }` or the existing duration/easing configuration. Combining a spring with duration/easing in the same phase is an error. Quantitative springs are bounded, with a finite terminal state. Omitted/false animation remains disabled. - **Responsiveness:** `responsive: { preserveEnd: true, labelSummary: true }` retains the final x-axis boundary and moves overflowing endpoint identities to a wrapped list. Explicit axis values and author choices remain authoritative. `chromeLayout: 'grow'` gives that content space. Tables may opt into `mobileLayout: 'priority'`; `cards` remains the default, with `scroll` also available. - **Field metadata:** chart, table and map documents accept `fields`, keyed by field name. Each entry may declare `type` (`text`, `number`, `date`), `role: 'identity'`, `label`, `unit`, `measure`, `provenance`, `representation` (`number`, `percent`, `proportion`), `interval: { lower: 'lowerField', upper: 'upperField' }`, or `statusField`. Supplied intervals are never synthesized. Percent means 0–100 and proportion means 0–1. @@ -2846,18 +2812,18 @@ Maps, tile maps, bar lists and Sankey support arrows for inspection and Enter/Sp Table `rowKey` values must be unique to support identity across revisions. Numeric `1` and string `"1"` are distinct. Missing/duplicate keys emit `TABLE_ROW_KEY_AMBIGUOUS` and use revision-local identities. Treat compiled row IDs as opaque. CSV export includes all filtered and sorted rows, not just the current pagination page. -### Compact callout limits and note sizing (8.9.1) +### Compact callout limits and note sizing `responsive.compactAnnotationLimit?: number | null` is an optional nonnegative -integer limit below 400px outer width. Omitted/null means unlimited; zero demotes -all eligible callouts. `responsive: false` callouts are exempt. Priority/source +integer limit below 400px outer width. Omitted means one; null means unlimited, +and zero demotes all eligible callouts. `responsive: false` callouts are exempt. Priority/source order precedes coverage/collision thinning. Existing disabled-response and short-height rules still apply. Facet panel width does not trigger this limit. Read `layout.chrome.noteBandHeight` for note space and `layout.dimensions.height` for the final rendered/exported height. `chromeLayout: 'grow'` also supports facets; `chrome.grownHeight` reports engine-added height for auto-height consumers. -See [annotation migration guidance](./migrating-v8.md#annotation-layout-opt-ins). +See [annotation migration guidance](./migrating-v9.md#changed-annotation-and-figure-defaults). --- @@ -3060,13 +3026,13 @@ const spec = { { os: "Other", share: 1 }, ], encoding: { - y: { field: "share", type: "quantitative" }, + theta: { field: "share", type: "quantitative" }, color: { field: "os", type: "nominal" }, }, }; ``` -The `y` channel maps to slice values, `color` maps to slice categories. No `x` encoding is needed. +The `theta` channel maps to slice values, `color` maps to slice categories. No `x` encoding is needed. **Live examples**: [Basic pie](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#pie-inline-labels) | [Small slice grouping](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#small-slice-grouping) | [Seven categories](https://tryopendata.github.io/openchart/?story=charts--pie-and-donut#many-categories) @@ -3087,7 +3053,7 @@ const spec = { { category: "All Other", spending: 29 }, ], encoding: { - y: { field: "spending", type: "quantitative" }, + theta: { field: "spending", type: "quantitative" }, color: { field: "category", type: "nominal" }, }, }; @@ -3212,7 +3178,7 @@ const spec = { ``` -Options: `nodeWidth`, `nodePadding`, `nodeAlign` (`'justify'` | `'left'` | `'right'` | `'center'`), `linkStyle` (`'gradient'` | `'source'` | `'target'` | `'neutral'`), `encoding.value.format` for number formatting (top-level `valueFormat` is deprecated in v8). +Options: `nodeWidth`, `nodePadding`, `nodeAlign` (`'justify'` | `'left'` | `'right'` | `'center'`), `linkStyle` (`'gradient'` | `'source'` | `'target'` | `'neutral'`), `encoding.value.format` for number formatting (top-level `valueFormat` is removed in v9). For the full field reference, see [SankeySpec in spec-reference.md](spec-reference.md#sankeyspec). @@ -3431,7 +3397,7 @@ const spec = { }; ``` -`theta` is the quantitative share value (the same part-to-whole channel arc marks use; it is an alias for `y`, so provide one of the two). `color` is the required category. Values normalize to the grid via largest-remainder rounding, so the cells always sum exactly even when the shares are fractional. +`theta` is the quantitative share value (the same part-to-whole channel arc marks use; the old authored `y` alias is removed). `color` is the required category. Values normalize to the grid via largest-remainder rounding, so the cells always sum exactly even when the shares are fractional. **Mark options:** - `units` (default 100) -- total number of cells in the grid @@ -3497,7 +3463,7 @@ const spec = { }; ``` -`theta` is the quantitative seat count (an alias for `y`; provide one of the two) and `color` is the required party. Order the data left to right by political group; the layout preserves that order across the arc. Party colors typically come from an explicit `color.scale.range` rather than the default palette. +`theta` is the quantitative seat count (the old authored `y` alias is removed) and `color` is the required party. Order the data left to right by political group; the layout preserves that order across the arc. Party colors typically come from an explicit `color.scale.range` rather than the default palette. **Mark options:** - `shape` (`'hemicycle'`, the default and only shape) @@ -4162,7 +4128,6 @@ All features are opt-in via top-level `TableSpec` properties. | Striping | `striped: true` | `false` | Zebra rows. Hairlines only by default | | Initial sort | `sort: { column, direction }` | first inline-bar column, desc | Sort applied before the user touches a header | | Total row | `totalRow: true` | `false` | Sticky footer summing every quantitative column with a summable cell type (text/bar/heatmap). Set `columns[].total: false` to leave an individual column's total cell blank | -| Compact mode | `compact: true` | `false` | **Deprecated.** Alias for `density: 'condensed'`; warns | | Responsive | `responsive: true` | `true` | Table adapts to container width | In controlled mode a `sort` of `null` means "the user cleared sorting" and @@ -4260,19 +4225,15 @@ For controlled component patterns and detailed event handling, see the [integrat -# Migration Guide - -## Theming Design Tokens - -This release widens `ThemeConfig` to expose every design-decision field and unifies CSS custom properties with the JS theme engine. All changes are backward-compatible. Existing theme configs continue to work without modification. - -### What changed +# Theming Design Tokens -**Before:** `ThemeConfig` covered colors, font family/sizes, 4 spacing fields, border radius, and chrome color (string only). Font weights, chrome typography, semantic colors, and additional spacing fields were hardcoded in `DEFAULT_THEME` with no override path. CSS custom properties in `tokens.css`/`dark.css` were hand-maintained separately from the JS theme. +`ThemeConfig` controls colors, typography, spacing, rules, and component surfaces. +Defaults are shared by the JavaScript theme and generated CSS tokens. v9 +consolidates authored deep merging and leaves omitted CSS tokens available to +host styles. For removed forms and legacy styling, see [Migrating to v9](migrating-v9.md). -**After:** `ThemeConfig` covers everything. CSS custom properties are generated from the resolved JS theme at mount time. The static CSS files serve as SSR/pre-mount fallback defaults. +### ThemeConfig fields -### New ThemeConfig fields ```ts const theme: ThemeConfig = { @@ -4355,11 +4316,10 @@ The neutral grays are surface-aware: on light backgrounds the second series gets ### Named presets -Five presets exported from `@opendata-ai/openchart-core`: +Four primary presets exported from `@opendata-ai/openchart-core`: | Preset | Feel | |--------|------| -| `editorial` | Current default (empty config, zero drift) | | `essay` | Serif titles, warm background, generous spacing | | `wire` | Monospace, dense, tight chrome, dashboard feel | | `broadsheet` | Newspaper editorial: warm paper, red masthead `rule` and eyebrow, 24/700 title, square corners | @@ -4409,11 +4369,11 @@ fixed zinc ladder. A warm or cool custom theme gets warm or cool grays for free instead of zinc showing up uninvited beside the palette. The ramp lives on `ResolvedTheme.colors.neutral` (`100`/`200`/`300`/`400`/`600`/`800`, plus `secondary` = `800`, `faint` = `300`, `border` = `100`, plus `surface`) and is -computed once in `resolveTheme`/`adaptTheme`; mounts stamp the CSS tokens from -it. `surface` is the opaque color the theme paints on — the background itself +computed once in `resolveTheme`/`adaptTheme`. Mounts stamp derived tokens when +the relevant theme colors are authored; omitted tokens use the CSS cascade. `surface` is the opaque color the theme paints on — the background itself when it is opaque, otherwise the mode's `--oc-bg` token — and is the single source for knockout rings, stacked-segment seams, table cell fills, graph node -rings and the stamped `--oc-bg`. When the +rings, annotation marker/plate fills, and authored background tokens. When the background is transparent (the default) the ramp falls back to the static token defaults, which is why the default theme and the generated `tokens.css` never disagree. @@ -4431,9 +4391,9 @@ never disagree. | `--oc-map-hover-dim` | 0.75 | map-local override; dimming 3000 county paths at 0.3 reads as a blackout, so maps use a lighter dim and only on legend-bin hover, never on feature hover | | `--oc-positive-tint` / `--oc-negative-tint` | 10% color-mix toward `--oc-bg` | delta chip backgrounds | -`--oc-border-radius` (8px) drives containers and tooltips. In v8, mounts stamp -resolved theme tokens inline, so ordinary stylesheet overrides of these tokens -do not win. Set `theme.borderRadius` to change this value. Mark geometry uses the fixed `--oc-radius-sm` +`--oc-border-radius` (8px) drives containers and tooltips. In v9, CSS overrides +work when `theme.borderRadius` is omitted. An authored `theme.borderRadius` +takes precedence. Mark geometry uses the fixed `--oc-radius-sm` regardless, so a theme with `borderRadius: 0` still draws a 2px bar-end radius. @@ -4442,7 +4402,7 @@ radius. `token-theme-parity` test asserts this, along with the four weight tokens, `--oc-gridline`, `--oc-axis`, `--oc-border-radius`, `colors.neutral`, and `--oc-animation-stagger`, so the JS theme and the generated CSS tokens can't -drift apart the way they did before this release. +drift apart. ### The editorial rule @@ -4462,16 +4422,30 @@ as `chrome.*.color` and needs no dark-mode branch. `computeChrome` reserves ### CSS custom properties -`--oc-*` custom properties are now stamped on each `.oc-root` container from the resolved JS theme at mount time. This means: +Defaults remain in `tokens.css` and `dark.css`. The shared renderer helper stamps +only authored theme leaves and values derived from them. A host stylesheet can +override omitted tokens, including `--oc-border-radius`; an authored theme value +wins via its inline style. Removing a theme override restores the host value. +SVG and raster exports retain the live CSS token values before leaving the page. -- **Two charts with different themes on the same page** each get correct `--oc-*` values (per-container scoping). -- **`tokens.css` and `dark.css` are fallback defaults**, not the source of truth. They still work for SSR, static HTML, and pre-mount styling. -- **If you were reading `--oc-*` values from CSS** to style surrounding UI, those values now reflect the actual resolved theme rather than the hardcoded defaults. This is almost certainly what you wanted, but worth noting. +Theme layers deep-merge in this order: spec theme, provider theme, component prop +(or mount/compile option). For layered charts, a leaf theme overrides its parent. +Objects merge by leaf; arrays and `{ light, dark }` pairs replace atomically. +`colors: string[]` remains shorthand for `colors.categorical`. `null` replaces: +use `rule: null` to remove a preset rule. Partial rules inherit unspecified +preset values; without a preset, width is 40px, thickness is 3px, and color is +theme text ink. Authored `chrome..fontSize` overrides the general +`fonts.sizes` scale. Chrome color shorthand is now `{ color: '...' }`. -No action required. If you import `tokens.css`/`dark.css`, keep doing so. The JS-stamped properties take precedence via inline style specificity. +The default small/axis tick/source/byline/footer size is 12px; chrome-to-chart +spacing is 16px and chart-to-footer spacing is 12px. The background remains +transparent. The empty `editorial` preset was removed (omit `theme` instead). +`clarity` is deprecated and retains only its background, axis, and eyebrow +colors. Use `ThemeConfig` for input and `ResolvedTheme` for output; the `Theme` +alias was removed. -### Annotation tokens (8.9.1) +### Annotation tokens `theme.colors` accepts `annotationLabel`, `annotationMuted`, `annotationLeader`, `annotationRule`, `annotationRangeFill`, `annotationMarkerFill`, and @@ -4480,4 +4454,6 @@ are `--oc-annotation-label`, `--oc-annotation-muted`, `--oc-annotation-leader`, `--oc-annotation-rule`, `--oc-annotation-range-fill`, `--oc-annotation-marker-fill`, and `--oc-annotation-plate`. Explicit annotation colors override the tokens. Arrow connectors match label ink. Existing `annotationText`/`annotationFill` -uses are unchanged. Omitted annotation tokens preserve current 8.x output. +uses are unchanged. Default rule ink is #888888 in light mode and #9ca3af in +dark mode; range fill is #f0c040. Marker and plate fills follow the resolved +neutral surface unless explicitly authored. diff --git a/packages/core/schema/chart.schema.json b/packages/core/schema/chart.schema.json index 8d6f03c4..7a684a37 100644 --- a/packages/core/schema/chart.schema.json +++ b/packages/core/schema/chart.schema.json @@ -2324,7 +2324,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for arc marks (pie/donut charts).\n- `theta`: required (quantitative — the canonical slice value channel)\n- `color`: required (nominal/ordinal — the category)\n- `y`: deprecated alias for `theta`, accepted for backward compatibility" + "description": "Encoding for arc marks (pie/donut charts).\n- `theta`: required (quantitative — the canonical slice value channel)\n- `color`: required (nominal/ordinal — the category)" }, "EncodingChannel": { "type": "object", @@ -3930,14 +3930,14 @@ }, "subtitleFontSize": { "type": "number", - "description": "Subtitle size in pixels. Default: round(fontSize * 0.85)." + "description": "Subtitle size in pixels. Default: round(fontSize * 12 / 13)." }, "maxWidth": { "type": [ "number", "null" ], - "description": "Optional display wrap width in pixels. Omitted or null keeps unbounded lines." + "description": "Display wrap width in pixels. Omitted uses min(240, plot width minus twice the padding); null disables wrapping." }, "dot": { "anyOf": [ @@ -3978,7 +3978,7 @@ "$ref": "#/definitions/ConnectorConfig" } ], - "description": "Connector from label to anchor point.\n- `true` (default): straight line, no arrow\n- `'straight'`: straight line, no arrow\n- `'curve'`: curved arrow with arrowhead\n- `'drop-line'`: vertical line through the data point's x\n- `{ type, arrow? }`: object form for explicit arrow control\n- `false`: no connector\n\nThe connector leaves the text block on the side facing the data point, with a small standoff gap. A leader shorter than 8px is dropped as noise (the marker alone reads better), as is a connector whose target sits inside the text block. Widen `offset` if you want the line back." + "description": "Connector from label to anchor point.\n- `true` (default): straight line, no arrow\n- `'straight'`: straight line, no arrow\n- `'curve'`: curved leader; set arrow: true for an arrowhead\n- `'drop-line'`: vertical line through the data point's x\n- `{ type, arrow? }`: object form for explicit arrow control\n- `false`: no connector\n\nThe connector leaves the text block on the side facing the data point, with a small standoff gap. A leader shorter than 8px is dropped as noise (the marker alone reads better), as is a connector whose target sits inside the text block. Widen `offset` if you want the line back." }, "connectorOffset": { "type": "object", @@ -4077,7 +4077,7 @@ }, "arrow": { "type": "boolean", - "description": "Whether to draw an arrowhead at the data-point end. Default: true for curve, false for straight. Drop-line never takes a head." + "description": "Whether to draw an arrowhead at the data-point end. Default: false for both curve and straight. Drop-line never takes a head." } }, "required": [ @@ -4163,7 +4163,7 @@ }, "fontSize": { "type": "number", - "description": "Font size override for the range label (px). Default: 11." + "description": "Font size override for the range label (px). Default: theme small (12px)." }, "fontWeight": { "type": "number", @@ -4210,7 +4210,7 @@ "type": { "type": "string", "const": "refline", - "description": "Discriminant. `'refline'` is canonical and the only value in the type. `'rule'` (it collides with the `rule` mark type) is still accepted at runtime for backward compatibility -- the spec-sugar pass rewrites it to `'refline'` with a deprecation warning before validation -- but it is not part of this type. Use `'refline'`." + "description": "Reference-line annotation discriminant. Older rule aliases require migrateSpecV8." }, "x": { "type": [ @@ -4258,7 +4258,7 @@ "end", "outside" ], - "description": "Opt-in screen-space label placement. Outside reserves a right gutter or top lane; omission preserves legacy labelAnchor behavior." + "description": "Screen-space label placement. Outside reserves a right gutter or top lane; omission uses auto unless labelAnchor is authored." }, "labelAnchor": { "$ref": "#/definitions/AnnotationAnchor", @@ -4266,7 +4266,7 @@ }, "fontSize": { "type": "number", - "description": "Label font size in pixels. Default: 11." + "description": "Label font size in pixels. Default: theme small (12px)." }, "fontWeight": { "type": "number", @@ -4313,14 +4313,6 @@ "type": "string", "description": "Fixed CSS color for all labels. Overrides the default fill-derived color." }, - "offsets": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/AnnotationOffset" - }, - "description": "Per-series pixel offsets for fine-tuning label positions, keyed by series name.", - "deprecated": "Per-series pixel offsets are a workaround for collision bugs\nnow fixed by the layout engine. Will be removed in a future version." - }, "fontSize": { "type": "number", "description": "Font size in pixels for bar/column value labels." @@ -4419,7 +4411,7 @@ } }, "additionalProperties": false, - "description": "Configuration for the endpoint labels column rendered at the chart's right edge for multi-series line/area charts. Each entry pairs the series name with its last formatted value, optionally anchored to the line by an open-circle marker.\n\nThe column is independent of the traditional `legend` and the legacy end-of-line labels. Together with `legend.show`, the three suppression toggles follow this truth table for ≥2-series line/area charts:\n\n| `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels | |--|--|--|--|--| | unset | unset | hidden (auto-suppressed) | shown (default) | hidden | | true | unset | shown | shown | hidden | | unset | false | shown (auto-suppress revoked) | hidden | hidden | | false | false | hidden | hidden | shown (last-resort) | | true | false | shown | hidden | hidden | | false | true | hidden | shown | hidden | | true | true | shown | shown | hidden |\n\nSingle-series charts: column is hidden by default (nothing to identify).\n\nThe implementation is in `packages/engine/src/legend/suppression.ts` — `resolveSuppression` is the single source of truth. The table above is a user-facing mirror; if the two ever diverge, the engine wins. Tests in `legend/__tests__/suppression.test.ts` enforce every cell." + "description": "Configuration for the endpoint labels column rendered at the chart's right edge for multi-series line/area charts. Each entry pairs the series name with its last formatted value, optionally anchored to the line by an open-circle marker.\n\nThe column and traditional legend are independent identification surfaces. For multi-series line/area charts:\n\n| `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | |--|--|--|--| | unset | unset | hidden | shown | | true | unset | shown | shown | | unset | false | shown | hidden | | false | false | hidden (accessibility warning) | hidden | | true | false | shown | hidden | | false | true | hidden | shown | | true | true | shown | shown |\n\nSingle-series charts: column is hidden by default (nothing to identify).\n\nThe implementation is in `packages/engine/src/legend/suppression.ts` — `resolveSuppression` is the single source of truth. The table above is a user-facing mirror; if the two ever diverge, the engine wins. Tests in `legend/__tests__/suppression.test.ts` enforce every cell." }, "EndpointLabelContent": { "type": "string", @@ -4438,7 +4430,7 @@ "number", "null" ], - "description": "Maximum eligible inline callouts below 400px container width; null or omitted is unlimited. Pinned annotations are exempt." + "description": "Maximum eligible inline callouts below 400px container width; default 1; null is unlimited. Pinned annotations are exempt." }, "preserveEnd": { "type": "boolean", @@ -4602,11 +4594,11 @@ }, "small": { "type": "number", - "description": "Small text (source line, footer). Default: 11." + "description": "Small text (source line, footer). Default: 12." }, "axisTick": { "type": "number", - "description": "Axis tick labels. Default: 11." + "description": "Axis tick labels. Default: 12." }, "metricLabel": { "type": "number", @@ -4690,97 +4682,57 @@ "type": "object", "properties": { "eyebrow": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Eyebrow (kicker) style override." }, "title": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Title style override." }, "subtitle": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Subtitle style override." }, "source": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Source/attribution style override." }, "byline": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Byline style override." }, "footer": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Footer style override." } }, "additionalProperties": false, - "description": "Per-element chrome style overrides. Pass a plain string for color-only (backward-compatible), or a ChromeThemeOverride object for full typography control (color, fontSize, fontWeight, lineHeight)." + "description": "Per-element chrome typography control (color, fontSize, fontWeight, lineHeight)." }, "rule": { - "type": "object", - "properties": { - "color": { - "$ref": "#/definitions/TokenValue", - "description": "Rule color. Accepts a TokenValue for light/dark pairs." - }, - "width": { - "type": "number", - "description": "Rule length in pixels." + "anyOf": [ + { + "type": "object", + "properties": { + "color": { + "$ref": "#/definitions/TokenValue", + "description": "Rule color. Accepts a TokenValue for light/dark pairs." + }, + "width": { + "type": "number", + "description": "Rule length in pixels." + }, + "thickness": { + "type": "number", + "description": "Rule height in pixels." + } + }, + "additionalProperties": false }, - "thickness": { - "type": "number", - "description": "Rule height in pixels." + { + "type": "null" } - }, - "required": [ - "color", - "width", - "thickness" ], - "additionalProperties": false, "description": "Editorial rule above the chrome block: a short colored bar (the Economist/FT masthead device). Omit for no rule." }, "seriesStrategy": { @@ -4836,7 +4788,7 @@ } }, "additionalProperties": false, - "description": "Per-element chrome theme override. Pass a plain string (color only, backward-compatible) or an object with full typography control." + "description": "Per-element chrome theme typography controls." }, "SeriesStrategy": { "type": "string", @@ -4846,15 +4798,6 @@ ], "description": "Series color assignment strategy.\n\n- `'palette'` — always assign from the full categorical palette (current default, zero visual drift).\n- `'accent-neutral'` — assign by series count: a single series gets the accent (first categorical color) only; 2-4 series get accent for the first and neutral grays for the rest; 5+ fall back to the full palette." }, - "DarkMode": { - "type": "string", - "enum": [ - "auto", - "force", - "off" - ], - "description": "Dark mode behavior.\n\n- `\"auto\"` - Checks the `prefers-color-scheme` media query to detect the user's system-level preference. This does NOT detect class-based dark mode toggles (e.g. `document.documentElement.classList.toggle('dark')`). If your app uses class-based dark mode, use VizThemeProvider with `\"force\"` or `\"off\"` instead of `\"auto\"` and toggle based on your app's state.\n- `\"force\"` - Always render in dark mode regardless of system preference.\n- `\"off\"` - Always render in light mode (default).\n\nAll components (Chart, Sankey, Graph) inherit darkMode from VizThemeProvider when no explicit prop is passed." - }, "SeriesStyle": { "type": "object", "properties": { @@ -4894,6 +4837,17 @@ "$ref": "#/definitions/LabelSpec", "description": "Override label configuration at this breakpoint." }, + "endpointLabels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/EndpointLabelsConfig" + } + ], + "description": "Override endpoint labels at this breakpoint, including migrated v8 line labels." + }, "legend": { "$ref": "#/definitions/LegendConfig", "description": "Override legend configuration at this breakpoint." @@ -4943,8 +4897,7 @@ "type": "string", "enum": [ "fluid", - "subtle", - "legacy" + "subtle" ], "description": "Opt-in motion language." }, @@ -5329,7 +5282,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5448,7 +5401,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5567,7 +5520,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5709,7 +5662,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5828,7 +5781,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5947,7 +5900,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6066,7 +6019,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6181,7 +6134,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6297,7 +6250,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6316,7 +6269,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for waffle marks (unit grids for part-to-whole counts).\n- `color`: required (nominal/ordinal, the category)\n- `theta`: the canonical quantitative share, the same part-to-whole value channel arc marks use. `y` is a deprecated alias, accepted for backward compatibility." + "description": "Encoding for waffle marks (unit grids for part-to-whole counts).\n- `color`: required (nominal/ordinal, the category)\n- `theta`: the canonical quantitative share, the same part-to-whole value channel arc marks use." }, "TextEncoding": { "type": "object", @@ -6415,7 +6368,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6533,7 +6486,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6652,7 +6605,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6771,7 +6724,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6876,7 +6829,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6992,7 +6945,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7011,7 +6964,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for parliament marks (hemicycle seat-dot charts).\n- `color`: required (nominal/ordinal, the party)\n- `theta`: the canonical quantitative seat count, the same part-to-whole value channel arc/waffle marks use. `y` is a deprecated alias, accepted for backward compatibility." + "description": "Encoding for parliament marks (hemicycle seat-dot charts).\n- `color`: required (nominal/ordinal, the party)\n- `theta`: the canonical quantitative seat count, the same part-to-whole value channel arc/waffle marks use." }, "HistogramEncoding": { "type": "object", @@ -7110,7 +7063,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7228,7 +7181,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7290,7 +7243,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -7370,7 +7323,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -7387,10 +7340,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." diff --git a/packages/core/schema/table.schema.json b/packages/core/schema/table.schema.json index 5ac3d22e..57941600 100644 --- a/packages/core/schema/table.schema.json +++ b/packages/core/schema/table.schema.json @@ -56,10 +56,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -121,10 +117,6 @@ ], "description": "Sticky footer summing every quantitative column over the filtered rows (the whole result set, not just the current page). `true` uses the label \"Total\"." }, - "compact": { - "type": "boolean", - "deprecated": "Use `density: 'condensed'`. Still honored, with a warning." - }, "responsive": { "type": "boolean", "description": "Whether the table adapts to container width. Defaults to true." @@ -734,11 +726,11 @@ }, "small": { "type": "number", - "description": "Small text (source line, footer). Default: 11." + "description": "Small text (source line, footer). Default: 12." }, "axisTick": { "type": "number", - "description": "Axis tick labels. Default: 11." + "description": "Axis tick labels. Default: 12." }, "metricLabel": { "type": "number", @@ -822,97 +814,57 @@ "type": "object", "properties": { "eyebrow": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Eyebrow (kicker) style override." }, "title": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Title style override." }, "subtitle": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Subtitle style override." }, "source": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Source/attribution style override." }, "byline": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Byline style override." }, "footer": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Footer style override." } }, "additionalProperties": false, - "description": "Per-element chrome style overrides. Pass a plain string for color-only (backward-compatible), or a ChromeThemeOverride object for full typography control (color, fontSize, fontWeight, lineHeight)." + "description": "Per-element chrome typography control (color, fontSize, fontWeight, lineHeight)." }, "rule": { - "type": "object", - "properties": { - "color": { - "$ref": "#/definitions/TokenValue", - "description": "Rule color. Accepts a TokenValue for light/dark pairs." - }, - "width": { - "type": "number", - "description": "Rule length in pixels." + "anyOf": [ + { + "type": "object", + "properties": { + "color": { + "$ref": "#/definitions/TokenValue", + "description": "Rule color. Accepts a TokenValue for light/dark pairs." + }, + "width": { + "type": "number", + "description": "Rule length in pixels." + }, + "thickness": { + "type": "number", + "description": "Rule height in pixels." + } + }, + "additionalProperties": false }, - "thickness": { - "type": "number", - "description": "Rule height in pixels." + { + "type": "null" } - }, - "required": [ - "color", - "width", - "thickness" ], - "additionalProperties": false, "description": "Editorial rule above the chrome block: a short colored bar (the Economist/FT masthead device). Omit for no rule." }, "seriesStrategy": { @@ -968,7 +920,7 @@ } }, "additionalProperties": false, - "description": "Per-element chrome theme override. Pass a plain string (color only, backward-compatible) or an object with full typography control." + "description": "Per-element chrome theme typography controls." }, "SeriesStrategy": { "type": "string", @@ -978,15 +930,6 @@ ], "description": "Series color assignment strategy.\n\n- `'palette'` — always assign from the full categorical palette (current default, zero visual drift).\n- `'accent-neutral'` — assign by series count: a single series gets the accent (first categorical color) only; 2-4 series get accent for the first and neutral grays for the rest; 5+ fall back to the full palette." }, - "DarkMode": { - "type": "string", - "enum": [ - "auto", - "force", - "off" - ], - "description": "Dark mode behavior.\n\n- `\"auto\"` - Checks the `prefers-color-scheme` media query to detect the user's system-level preference. This does NOT detect class-based dark mode toggles (e.g. `document.documentElement.classList.toggle('dark')`). If your app uses class-based dark mode, use VizThemeProvider with `\"force\"` or `\"off\"` instead of `\"auto\"` and toggle based on your app's state.\n- `\"force\"` - Always render in dark mode regardless of system preference.\n- `\"off\"` - Always render in light mode (default).\n\nAll components (Chart, Sankey, Graph) inherit darkMode from VizThemeProvider when no explicit prop is passed." - }, "TableDensity": { "type": "string", "enum": [ @@ -1037,8 +980,7 @@ "type": "string", "enum": [ "fluid", - "subtle", - "legacy" + "subtle" ], "description": "Opt-in motion language." }, diff --git a/packages/core/schema/vizspec.schema.json b/packages/core/schema/vizspec.schema.json index a4837d98..03299205 100644 --- a/packages/core/schema/vizspec.schema.json +++ b/packages/core/schema/vizspec.schema.json @@ -2353,7 +2353,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for arc marks (pie/donut charts).\n- `theta`: required (quantitative — the canonical slice value channel)\n- `color`: required (nominal/ordinal — the category)\n- `y`: deprecated alias for `theta`, accepted for backward compatibility" + "description": "Encoding for arc marks (pie/donut charts).\n- `theta`: required (quantitative — the canonical slice value channel)\n- `color`: required (nominal/ordinal — the category)" }, "EncodingChannel": { "type": "object", @@ -3959,14 +3959,14 @@ }, "subtitleFontSize": { "type": "number", - "description": "Subtitle size in pixels. Default: round(fontSize * 0.85)." + "description": "Subtitle size in pixels. Default: round(fontSize * 12 / 13)." }, "maxWidth": { "type": [ "number", "null" ], - "description": "Optional display wrap width in pixels. Omitted or null keeps unbounded lines." + "description": "Display wrap width in pixels. Omitted uses min(240, plot width minus twice the padding); null disables wrapping." }, "dot": { "anyOf": [ @@ -4007,7 +4007,7 @@ "$ref": "#/definitions/ConnectorConfig" } ], - "description": "Connector from label to anchor point.\n- `true` (default): straight line, no arrow\n- `'straight'`: straight line, no arrow\n- `'curve'`: curved arrow with arrowhead\n- `'drop-line'`: vertical line through the data point's x\n- `{ type, arrow? }`: object form for explicit arrow control\n- `false`: no connector\n\nThe connector leaves the text block on the side facing the data point, with a small standoff gap. A leader shorter than 8px is dropped as noise (the marker alone reads better), as is a connector whose target sits inside the text block. Widen `offset` if you want the line back." + "description": "Connector from label to anchor point.\n- `true` (default): straight line, no arrow\n- `'straight'`: straight line, no arrow\n- `'curve'`: curved leader; set arrow: true for an arrowhead\n- `'drop-line'`: vertical line through the data point's x\n- `{ type, arrow? }`: object form for explicit arrow control\n- `false`: no connector\n\nThe connector leaves the text block on the side facing the data point, with a small standoff gap. A leader shorter than 8px is dropped as noise (the marker alone reads better), as is a connector whose target sits inside the text block. Widen `offset` if you want the line back." }, "connectorOffset": { "type": "object", @@ -4106,7 +4106,7 @@ }, "arrow": { "type": "boolean", - "description": "Whether to draw an arrowhead at the data-point end. Default: true for curve, false for straight. Drop-line never takes a head." + "description": "Whether to draw an arrowhead at the data-point end. Default: false for both curve and straight. Drop-line never takes a head." } }, "required": [ @@ -4192,7 +4192,7 @@ }, "fontSize": { "type": "number", - "description": "Font size override for the range label (px). Default: 11." + "description": "Font size override for the range label (px). Default: theme small (12px)." }, "fontWeight": { "type": "number", @@ -4239,7 +4239,7 @@ "type": { "type": "string", "const": "refline", - "description": "Discriminant. `'refline'` is canonical and the only value in the type. `'rule'` (it collides with the `rule` mark type) is still accepted at runtime for backward compatibility -- the spec-sugar pass rewrites it to `'refline'` with a deprecation warning before validation -- but it is not part of this type. Use `'refline'`." + "description": "Reference-line annotation discriminant. Older rule aliases require migrateSpecV8." }, "x": { "type": [ @@ -4287,7 +4287,7 @@ "end", "outside" ], - "description": "Opt-in screen-space label placement. Outside reserves a right gutter or top lane; omission preserves legacy labelAnchor behavior." + "description": "Screen-space label placement. Outside reserves a right gutter or top lane; omission uses auto unless labelAnchor is authored." }, "labelAnchor": { "$ref": "#/definitions/AnnotationAnchor", @@ -4295,7 +4295,7 @@ }, "fontSize": { "type": "number", - "description": "Label font size in pixels. Default: 11." + "description": "Label font size in pixels. Default: theme small (12px)." }, "fontWeight": { "type": "number", @@ -4342,14 +4342,6 @@ "type": "string", "description": "Fixed CSS color for all labels. Overrides the default fill-derived color." }, - "offsets": { - "type": "object", - "additionalProperties": { - "$ref": "#/definitions/AnnotationOffset" - }, - "description": "Per-series pixel offsets for fine-tuning label positions, keyed by series name.", - "deprecated": "Per-series pixel offsets are a workaround for collision bugs\nnow fixed by the layout engine. Will be removed in a future version." - }, "fontSize": { "type": "number", "description": "Font size in pixels for bar/column value labels." @@ -4448,7 +4440,7 @@ } }, "additionalProperties": false, - "description": "Configuration for the endpoint labels column rendered at the chart's right edge for multi-series line/area charts. Each entry pairs the series name with its last formatted value, optionally anchored to the line by an open-circle marker.\n\nThe column is independent of the traditional `legend` and the legacy end-of-line labels. Together with `legend.show`, the three suppression toggles follow this truth table for ≥2-series line/area charts:\n\n| `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels | |--|--|--|--|--| | unset | unset | hidden (auto-suppressed) | shown (default) | hidden | | true | unset | shown | shown | hidden | | unset | false | shown (auto-suppress revoked) | hidden | hidden | | false | false | hidden | hidden | shown (last-resort) | | true | false | shown | hidden | hidden | | false | true | hidden | shown | hidden | | true | true | shown | shown | hidden |\n\nSingle-series charts: column is hidden by default (nothing to identify).\n\nThe implementation is in `packages/engine/src/legend/suppression.ts` — `resolveSuppression` is the single source of truth. The table above is a user-facing mirror; if the two ever diverge, the engine wins. Tests in `legend/__tests__/suppression.test.ts` enforce every cell." + "description": "Configuration for the endpoint labels column rendered at the chart's right edge for multi-series line/area charts. Each entry pairs the series name with its last formatted value, optionally anchored to the line by an open-circle marker.\n\nThe column and traditional legend are independent identification surfaces. For multi-series line/area charts:\n\n| `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | |--|--|--|--| | unset | unset | hidden | shown | | true | unset | shown | shown | | unset | false | shown | hidden | | false | false | hidden (accessibility warning) | hidden | | true | false | shown | hidden | | false | true | hidden | shown | | true | true | shown | shown |\n\nSingle-series charts: column is hidden by default (nothing to identify).\n\nThe implementation is in `packages/engine/src/legend/suppression.ts` — `resolveSuppression` is the single source of truth. The table above is a user-facing mirror; if the two ever diverge, the engine wins. Tests in `legend/__tests__/suppression.test.ts` enforce every cell." }, "EndpointLabelContent": { "type": "string", @@ -4467,7 +4459,7 @@ "number", "null" ], - "description": "Maximum eligible inline callouts below 400px container width; null or omitted is unlimited. Pinned annotations are exempt." + "description": "Maximum eligible inline callouts below 400px container width; default 1; null is unlimited. Pinned annotations are exempt." }, "preserveEnd": { "type": "boolean", @@ -4631,11 +4623,11 @@ }, "small": { "type": "number", - "description": "Small text (source line, footer). Default: 11." + "description": "Small text (source line, footer). Default: 12." }, "axisTick": { "type": "number", - "description": "Axis tick labels. Default: 11." + "description": "Axis tick labels. Default: 12." }, "metricLabel": { "type": "number", @@ -4719,97 +4711,57 @@ "type": "object", "properties": { "eyebrow": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Eyebrow (kicker) style override." }, "title": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Title style override." }, "subtitle": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Subtitle style override." }, "source": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Source/attribution style override." }, "byline": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Byline style override." }, "footer": { - "anyOf": [ - { - "$ref": "#/definitions/ChromeThemeOverride" - }, - { - "type": "string" - } - ], + "$ref": "#/definitions/ChromeThemeOverride", "description": "Footer style override." } }, "additionalProperties": false, - "description": "Per-element chrome style overrides. Pass a plain string for color-only (backward-compatible), or a ChromeThemeOverride object for full typography control (color, fontSize, fontWeight, lineHeight)." + "description": "Per-element chrome typography control (color, fontSize, fontWeight, lineHeight)." }, "rule": { - "type": "object", - "properties": { - "color": { - "$ref": "#/definitions/TokenValue", - "description": "Rule color. Accepts a TokenValue for light/dark pairs." - }, - "width": { - "type": "number", - "description": "Rule length in pixels." + "anyOf": [ + { + "type": "object", + "properties": { + "color": { + "$ref": "#/definitions/TokenValue", + "description": "Rule color. Accepts a TokenValue for light/dark pairs." + }, + "width": { + "type": "number", + "description": "Rule length in pixels." + }, + "thickness": { + "type": "number", + "description": "Rule height in pixels." + } + }, + "additionalProperties": false }, - "thickness": { - "type": "number", - "description": "Rule height in pixels." + { + "type": "null" } - }, - "required": [ - "color", - "width", - "thickness" ], - "additionalProperties": false, "description": "Editorial rule above the chrome block: a short colored bar (the Economist/FT masthead device). Omit for no rule." }, "seriesStrategy": { @@ -4865,7 +4817,7 @@ } }, "additionalProperties": false, - "description": "Per-element chrome theme override. Pass a plain string (color only, backward-compatible) or an object with full typography control." + "description": "Per-element chrome theme typography controls." }, "SeriesStrategy": { "type": "string", @@ -4875,15 +4827,6 @@ ], "description": "Series color assignment strategy.\n\n- `'palette'` — always assign from the full categorical palette (current default, zero visual drift).\n- `'accent-neutral'` — assign by series count: a single series gets the accent (first categorical color) only; 2-4 series get accent for the first and neutral grays for the rest; 5+ fall back to the full palette." }, - "DarkMode": { - "type": "string", - "enum": [ - "auto", - "force", - "off" - ], - "description": "Dark mode behavior.\n\n- `\"auto\"` - Checks the `prefers-color-scheme` media query to detect the user's system-level preference. This does NOT detect class-based dark mode toggles (e.g. `document.documentElement.classList.toggle('dark')`). If your app uses class-based dark mode, use VizThemeProvider with `\"force\"` or `\"off\"` instead of `\"auto\"` and toggle based on your app's state.\n- `\"force\"` - Always render in dark mode regardless of system preference.\n- `\"off\"` - Always render in light mode (default).\n\nAll components (Chart, Sankey, Graph) inherit darkMode from VizThemeProvider when no explicit prop is passed." - }, "SeriesStyle": { "type": "object", "properties": { @@ -4923,6 +4866,17 @@ "$ref": "#/definitions/LabelSpec", "description": "Override label configuration at this breakpoint." }, + "endpointLabels": { + "anyOf": [ + { + "type": "boolean" + }, + { + "$ref": "#/definitions/EndpointLabelsConfig" + } + ], + "description": "Override endpoint labels at this breakpoint, including migrated v8 line labels." + }, "legend": { "$ref": "#/definitions/LegendConfig", "description": "Override legend configuration at this breakpoint." @@ -4972,8 +4926,7 @@ "type": "string", "enum": [ "fluid", - "subtle", - "legacy" + "subtle" ], "description": "Opt-in motion language." }, @@ -5358,7 +5311,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5477,7 +5430,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5596,7 +5549,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5738,7 +5691,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5857,7 +5810,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -5976,7 +5929,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6095,7 +6048,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6210,7 +6163,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6326,7 +6279,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6345,7 +6298,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for waffle marks (unit grids for part-to-whole counts).\n- `color`: required (nominal/ordinal, the category)\n- `theta`: the canonical quantitative share, the same part-to-whole value channel arc marks use. `y` is a deprecated alias, accepted for backward compatibility." + "description": "Encoding for waffle marks (unit grids for part-to-whole counts).\n- `color`: required (nominal/ordinal, the category)\n- `theta`: the canonical quantitative share, the same part-to-whole value channel arc marks use." }, "TextEncoding": { "type": "object", @@ -6444,7 +6397,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6562,7 +6515,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6681,7 +6634,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6800,7 +6753,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -6905,7 +6858,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7021,7 +6974,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7040,7 +6993,7 @@ "color" ], "additionalProperties": false, - "description": "Encoding for parliament marks (hemicycle seat-dot charts).\n- `color`: required (nominal/ordinal, the party)\n- `theta`: the canonical quantitative seat count, the same part-to-whole value channel arc/waffle marks use. `y` is a deprecated alias, accepted for backward compatibility." + "description": "Encoding for parliament marks (hemicycle seat-dot charts).\n- `color`: required (nominal/ordinal, the party)\n- `theta`: the canonical quantitative seat count, the same part-to-whole value channel arc/waffle marks use." }, "HistogramEncoding": { "type": "object", @@ -7139,7 +7092,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7257,7 +7210,7 @@ }, "theta": { "$ref": "#/definitions/EncodingChannel%3CDataRow%3E", - "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). `y` is still accepted on these marks as a deprecated alias for backward compatibility (a compile warning is emitted and `y` is populated from `theta` internally). Not used by any other mark type." + "description": "Angular position (slice value) for arc marks (pie/donut), the canonical value channel in v8. Waffle and parliament marks accept it too (the same part-to-whole value channel). The compiler populates internal `y` from `theta`; authored `y` aliases are rejected. Not used by any other mark type." }, "facet": { "$ref": "#/definitions/FacetChannel%3CDataRow%3E", @@ -7379,10 +7332,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -7574,7 +7523,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -7654,7 +7603,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -7671,10 +7620,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -7902,7 +7847,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -7982,7 +7927,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -7999,10 +7944,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -8212,7 +8153,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -8292,7 +8233,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -8309,10 +8250,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -8540,7 +8477,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -8620,7 +8557,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -8637,10 +8574,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -8841,7 +8774,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -8921,7 +8854,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -8938,10 +8871,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -9131,7 +9060,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -9211,7 +9140,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -9228,10 +9157,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -9421,7 +9346,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -9501,7 +9426,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -9518,10 +9443,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -9711,7 +9632,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -9791,7 +9712,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -9808,10 +9729,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -10014,7 +9931,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -10094,7 +10011,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -10111,10 +10028,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -10347,7 +10260,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -10427,7 +10340,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -10444,10 +10357,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -10667,7 +10576,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -10747,7 +10656,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -10764,10 +10673,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -10957,7 +10862,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -11037,7 +10942,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -11054,10 +10959,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -11259,7 +11160,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -11339,7 +11240,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -11356,10 +11257,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -11549,7 +11446,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -11629,7 +11526,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -11646,10 +11543,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -11851,7 +11744,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -11931,7 +11824,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -11948,10 +11841,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -12178,7 +12067,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -12258,7 +12147,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -12275,10 +12164,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -12496,7 +12381,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -12576,7 +12461,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -12593,10 +12478,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -12836,7 +12717,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -12916,7 +12797,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -12933,10 +12814,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." @@ -13117,10 +12994,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -13208,10 +13081,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -13273,10 +13142,6 @@ ], "description": "Sticky footer summing every quantitative column over the filtered rows (the whole result set, not just the current page). `true` uses the label \"Total\"." }, - "compact": { - "type": "boolean", - "deprecated": "Use `density: 'condensed'`. Still honored, with a warning." - }, "responsive": { "type": "boolean", "description": "Whether the table adapts to container width. Defaults to true." @@ -13633,10 +13498,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "animation": { "$ref": "#/definitions/GraphAnimationSpec", "description": "Motion configuration. DEFAULT-ON: omit for full defaults, `false` to opt out of choreography (layout warmup still applies)." @@ -14173,10 +14034,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -14184,11 +14041,6 @@ "animation": { "$ref": "#/definitions/AnimationSpec", "description": "Animation configuration for entrance animations." - }, - "valueFormat": { - "type": "string", - "description": "d3-format string applied to flow values in tooltips and ARIA labels. Uses the literal suffix extension: \".0f%\" appends \"%\" to the formatted number (data value 28 renders as \"28%\"). For currency: \"$,.0f\" or \"$~s\". For SI suffixes: \"~s\" (renders 1000 as \"1k\"). When not set, values use the default number formatter.", - "deprecated": "Use the encoding channel's `format` field instead. This\nproperty is expanded into the encoding during compilation." } }, "required": [ @@ -14470,10 +14322,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -14583,10 +14431,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -14598,11 +14442,6 @@ "animation": { "$ref": "#/definitions/AnimationSpec", "description": "Animation configuration for entrance animations." - }, - "valueFormat": { - "type": "string", - "description": "d3-format string applied to color values in tooltips and legend.", - "deprecated": "Use the encoding channel's `format` field instead." } }, "required": [ @@ -14907,10 +14746,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true." @@ -14918,11 +14753,6 @@ "animation": { "$ref": "#/definitions/AnimationSpec", "description": "Animation configuration for entrance animations." - }, - "valueFormat": { - "type": "string", - "description": "d3-format string applied to bar values and tooltips. Examples: \".1f\" for one decimal, \"$,.0f\" for currency, \"~s\" for SI.", - "deprecated": "Use the encoding channel's `format` field instead. This\nproperty is expanded into the encoding during compilation." } }, "required": [ @@ -15014,7 +14844,7 @@ "subtract", "grow" ], - "description": "How chrome height interacts with the container height budget.\n\n- `'subtract'` (default): the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\n`'grow'` is a no-op for faceted specs in this version." + "description": "How chrome height interacts with the container height budget.\n\n- omitted: only notes and the series summary grow the figure; title, subtitle, and source subtract from the height budget.\n- `'subtract'`: the container height is fixed and chrome text (title/subtitle/source) is subtracted from it, shrinking the plot. This is the historical behavior.\n- `'grow'`: the container height is treated as the *plot* budget, and the rendered SVG grows taller by the measured chrome height. The plot keeps its full budget (minus axis/padding overhead) regardless of how many lines the title wraps to.\n\nFacets and layers report engine-owned growth once." }, "title": { "anyOf": [ @@ -15094,7 +14924,7 @@ "$ref": "#/definitions/EndpointLabelsConfig" } ], - "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show` and the legacy end-of-line labels." + "description": "Right-side endpoint labels column for multi-series line/area charts.\n\n- `true` or `EndpointLabelsConfig`: render the column.\n- `false`: hide the column.\n- omitted: auto-enable for multi-series line/area charts, hide otherwise.\n\nSee {@link EndpointLabelsConfig } for the full suppression truth table that relates this flag to `legend.show`." }, "responsive": { "anyOf": [ @@ -15111,10 +14941,6 @@ "$ref": "#/definitions/ThemeConfig", "description": "Theme configuration overrides." }, - "darkMode": { - "$ref": "#/definitions/DarkMode", - "deprecated": "Use the mount darkMode option; the spec field has no effect." - }, "watermark": { "type": "boolean", "description": "Whether to show the OpenData watermark. Defaults to true; auto-hides when the container is under 200px tall (the cramped height class) unless explicitly set." diff --git a/packages/core/src/helpers/__tests__/spec-builders.test.ts b/packages/core/src/helpers/__tests__/spec-builders.test.ts index dc396687..106ed88d 100644 --- a/packages/core/src/helpers/__tests__/spec-builders.test.ts +++ b/packages/core/src/helpers/__tests__/spec-builders.test.ts @@ -58,9 +58,9 @@ describe('inferFieldType', () => { expect(inferFieldType(data, 'period')).toBe('temporal'); }); - it('infers temporal for year-only strings (YYYY format)', () => { + it('infers quantitative for numeric year strings, matching compilation', () => { const data = [{ year: '2020' }, { year: '2021' }]; - expect(inferFieldType(data, 'year')).toBe('temporal'); + expect(inferFieldType(data, 'year')).toBe('quantitative'); }); it('infers nominal for plain string fields', () => { @@ -83,10 +83,29 @@ describe('inferFieldType', () => { expect(inferFieldType(data, 'date')).toBe('temporal'); }); - it('samples at most 20 values', () => { - // Create 100 items, all numbers. Should still work without issue. - const data = Array.from({ length: 100 }, (_, i) => ({ v: i })); - expect(inferFieldType(data, 'v')).toBe('quantitative'); + it.each([ + { values: ['12', '15.5', '-3'], type: 'quantitative' }, + { values: ['2020', '2021'], type: 'quantitative' }, + { values: ['2020-01-01', '2021-01-01'], type: 'temporal' }, + { values: [1, 2, 3, 4, 'other'], type: 'nominal' }, + { values: [1, 2, 3, 4, 5, 'other'], type: 'quantitative' }, + ])('uses compilation inference rules for $values', ({ values, type }) => { + expect( + inferFieldType( + values.map((value) => ({ value })), + 'value', + ), + ).toBe(type); + }); + + it('samples 50 rows, including evidence beyond the former 20-row window', () => { + const data = Array.from({ length: 100 }, (_, i) => ({ value: i < 20 ? i : 'other' })); + expect(inferFieldType(data, 'value')).toBe('nominal'); + }); + + it('does not let rows after the first 50 change the inferred type', () => { + const data = Array.from({ length: 100 }, (_, i) => ({ value: i < 50 ? i : 'other' })); + expect(inferFieldType(data, 'value')).toBe('quantitative'); }); it('returns nominal for empty data', () => { @@ -147,11 +166,10 @@ describe('lineChart', () => { it('passes through theme without emitting ineffective spec darkMode', () => { const spec = lineChart(timeSeriesData, 'date', 'value', { theme: { borderRadius: 8 }, - darkMode: 'auto', }); expect(spec.theme).toEqual({ borderRadius: 8 }); - expect(spec.darkMode).toBeUndefined(); + expect(spec).not.toHaveProperty('darkMode'); }); }); @@ -290,8 +308,7 @@ describe('dataTable', () => { search: true, pagination: { pageSize: 10 }, stickyFirstColumn: true, - compact: true, - darkMode: 'auto', + density: 'condensed', }); expect(spec.rowKey).toBe('name'); @@ -300,8 +317,8 @@ describe('dataTable', () => { expect(spec.pagination).toEqual({ pageSize: 10 }); expect(spec.stickyFirstColumn).toBe(true); expect(spec.density).toBe('condensed'); - expect(spec.compact).toBeUndefined(); - expect(spec.darkMode).toBeUndefined(); + expect(spec).not.toHaveProperty('compact'); + expect(spec).not.toHaveProperty('darkMode'); }); it('returns empty columns for empty data', () => { diff --git a/packages/core/src/helpers/spec-builders.ts b/packages/core/src/helpers/spec-builders.ts index 187b53fb..75f7e0a0 100644 --- a/packages/core/src/helpers/spec-builders.ts +++ b/packages/core/src/helpers/spec-builders.ts @@ -14,7 +14,6 @@ import type { Annotation, ChartSpec, Chrome, - DarkMode, DataRow, Encoding, EncodingChannel, @@ -38,20 +37,6 @@ import type { ColumnConfig } from '../types/table'; /** A field reference: either a plain string (field name) or a full EncodingChannel. */ export type FieldRef = string | EncodingChannel; -const warnedBuilderOptions = new Set(); -function warnBuilderOption(key: string, message: string): void { - if (warnedBuilderOptions.has(key)) return; - warnedBuilderOptions.add(key); - console.warn(`[openchart] ${message}`); -} -function warnDarkMode(options?: { darkMode?: DarkMode }): void { - if (options?.darkMode !== undefined) - warnBuilderOption( - 'darkMode', - 'Builder darkMode is deprecated and has no effect; use the mount option.', - ); -} - /** Common options shared by all chart builder functions. */ export interface ChartBuilderOptions { /** Color encoding: field name or full channel config for series differentiation. */ @@ -66,8 +51,6 @@ export interface ChartBuilderOptions { responsive?: boolean; /** Theme configuration overrides. */ theme?: ThemeConfig; - /** @deprecated Set darkMode on the mount instead; this option has no effect. */ - darkMode?: DarkMode; } /** Options for the dataTable builder. */ @@ -80,8 +63,6 @@ export interface TableBuilderOptions { chrome?: Chrome; /** Theme configuration overrides. */ theme?: ThemeConfig; - /** @deprecated Set darkMode on the mount instead; this option has no effect. */ - darkMode?: DarkMode; /** Enable client-side search/filter. */ search?: boolean; /** Pagination configuration. */ @@ -90,8 +71,6 @@ export interface TableBuilderOptions { stickyFirstColumn?: boolean; /** Row density. */ density?: TableDensity; - /** @deprecated Use density. */ - compact?: boolean; /** Whether the table adapts to container width. */ responsive?: boolean; } @@ -100,49 +79,59 @@ export interface TableBuilderOptions { // Type inference // --------------------------------------------------------------------------- -/** ISO 8601 date patterns we recognize for temporal inference. */ -const ISO_DATE_RE = /^\d{4}(-\d{2}(-\d{2}(T\d{2}(:\d{2}(:\d{2})?)?)?)?)?$/; - /** * Infer the encoding field type from data values. * - * Samples up to 20 values from the data array for the given field. - * - If all sampled values are numbers (or null/undefined), returns "quantitative". - * - If all sampled string values match ISO date patterns, returns "temporal". - * - Otherwise returns "nominal". + * Samples 50 rows, ignoring nulls; more than 80% numeric or temporal values + * establish a type. Numeric strings (including ISO years) are quantitative. */ export function inferFieldType(data: DataRow[], field: string): FieldType { - const sampleSize = Math.min(data.length, 20); - let hasNumber = false; - let hasDateString = false; - let hasOtherString = false; + // Sample up to 50 rows for more reliable inference on mixed/messy data + const sampleSize = Math.min(50, data.length); + let numericCount = 0; + let dateCount = 0; + let totalNonNull = 0; for (let i = 0; i < sampleSize; i++) { const value = data[i][field]; - - // Skip null/undefined if (value == null) continue; + totalNonNull++; + + // Check numeric + if (typeof value === 'number' && Number.isFinite(value)) { + numericCount++; + continue; + } + + // Check date-like strings + if (typeof value === 'string') { + // Try as number first + const num = Number(value); + if (!Number.isNaN(num) && Number.isFinite(num) && value.trim() !== '') { + numericCount++; + continue; + } - if (typeof value === 'number') { - hasNumber = true; - } else if (typeof value === 'string') { - if (ISO_DATE_RE.test(value) && !Number.isNaN(Date.parse(value))) { - hasDateString = true; - } else { - hasOtherString = true; + // Try as date + const date = new Date(value); + if (!Number.isNaN(date.getTime())) { + dateCount++; + continue; } - } else if (value instanceof Date) { - hasDateString = true; - } else { - hasOtherString = true; + } + + if (value instanceof Date && !Number.isNaN(value.getTime())) { + dateCount++; } } - // Numbers-only: quantitative - if (hasNumber && !hasDateString && !hasOtherString) return 'quantitative'; - // Date strings/Date objects only: temporal - if (hasDateString && !hasNumber && !hasOtherString) return 'temporal'; - // Everything else: nominal + if (totalNonNull === 0) return 'nominal'; + + // If >80% of sampled values are dates, it's temporal + if (dateCount / totalNonNull > 0.8) return 'temporal'; + // If >80% are numeric, it's quantitative + if (numericCount / totalNonNull > 0.8) return 'quantitative'; + // Otherwise it's nominal return 'nominal'; } @@ -184,7 +173,6 @@ function buildChartSpec( encoding: Encoding, options?: ChartBuilderOptions, ): ChartSpec { - warnDarkMode(options); // Cast needed because the generic discriminated union can't be constructed // from a runtime MarkType variable — the caller always pairs mark+encoding correctly. const spec = { mark, data, encoding } as ChartSpec; @@ -399,9 +387,6 @@ export function scatterChart( * @param options - Column definitions, chrome, pagination, search, etc. */ export function dataTable(data: DataRow[], options?: TableBuilderOptions): TableSpec { - warnDarkMode(options); - if (options?.compact !== undefined) - warnBuilderOption('compact', 'dataTable compact is deprecated; use density.'); // Auto-generate columns from data keys if not provided let columns = options?.columns; if (!columns && data.length > 0) { @@ -431,7 +416,6 @@ export function dataTable(data: DataRow[], options?: TableBuilderOptions): Table if (options?.pagination !== undefined) spec.pagination = options.pagination; if (options?.stickyFirstColumn !== undefined) spec.stickyFirstColumn = options.stickyFirstColumn; if (options?.density !== undefined) spec.density = options.density; - else if (options?.compact !== undefined) spec.density = options.compact ? 'condensed' : 'regular'; if (options?.responsive !== undefined) spec.responsive = options.responsive; return spec; @@ -455,8 +439,6 @@ export interface TileMapBuilderOptions { legend?: LegendConfig; /** Theme overrides. */ theme?: ThemeConfig; - /** @deprecated Set darkMode on the mount instead; this option has no effect. */ - darkMode?: DarkMode; /** Whether to show the watermark. */ watermark?: boolean; /** Animation configuration. */ @@ -482,7 +464,6 @@ export function tileMap( data: Record | DataRow[], options?: TileMapBuilderOptions, ): TileMapSpec { - warnDarkMode(options); return { type: 'tilemap' as const, data, diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 7a491a68..74877ce2 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -82,7 +82,6 @@ export { computeNoteBandHeight, estimateTextWidth, FOOTNOTE_LINE_HEIGHT, - footnoteBandHeight, heuristicMeasure, resolveMeasurer, textAscent, @@ -127,7 +126,6 @@ export { HPAD_COMPACT_MIN, LABEL_GAP_COMPACT, LABEL_GAP_DEFAULT, - LABEL_GAP_NARROW_MAX, MAX_LEFT_LABEL_FRACTION_COMPACT, MAX_LEFT_LABEL_FRACTION_DEFAULT, MAX_LEFT_LABEL_FRACTION_MEDIUM, @@ -138,7 +136,6 @@ export { TICK_LABEL_OFFSET, TICK_LINE_HEIGHT_FACTOR, TOP_PAD_EXTRA_NARROW, - TOP_PAD_NARROW_MAX, truncateRotatedLabel, truncateToWidth, X_AXIS_BAND_HEIGHT, diff --git a/packages/core/src/layout/__tests__/note-band-compat.test.ts b/packages/core/src/layout/__tests__/note-band-compat.test.ts index 82bfdb1e..2f458921 100644 --- a/packages/core/src/layout/__tests__/note-band-compat.test.ts +++ b/packages/core/src/layout/__tests__/note-band-compat.test.ts @@ -1,14 +1,12 @@ -import { expect, it, vi } from 'vitest'; +import { expect, it } from 'vitest'; +import * as publicCore from '../../index'; import { resolveTheme } from '../../theme/resolve'; -import { footnoteBandHeight } from '../chrome'; +import { computeNoteBandHeight } from '../chrome'; -it('warns once and retains the numeric API while preferring resolved band metadata', () => { - const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); +it('removes the public footnoteBandHeight alias and prefers resolved note-band metadata', () => { + expect(publicCore).not.toHaveProperty('footnoteBandHeight'); const theme = resolveTheme(); - expect(footnoteBandHeight(2, theme)).toBeCloseTo(2 * 11 * 1.3 + 4); - expect(footnoteBandHeight({ noteBandHeight: 87 }, theme)).toBe(87); - expect(footnoteBandHeight(0, theme)).toBe(0); - expect(warn).toHaveBeenCalledTimes(1); - expect(warn.mock.calls[0][0]).toContain('noteBandHeight'); - warn.mockRestore(); + expect(computeNoteBandHeight({ noteBandHeight: 87 }, theme)).toBe(87); + expect(computeNoteBandHeight(2, theme)).toBeCloseTo(2 * theme.fonts.sizes.small * 1.3 + 4); + expect(computeNoteBandHeight(0, theme)).toBe(0); }); diff --git a/packages/core/src/layout/chrome.ts b/packages/core/src/layout/chrome.ts index 13774402..79ec8858 100644 --- a/packages/core/src/layout/chrome.ts +++ b/packages/core/src/layout/chrome.ts @@ -195,23 +195,6 @@ export function computeNoteBandHeight( : count * theme.fonts.sizes.small * FOOTNOTE_LINE_HEIGHT + FOOTNOTE_BAND_GAP; } -let warnedFootnoteBandHeight = false; -/** - * @deprecated Read layout.chrome.noteBandHeight. The numeric signature remains compatible in 8.x. - */ -export function footnoteBandHeight( - input: number | Pick, - theme: ResolvedTheme, -): number { - if (!warnedFootnoteBandHeight) { - warnedFootnoteBandHeight = true; - console.warn( - '[openchart] footnoteBandHeight is deprecated; read layout.chrome.noteBandHeight.', - ); - } - return computeNoteBandHeight(input, theme); -} - /** * Compute resolved chrome layout from a Chrome spec and resolved theme. * diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index 990266b4..6452353c 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -6,7 +6,6 @@ export { computeChrome, computeNoteBandHeight, FOOTNOTE_LINE_HEIGHT, - footnoteBandHeight, } from './chrome'; export { BRAND_FONT_SIZE, diff --git a/packages/core/src/responsive/index.ts b/packages/core/src/responsive/index.ts index f027a253..b112e6b6 100644 --- a/packages/core/src/responsive/index.ts +++ b/packages/core/src/responsive/index.ts @@ -39,7 +39,6 @@ export { HPAD_COMPACT_MIN, LABEL_GAP_COMPACT, LABEL_GAP_DEFAULT, - LABEL_GAP_NARROW_MAX, MAX_LEFT_LABEL_FRACTION_COMPACT, MAX_LEFT_LABEL_FRACTION_DEFAULT, MAX_LEFT_LABEL_FRACTION_MEDIUM, @@ -49,7 +48,6 @@ export { TICK_LABEL_OFFSET, TICK_LINE_HEIGHT_FACTOR, TOP_PAD_EXTRA_NARROW, - TOP_PAD_NARROW_MAX, truncateRotatedLabel, truncateToWidth, X_AXIS_BAND_HEIGHT, diff --git a/packages/core/src/responsive/metrics.ts b/packages/core/src/responsive/metrics.ts index 4474ace5..532ff569 100644 --- a/packages/core/src/responsive/metrics.ts +++ b/packages/core/src/responsive/metrics.ts @@ -129,9 +129,6 @@ export const NARROW_VIEWPORT_MAX = 500; /** Extra top padding added below NARROW_VIEWPORT_MAX to clear iOS Safari browser chrome. */ export const TOP_PAD_EXTRA_NARROW = 10; -/** @deprecated Use NARROW_VIEWPORT_MAX instead. */ -export const TOP_PAD_NARROW_MAX = NARROW_VIEWPORT_MAX; - // --------------------------------------------------------------------------- // Category label gaps (left-axis bar/dot charts) // --------------------------------------------------------------------------- @@ -142,9 +139,6 @@ export const LABEL_GAP_COMPACT = 8; /** Gap between category label text and chart edge on standard viewports. */ export const LABEL_GAP_DEFAULT = 12; -/** @deprecated Use NARROW_VIEWPORT_MAX instead. */ -export const LABEL_GAP_NARROW_MAX = NARROW_VIEWPORT_MAX; - // --------------------------------------------------------------------------- // Max left-axis label fraction // --------------------------------------------------------------------------- diff --git a/packages/core/src/styles/interaction.css b/packages/core/src/styles/interaction.css index be4c5037..d542acd3 100644 --- a/packages/core/src/styles/interaction.css +++ b/packages/core/src/styles/interaction.css @@ -135,3 +135,17 @@ stroke-width: 1.5; } } + +@layer oc.components { + /* Source-linked annotation inspection; exports render a fresh, inactive scene. */ + .oc-footnotes, + [data-note-marker-index] { + cursor: pointer; + } + .oc-footnotes.oc-note-active { + text-decoration: underline; + } + [data-note-marker-index].oc-note-active .oc-annotation-footnote-marker { + stroke-width: 3; + } +} diff --git a/packages/core/src/styles/table.css b/packages/core/src/styles/table.css index 1ea30da6..6fd043f2 100644 --- a/packages/core/src/styles/table.css +++ b/packages/core/src/styles/table.css @@ -358,12 +358,10 @@ /* --------------------------------------------------------------------------- * Density * - * regular 48px rows (base), condensed 40px, relaxed 56px. `.oc-table--compact` - * is the deprecated alias for condensed, kept one release. + * regular 48px rows (base), condensed 40px, relaxed 56px. * --------------------------------------------------------------------------- */ - .oc-table--condensed, - .oc-table--compact { + .oc-table--condensed { & th, & td { padding: 8px 12px; diff --git a/packages/core/src/types/layout.ts b/packages/core/src/types/layout.ts index 1cc7d551..f05af235 100644 --- a/packages/core/src/types/layout.ts +++ b/packages/core/src/types/layout.ts @@ -786,6 +786,10 @@ export interface ResolvedAnnotation { /** A footnote entry generated by annotation auto-thinning. */ export interface AnnotationFootnote { + /** Original subtitle, kept separate from the primary text. */ + subtitle?: string; + /** Parsed display runs, one array per wrapped line, including the ordinal. */ + spans?: Array>; /** Engine-wrapped display lines, including the number; omitted for one-line notes. */ lines?: string[]; /** Source annotation index, independent of note numbering. */ @@ -1676,11 +1680,6 @@ export interface TableLayout { striped: boolean; /** Totals footer, present only when `totalRow` is enabled. */ totalRow?: TableTotalRow; - /** - * Whether condensed density is active. - * @deprecated Read `density === 'condensed'` instead. - */ - compact: boolean; /** Accessibility metadata. */ a11y: { caption: string; summary: string }; /** The resolved theme. */ diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index 88fc07d1..5351fe89 100644 --- a/packages/core/src/types/spec.ts +++ b/packages/core/src/types/spec.ts @@ -870,9 +870,8 @@ export interface Encoding { /** * Angular position (slice value) for arc marks (pie/donut), the canonical * value channel in v8. Waffle and parliament marks accept it too (the same - * part-to-whole value channel). `y` is still accepted on these marks as a - * deprecated alias for backward compatibility (a compile warning is emitted - * and `y` is populated from `theta` internally). Not used by any other mark + * part-to-whole value channel). The compiler populates internal `y` from + * `theta`; authored `y` aliases are rejected. Not used by any other mark * type. */ theta?: EncodingChannel; @@ -1098,7 +1097,7 @@ export interface ConnectorConfig { type: ConnectorType; /** Curve displacement along the chord normal, from -1 to 1. Omitted or zero preserves the automatic curve. Ignored for other connector types. */ bend?: number; - /** Whether to draw an arrowhead at the data-point end. Default: true for curve, false for straight. Drop-line never takes a head. */ + /** Whether to draw an arrowhead at the data-point end. Default: false for both curve and straight. Drop-line never takes a head. */ arrow?: boolean; } @@ -1162,9 +1161,9 @@ export interface TextAnnotation extends AnnotationBase { * 700 — the lede + context stack. */ subtitle?: string; - /** Subtitle size in pixels. Default: round(fontSize * 0.85). */ + /** Subtitle size in pixels. Default: round(fontSize * 12 / 13). */ subtitleFontSize?: number; - /** Optional display wrap width in pixels. Omitted or null keeps unbounded lines. */ + /** Display wrap width in pixels. Omitted uses min(240, plot width minus twice the padding); null disables wrapping. */ maxWidth?: number | null; /** * Open-ring marker drawn on the data point itself (not on the pulled-back @@ -1191,7 +1190,7 @@ export interface TextAnnotation extends AnnotationBase { * Connector from label to anchor point. * - `true` (default): straight line, no arrow * - `'straight'`: straight line, no arrow - * - `'curve'`: curved arrow with arrowhead + * - `'curve'`: curved leader; set arrow: true for an arrowhead * - `'drop-line'`: vertical line through the data point's x * - `{ type, arrow? }`: object form for explicit arrow control * - `false`: no connector @@ -1249,7 +1248,7 @@ export interface RangeAnnotation extends AnnotationBase { labelOffset?: AnnotationOffset; /** Anchor direction for the range label. */ labelAnchor?: AnnotationAnchor; - /** Font size override for the range label (px). Default: 11. */ + /** Font size override for the range label (px). Default: theme small (12px). */ fontSize?: number; /** Font weight override for the range label. Default: 500. */ fontWeight?: number; @@ -1260,13 +1259,7 @@ export interface RangeAnnotation extends AnnotationBase { * Useful for baselines (zero), targets, or thresholds. */ export interface RefLineAnnotation extends AnnotationBase { - /** - * Discriminant. `'refline'` is canonical and the only value in the type. - * `'rule'` (it collides with the `rule` mark type) is still accepted at - * runtime for backward compatibility -- the spec-sugar pass rewrites it to - * `'refline'` with a deprecation warning before validation -- but it is not - * part of this type. Use `'refline'`. - */ + /** Reference-line annotation discriminant. Older rule aliases require migrateSpecV8. */ type: 'refline'; /** X-axis value for a vertical reference line. */ x?: string | number; @@ -1280,11 +1273,11 @@ export interface RefLineAnnotation extends AnnotationBase { strokeWidth?: number; /** Pixel offset for the reference line label. */ labelOffset?: AnnotationOffset; - /** Opt-in screen-space label placement. Outside reserves a right gutter or top lane; omission preserves legacy labelAnchor behavior. */ + /** Screen-space label placement. Outside reserves a right gutter or top lane; omission uses auto unless labelAnchor is authored. */ labelPlacement?: 'auto' | 'start' | 'end' | 'outside'; /** Anchor direction for the reference line label. */ labelAnchor?: AnnotationAnchor; - /** Label font size in pixels. Default: 11. */ + /** Label font size in pixels. Default: theme small (12px). */ fontSize?: number; /** Label font weight. Default: 400. */ fontWeight?: number; @@ -1499,12 +1492,6 @@ export interface LabelConfig { suffix?: string; /** Fixed CSS color for all labels. Overrides the default fill-derived color. */ color?: string; - /** - * Per-series pixel offsets for fine-tuning label positions, keyed by series name. - * @deprecated Per-series pixel offsets are a workaround for collision bugs - * now fixed by the layout engine. Will be removed in a future version. - */ - offsets?: Record; /** Font size in pixels for bar/column value labels. */ fontSize?: number; } @@ -1581,19 +1568,18 @@ export type EndpointLabelContent = 'label' | 'value' | 'label value'; * for multi-series line/area charts. Each entry pairs the series name with its * last formatted value, optionally anchored to the line by an open-circle marker. * - * The column is independent of the traditional `legend` and the legacy - * end-of-line labels. Together with `legend.show`, the three suppression toggles - * follow this truth table for ≥2-series line/area charts: + * The column and traditional legend are independent identification surfaces. + * For multi-series line/area charts: * - * | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels | - * |--|--|--|--|--| - * | unset | unset | hidden (auto-suppressed) | shown (default) | hidden | - * | true | unset | shown | shown | hidden | - * | unset | false | shown (auto-suppress revoked) | hidden | hidden | - * | false | false | hidden | hidden | shown (last-resort) | - * | true | false | shown | hidden | hidden | - * | false | true | hidden | shown | hidden | - * | true | true | shown | shown | hidden | + * | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | + * |--|--|--|--| + * | unset | unset | hidden | shown | + * | true | unset | shown | shown | + * | unset | false | shown | hidden | + * | false | false | hidden (accessibility warning) | hidden | + * | true | false | shown | hidden | + * | false | true | hidden | shown | + * | true | true | shown | shown | * * Single-series charts: column is hidden by default (nothing to identify). * @@ -1682,11 +1668,7 @@ export interface SeriesStyle { export type AnimationEase = 'smooth' | 'snappy'; /** Opt-in motion language. */ -export type AnimationPreset = - | 'fluid' - | 'subtle' - /** @deprecated Omit preset for v8 timing. */ - | 'legacy'; +export type AnimationPreset = 'fluid' | 'subtle'; /** Physical spring parameters. Quantitative motion is bounded at critical damping. */ export interface SpringConfig { @@ -1705,7 +1687,7 @@ export interface AnimationStagger { /** Fine-grained responsive behavior configuration. */ export interface ResponsiveConfig { - /** Maximum eligible inline callouts below 400px container width; null or omitted is unlimited. Pinned annotations are exempt. */ + /** Maximum eligible inline callouts below 400px container width; default 1; null is unlimited. Pinned annotations are exempt. */ compactAnnotationLimit?: number | null; /** Preserve the final temporal boundary before thinning interior ticks. */ preserveEnd?: boolean; @@ -1798,6 +1780,8 @@ export interface ChartSpecOverride { chrome?: Chrome; /** Override label configuration at this breakpoint. */ labels?: LabelSpec; + /** Override endpoint labels at this breakpoint, including migrated v8 line labels. */ + endpointLabels?: boolean | EndpointLabelsConfig; /** Override legend configuration at this breakpoint. */ legend?: LegendConfig; /** Override annotations at this breakpoint. */ @@ -1843,7 +1827,6 @@ export interface Metric { * Encoding for arc marks (pie/donut charts). * - `theta`: required (quantitative — the canonical slice value channel) * - `color`: required (nominal/ordinal — the category) - * - `y`: deprecated alias for `theta`, accepted for backward compatibility */ export interface ArcEncoding extends Encoding { /** Canonical value channel for arc marks (quantitative — the slice value). */ @@ -1940,8 +1923,7 @@ export interface RangeEncoding extends Encoding * Encoding for waffle marks (unit grids for part-to-whole counts). * - `color`: required (nominal/ordinal, the category) * - `theta`: the canonical quantitative share, the same part-to-whole value - * channel arc marks use. `y` is a deprecated alias, accepted for backward - * compatibility. + * channel arc marks use. */ export interface WaffleEncoding extends Encoding { color: EncodingChannel | ConditionalValueDef; @@ -1951,8 +1933,7 @@ export interface WaffleEncoding extends Encodin * Encoding for parliament marks (hemicycle seat-dot charts). * - `color`: required (nominal/ordinal, the party) * - `theta`: the canonical quantitative seat count, the same part-to-whole - * value channel arc/waffle marks use. `y` is a deprecated alias, accepted - * for backward compatibility. + * value channel arc/waffle marks use. */ export interface ParliamentEncoding extends Encoding { color: EncodingChannel | ConditionalValueDef; @@ -2076,7 +2057,9 @@ interface BaseChartSpec { /** * How chrome height interacts with the container height budget. * - * - `'subtract'` (default): the container height is fixed and chrome text + * - omitted: only notes and the series summary grow the figure; title, + * subtitle, and source subtract from the height budget. + * - `'subtract'`: the container height is fixed and chrome text * (title/subtitle/source) is subtracted from it, shrinking the plot. This * is the historical behavior. * - `'grow'`: the container height is treated as the *plot* budget, and the @@ -2084,7 +2067,7 @@ interface BaseChartSpec { * its full budget (minus axis/padding overhead) regardless of how many * lines the title wraps to. * - * `'grow'` is a no-op for faceted specs in this version. + * Facets and layers report engine-owned growth once. */ chromeLayout?: 'subtract' | 'grow'; /** @@ -2135,7 +2118,7 @@ interface BaseChartSpec { * - omitted: auto-enable for multi-series line/area charts, hide otherwise. * * See {@link EndpointLabelsConfig} for the full suppression truth table that - * relates this flag to `legend.show` and the legacy end-of-line labels. + * relates this flag to `legend.show`. */ endpointLabels?: boolean | EndpointLabelsConfig; /** @@ -2147,8 +2130,6 @@ interface BaseChartSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** * Whether to show the OpenData watermark. Defaults to true; auto-hides * when the container is under 200px tall (the cramped height class) unless @@ -2367,8 +2348,6 @@ export interface TableSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Enable client-side search/filter. */ @@ -2393,10 +2372,6 @@ export interface TableSpec { * "Total". */ totalRow?: boolean | { label?: string }; - /** - * @deprecated Use `density: 'condensed'`. Still honored, with a warning. - */ - compact?: boolean; /** Whether the table adapts to container width. Defaults to true. */ responsive?: boolean; /** Animation configuration for entrance animations. */ @@ -2529,8 +2504,6 @@ export interface GraphSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** * Motion configuration. DEFAULT-ON: omit for full defaults, `false` to opt * out of choreography (layout warmup still applies). @@ -2659,8 +2632,6 @@ export interface LayerSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Resolution strategy for shared scales/axes/legends. */ @@ -2752,22 +2723,10 @@ export interface SankeySpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Animation configuration for entrance animations. */ animation?: AnimationSpec; - /** - * d3-format string applied to flow values in tooltips and ARIA labels. - * Uses the literal suffix extension: ".0f%" appends "%" to the formatted - * number (data value 28 renders as "28%"). For currency: "$,.0f" or "$~s". - * For SI suffixes: "~s" (renders 1000 as "1k"). When not set, values use - * the default number formatter. - * @deprecated Use the encoding channel's `format` field instead. This - * property is expanded into the encoding during compilation. - */ - valueFormat?: string; } // --------------------------------------------------------------------------- @@ -2824,8 +2783,6 @@ export interface TileMapSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Animation configuration for entrance animations. */ @@ -2954,19 +2911,12 @@ export interface GeoMapSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Optional point/symbol layer rendered above choropleth features. */ points?: GeoMapPointsLayer; /** Animation configuration for entrance animations. */ animation?: AnimationSpec; - /** - * d3-format string applied to color values in tooltips and legend. - * @deprecated Use the encoding channel's `format` field instead. - */ - valueFormat?: string; } // --------------------------------------------------------------------------- @@ -2994,19 +2944,10 @@ export interface BarListSpec { /** Theme configuration overrides. */ theme?: ThemeConfig; /** Dark mode behavior. Defaults to "off". */ - /** @deprecated Use the mount darkMode option; the spec field has no effect. */ - darkMode?: DarkMode; /** Whether to show the OpenData watermark. Defaults to true. */ watermark?: boolean; /** Animation configuration for entrance animations. */ animation?: AnimationSpec; - /** - * d3-format string applied to bar values and tooltips. - * Examples: ".1f" for one decimal, "$,.0f" for currency, "~s" for SI. - * @deprecated Use the encoding channel's `format` field instead. This - * property is expanded into the encoding during compilation. - */ - valueFormat?: string; } /** Encoding channels for a barlist visualization. */ @@ -3511,14 +3452,9 @@ export function isRangeAnnotation(annotation: Annotation): annotation is RangeAn return annotation.type === 'range'; } -/** - * Check if an annotation is a RefLineAnnotation. Also accepts the deprecated - * `'rule'` type at runtime (pre-sugar-expansion input), since `annotation.type` - * may not yet be narrowed to the canonical `'refline'` form. - */ +/** Check if an annotation is a RefLineAnnotation. */ export function isRefLineAnnotation(annotation: Annotation): annotation is RefLineAnnotation { - const type: string = annotation.type; - return type === 'refline' || type === 'rule'; + return annotation.type === 'refline'; } // --------------------------------------------------------------------------- diff --git a/packages/engine/src/__test-fixtures__/specs.ts b/packages/engine/src/__test-fixtures__/specs.ts index efe4c3cb..38a80377 100644 --- a/packages/engine/src/__test-fixtures__/specs.ts +++ b/packages/engine/src/__test-fixtures__/specs.ts @@ -61,6 +61,7 @@ export function makeLineSpec(): NormalizedChartSpec { y: { field: 'value', type: 'quantitative' }, color: { field: 'country', type: 'nominal' }, }, + chromeLayout: 'notes', chrome: { title: { text: 'GDP Growth' }, subtitle: { text: 'US vs UK over time' }, @@ -70,7 +71,6 @@ export function makeLineSpec(): NormalizedChartSpec { responsive: true, autoThin: true, theme: {}, - darkMode: 'off', labels: { density: 'auto', format: '', prefix: '' }, hiddenSeries: [], seriesStyles: {}, @@ -109,12 +109,12 @@ export function makeBarSpec(): NormalizedChartSpec { x: { field: 'value', type: 'quantitative' }, y: { field: 'name', type: 'nominal' }, }, + chromeLayout: 'notes', chrome: {}, annotations: [], responsive: true, autoThin: true, theme: {}, - darkMode: 'off', labels: { density: 'auto', format: '', prefix: '' }, hiddenSeries: [], seriesStyles: {}, @@ -155,12 +155,12 @@ export function makeScatterSpec(): NormalizedChartSpec { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' }, }, + chromeLayout: 'notes', chrome: {}, annotations: [], responsive: true, autoThin: true, theme: {}, - darkMode: 'off', labels: { density: 'auto', format: '', prefix: '' }, hiddenSeries: [], seriesStyles: {}, diff --git a/packages/engine/src/__test-fixtures__/v8-styling.json b/packages/engine/src/__test-fixtures__/v8-styling.json new file mode 100644 index 00000000..0ccde543 --- /dev/null +++ b/packages/engine/src/__test-fixtures__/v8-styling.json @@ -0,0 +1,1335 @@ +[ + { + "name": "defaults and annotations", + "source": { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "chrome": { + "title": "Title", + "source": "Source" + }, + "annotations": [ + { + "type": "text", + "x": 0, + "y": 0, + "text": "Headline", + "subtitle": "Detail", + "connector": "curve" + }, + { + "type": "refline", + "y": 1, + "label": "Rule" + }, + { + "type": "range", + "y1": 0, + "y2": 1, + "label": "Range" + } + ] + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 26, + "subtitle": 14, + "body": 13, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 26, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "transparent", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#333333", + "annotationMuted": "#6b7280", + "annotationLeader": "#8f8f8f", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#ffffff", + "annotationPlate": "#ffffff", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#f0f0f0", + "200": "#e1e1e2", + "300": "#c9c9c9", + "400": "#9d9d9d", + "600": "#787879", + "800": "#474748", + "secondary": "#3f3f46", + "faint": "#d4d4d8", + "border": "rgba(0, 0, 0, 0.08)", + "surface": "#ffffff" + } + }, + "annotations": [ + { + "type": "text", + "label": { + "text": "Headline", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 13, + "fontWeight": 700, + "fill": "#333333", + "lineHeight": 1.3, + "textAnchor": "start" + } + }, + "subtitle": { + "text": "Detail", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 11, + "fontWeight": 400, + "fill": "#6b7280", + "lineHeight": 1.3, + "textAnchor": "start" + } + }, + "connector": { + "arrow": true, + "stroke": "#333333" + } + }, + { + "type": "refline", + "label": { + "text": "Rule", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 11, + "fontWeight": 400, + "fill": "#666666", + "lineHeight": 1.3, + "textAnchor": "start" + } + } + }, + { + "type": "range", + "label": { + "text": "Range", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 11, + "fontWeight": 500, + "fill": "#333333", + "lineHeight": 1.3, + "textAnchor": "middle" + } + } + } + ] + } + }, + { + "name": "authored typography 1", + "source": { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "theme": { + "chrome": { + "title": { + "fontSize": 31 + }, + "source": { + "fontSize": 16 + } + } + }, + "chrome": { + "title": "Title", + "source": "Source", + "footer": "Footer" + } + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 26, + "subtitle": 14, + "body": 13, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 31, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 16, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "transparent", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#333333", + "annotationMuted": "#6b7280", + "annotationLeader": "#8f8f8f", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#ffffff", + "annotationPlate": "#ffffff", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#f0f0f0", + "200": "#e1e1e2", + "300": "#c9c9c9", + "400": "#9d9d9d", + "600": "#787879", + "800": "#474748", + "secondary": "#3f3f46", + "faint": "#d4d4d8", + "border": "rgba(0, 0, 0, 0.08)", + "surface": "#ffffff" + } + }, + "annotations": [] + } + }, + { + "name": "authored typography 2", + "source": { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "theme": { + "fonts": { + "sizes": { + "small": 15, + "title": 32 + } + } + }, + "chrome": { + "title": "Title", + "source": "Source", + "footer": "Footer" + } + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 32, + "subtitle": 14, + "body": 13, + "small": 15, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 32, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 15, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 15, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 15, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "transparent", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#333333", + "annotationMuted": "#6b7280", + "annotationLeader": "#8f8f8f", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#ffffff", + "annotationPlate": "#ffffff", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#f0f0f0", + "200": "#e1e1e2", + "300": "#c9c9c9", + "400": "#9d9d9d", + "600": "#787879", + "800": "#474748", + "secondary": "#3f3f46", + "faint": "#d4d4d8", + "border": "rgba(0, 0, 0, 0.08)", + "surface": "#ffffff" + } + }, + "annotations": [] + } + }, + { + "name": "authored typography 3", + "source": { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "theme": { + "fonts": { + "sizes": { + "body": 16 + } + }, + "chrome": { + "footer": { + "fontSize": 19 + } + } + }, + "chrome": { + "title": "Title", + "source": "Source", + "footer": "Footer" + } + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 26, + "subtitle": 14, + "body": 16, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 26, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 19, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "transparent", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#333333", + "annotationMuted": "#6b7280", + "annotationLeader": "#8f8f8f", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#ffffff", + "annotationPlate": "#ffffff", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#f0f0f0", + "200": "#e1e1e2", + "300": "#c9c9c9", + "400": "#9d9d9d", + "600": "#787879", + "800": "#474748", + "secondary": "#3f3f46", + "faint": "#d4d4d8", + "border": "rgba(0, 0, 0, 0.08)", + "surface": "#ffffff" + } + }, + "annotations": [] + } + }, + { + "name": "partial child theme replaces parent", + "source": { + "theme": { + "colors": { + "categorical": ["green"] + } + }, + "layer": [ + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "theme": { + "fonts": { + "sizes": { + "small": 16 + } + } + } + }, + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + } + } + ] + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 26, + "subtitle": 14, + "body": 13, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 26, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 11, + "fontWeight": 400, + "color": "#71717a", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["green"], + "categoricalFill": ["green"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "transparent", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#333333", + "annotationMuted": "#6b7280", + "annotationLeader": "#8f8f8f", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#ffffff", + "annotationPlate": "#ffffff", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#f0f0f0", + "200": "#e1e1e2", + "300": "#c9c9c9", + "400": "#9d9d9d", + "600": "#787879", + "800": "#474748", + "secondary": "#3f3f46", + "faint": "#d4d4d8", + "border": "rgba(0, 0, 0, 0.08)", + "surface": "#ffffff" + } + }, + "annotations": [] + } + }, + { + "name": "leaf owner nested=false", + "source": { + "layer": [ + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "responsive": false, + "theme": { + "colors": { + "background": "pink" + }, + "fonts": { + "sizes": { + "title": 40 + } + } + }, + "chrome": { + "title": "Owned by first leaf" + }, + "annotations": [ + { + "type": "text", + "x": 0, + "y": 0, + "text": "Hidden" + } + ] + }, + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + } + } + ] + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 40, + "subtitle": 14, + "body": 13, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 40, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#2d2d2e", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "pink", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#d1d5db", + "annotationMuted": "#9ca3af", + "annotationLeader": "#9aa0a6", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#0a0a0a", + "annotationPlate": "#0a0a0a", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#171719", + "200": "#262627", + "300": "#3d3e3f", + "400": "#68696a", + "600": "#8c8c8d", + "800": "#bcbcbd", + "secondary": "#d0d6e0", + "faint": "#52525b", + "border": "rgba(255, 255, 255, 0.1)", + "surface": "pink" + } + }, + "annotations": [ + { + "type": "text", + "label": { + "text": "Hidden", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 13, + "fontWeight": 400, + "fill": "#d1d5db", + "lineHeight": 1.3, + "textAnchor": "start" + } + }, + "connector": { + "arrow": false, + "stroke": "#9aa0a6" + } + } + ] + } + }, + { + "name": "leaf owner nested=true", + "source": { + "layer": [ + { + "layer": [ + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + }, + "responsive": false, + "theme": { + "colors": { + "background": "pink" + }, + "fonts": { + "sizes": { + "title": 40 + } + } + }, + "chrome": { + "title": "Owned by first leaf" + }, + "annotations": [ + { + "type": "text", + "x": 0, + "y": 0, + "text": "Hidden" + } + ] + } + ] + }, + { + "mark": "point", + "data": [ + { + "x": 0, + "y": 0 + }, + { + "x": 1, + "y": 1 + } + ], + "encoding": { + "x": { + "field": "x", + "type": "quantitative", + "scale": { + "zero": false + } + }, + "y": { + "field": "y", + "type": "quantitative", + "scale": { + "zero": false + } + } + } + } + ] + }, + "expected": { + "fonts": { + "family": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "mono": "\"JetBrains Mono\", \"Fira Code\", \"Cascadia Code\", monospace", + "sizes": { + "title": 40, + "subtitle": 14, + "body": 13, + "small": 11, + "axisTick": 11, + "metricLabel": 11, + "metricValue": 22 + }, + "weights": { + "normal": 400, + "medium": 500, + "semibold": 600, + "bold": 700 + } + }, + "spacing": { + "padding": 20, + "chromeGap": 6, + "chromeToChart": 8, + "chartToFooter": 8, + "axisMargin": 6, + "xAxisHeight": 26, + "xAxisLabelPadding": 4 + }, + "chrome": { + "eyebrow": { + "fontSize": 11, + "fontWeight": 500, + "color": "#06b6d4", + "lineHeight": 1.4 + }, + "title": { + "fontSize": 40, + "fontWeight": 600, + "color": "#09090b", + "lineHeight": 1.15 + }, + "subtitle": { + "fontSize": 14, + "fontWeight": 400, + "color": "#2d2d2e", + "lineHeight": 1.45 + }, + "source": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + }, + "byline": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + }, + "footer": { + "fontSize": 11, + "fontWeight": 400, + "color": "#454546", + "lineHeight": 1.4 + } + }, + "colors": { + "categorical": ["#06b6d4", "#e29e47", "#4170cb", "#b2417f", "#3ca368", "#a584dc"], + "categoricalFill": ["#51bccc", "#dda96b", "#587fc8", "#bf6291", "#62ab7d", "#ae96da"], + "sequential": { + "blue": ["#deebf7", "#c6dbef", "#9ecae1", "#6baed6", "#3182bd", "#08519c"], + "green": ["#e5f5e0", "#c7e9c0", "#a1d99b", "#74c476", "#31a354", "#006d2c"], + "orange": ["#fee6ce", "#fdd0a2", "#fdae6b", "#fd8d3c", "#e6550d", "#a63603"], + "purple": ["#efedf5", "#dadaeb", "#bcbddc", "#9e9ac8", "#756bb1", "#54278f"], + "teal": ["#06b6d4", "#05a3be", "#0490a8", "#037d92", "#026a7c", "#015766", "#004450"] + }, + "diverging": { + "redBlue": ["#b2182b", "#d6604d", "#f4a582", "#f7f7f7", "#92c5de", "#4393c3", "#2166ac"], + "brownTeal": ["#8c510a", "#bf812d", "#dfc27d", "#f6e8c3", "#80cdc1", "#35978f", "#01665e"] + }, + "background": "pink", + "text": "#09090b", + "gridline": "rgba(0,0,0,0.08)", + "hairline": "rgba(0,0,0,0.14)", + "axis": "#71717a", + "annotationFill": "rgba(0,0,0,0.04)", + "annotationText": "#71717a", + "annotationLabel": "#d1d5db", + "annotationMuted": "#9ca3af", + "annotationLeader": "#9aa0a6", + "annotationRule": "#666666", + "annotationRangeFill": "#000000", + "annotationMarkerFill": "#0a0a0a", + "annotationPlate": "#0a0a0a", + "positive": "#15803d", + "negative": "#dc2626", + "neutral": { + "100": "#171719", + "200": "#262627", + "300": "#3d3e3f", + "400": "#68696a", + "600": "#8c8c8d", + "800": "#bcbcbd", + "secondary": "#d0d6e0", + "faint": "#52525b", + "border": "rgba(255, 255, 255, 0.1)", + "surface": "pink" + } + }, + "annotations": [ + { + "type": "text", + "label": { + "text": "Hidden", + "style": { + "fontFamily": "\"Inter Variable\", Inter, -apple-system, BlinkMacSystemFont, \"Segoe UI\", Roboto, sans-serif", + "fontSize": 13, + "fontWeight": 400, + "fill": "#d1d5db", + "lineHeight": 1.3, + "textAnchor": "start" + } + }, + "connector": { + "arrow": false, + "stroke": "#9aa0a6" + } + } + ] + } + } +] diff --git a/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap b/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap index fe080369..110e9735 100644 --- a/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +++ b/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap @@ -36,19 +36,19 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "animation": undefined, "annotations": [], "area": { - "height": 296.1, - "width": 540.058, - "x": 36.942, - "y": 60.9, + "height": 287.1, + "width": 539.336, + "x": 37.664, + "y": 68.9, }, "axes": { "x": { "domainLine": undefined, "end": { "x": 577, - "y": 357, + "y": 356, }, - "extent": 26, + "extent": 27, "gridlines": [], "label": undefined, "labelFlush": undefined, @@ -64,14 +64,14 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "offset": undefined, "orient": undefined, "start": { - "x": 36.942, - "y": 357, + "x": 37.664, + "y": 356, }, "tickAngle": undefined, "tickLabelStyle": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.2, @@ -81,17 +81,17 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "ticks": [ { "label": "0", - "position": 36.942, + "position": 37.664, "value": 0, }, { "label": "20", - "position": 252.9652, + "position": 253.3984, "value": 20, }, { "label": "40", - "position": 468.9884, + "position": 469.13280000000003, "value": 40, }, ], @@ -100,21 +100,21 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "y": { "domainLine": undefined, "end": { - "x": 36.942, - "y": 357, + "x": 37.664, + "y": 356, }, "gridlines": [ { "major": true, - "position": 300.05769230769226, + "position": 300.78846153846155, }, { "major": true, - "position": 208.95000000000002, + "position": 212.45000000000002, }, { "major": true, - "position": 117.84230769230768, + "position": 124.11153846153847, }, ], "label": undefined, @@ -131,14 +131,14 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "offset": undefined, "orient": undefined, "start": { - "x": 36.942, - "y": 60.9, + "x": 37.664, + "y": 68.9, }, "tickAngle": undefined, "tickLabelStyle": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.2, @@ -148,17 +148,17 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "ticks": [ { "label": "A", - "position": 300.05769230769226, + "position": 300.78846153846155, "value": "A", }, { "label": "B", - "position": 208.95000000000002, + "position": 212.45000000000002, "value": "B", }, { "label": "C", - "position": 117.84230769230768, + "position": 124.11153846153847, "value": "C", }, ], @@ -168,6 +168,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "chrome": { "bottomAnchorY": 383, "bottomHeight": 0, + "grownHeight": 0, "title": { "maxWidth": 561, "style": { @@ -182,7 +183,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "x": 17, "y": 17, }, - "topHeight": 37.9, + "topHeight": 45.9, }, "crosshair": false, "dimensions": { @@ -202,7 +203,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.3, }, @@ -225,7 +226,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.3, }, @@ -253,7 +254,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "value": 10, }, "fill": "#51bccc", - "height": 68.33076923076923, + "height": 66.25384615384615, "key": "|A", "label": { "connector": undefined, @@ -270,14 +271,14 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data }, "text": "10", "visible": true, - "x": 138.9536, - "y": 300.05769230769226, + "x": 139.5312, + "y": 300.78846153846155, }, "orient": "horizontal", "type": "rect", - "width": 108.01159999999999, - "x": 36.942, - "y": 265.89230769230767, + "width": 107.86720000000001, + "x": 37.664, + "y": 267.6615384615385, }, { "aria": { @@ -295,7 +296,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "value": 25, }, "fill": "#51bccc", - "height": 68.33076923076923, + "height": 66.25384615384615, "key": "|B", "label": { "connector": undefined, @@ -312,14 +313,14 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data }, "text": "25", "visible": true, - "x": 300.971, - "y": 208.95000000000002, + "x": 301.332, + "y": 212.45000000000002, }, "orient": "horizontal", "type": "rect", - "width": 270.029, - "x": 36.942, - "y": 174.7846153846154, + "width": 269.668, + "x": 37.664, + "y": 179.32307692307694, }, { "aria": { @@ -337,7 +338,7 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "value": 40, }, "fill": "#51bccc", - "height": 68.33076923076923, + "height": 66.25384615384615, "key": "|C", "label": { "connector": undefined, @@ -354,24 +355,25 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data }, "text": "40", "visible": true, - "x": 462.9884, - "y": 117.84230769230768, + "x": 463.13280000000003, + "y": 124.11153846153847, }, "orient": "horizontal", "type": "rect", - "width": 432.0464, - "x": 36.942, - "y": 83.67692307692306, + "width": 431.46880000000004, + "x": 37.664, + "y": 90.9846153846154, }, ], "metrics": undefined, "theme": { + "_authored": {}, "_tokenPairs": undefined, "borderRadius": 8, "chrome": { "byline": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -383,13 +385,13 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data }, "footer": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, "source": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -413,8 +415,8 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "annotationMarkerFill": "#ffffff", "annotationMuted": "#6b7280", "annotationPlate": "#ffffff", - "annotationRangeFill": "#000000", - "annotationRule": "#666666", + "annotationRangeFill": "#f0c040", + "annotationRule": "#888888", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", @@ -519,11 +521,11 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "family": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", "mono": ""JetBrains Mono", "Fira Code", "Cascadia Code", monospace", "sizes": { - "axisTick": 11, + "axisTick": 12, "body": 13, "metricLabel": 11, "metricValue": 22, - "small": 11, + "small": 12, "subtitle": 14, "title": 26, }, @@ -539,9 +541,9 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "seriesStrategy": "palette", "spacing": { "axisMargin": 6, - "chartToFooter": 8, + "chartToFooter": 12, "chromeGap": 6, - "chromeToChart": 8, + "chromeToChart": 16, "padding": 20, "xAxisHeight": 26, "xAxisLabelPadding": 4, @@ -591,23 +593,23 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data "bottomPixel": 577, "scaleType": "linear", "topData": 0, - "topPixel": 36.942, + "topPixel": 37.664, }, "yInvert": { "bottomData": 0, - "bottomPixel": 60.9, + "bottomPixel": 68.9, "domain": [ "A", "B", "C", ], "positions": [ - 300.05769230769226, - 208.95000000000002, - 117.84230769230768, + 300.78846153846155, + 212.45000000000002, + 124.11153846153847, ], "topData": 0, - "topPixel": 357, + "topPixel": 356, }, } `; @@ -669,10 +671,10 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "animation": undefined, "annotations": [], "area": { - "height": 263.4, + "height": 248, "width": 683.04015, "x": 26, - "y": 147.2, + "y": 156.2, }, "axes": { "x": undefined, @@ -680,36 +682,36 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "domainLine": false, "end": { "x": 26, - "y": 410.59999999999997, + "y": 404.2, }, "gridlines": [ { "major": true, - "position": 394.13749999999993, + "position": 388.7, }, { "major": true, - "position": 352.98125, + "position": 349.95, }, { "major": true, - "position": 311.825, + "position": 311.2, }, { "major": true, - "position": 270.66874999999993, + "position": 272.45, }, { "major": true, - "position": 229.5125, + "position": 233.7, }, { "major": true, - "position": 188.35625, + "position": 194.95, }, { "major": true, - "position": 147.2, + "position": 156.2, }, ], "label": undefined, @@ -727,13 +729,13 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "orient": undefined, "start": { "x": 26, - "y": 147.2, + "y": 156.2, }, "tickAngle": undefined, "tickLabelStyle": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.2, @@ -743,37 +745,37 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "ticks": [ { "label": "10", - "position": 394.13749999999993, + "position": 388.7, "value": 10, }, { "label": "15", - "position": 352.98125, + "position": 349.95, "value": 15, }, { "label": "20", - "position": 311.825, + "position": 311.2, "value": 20, }, { "label": "25", - "position": 270.66874999999993, + "position": 272.45, "value": 25, }, { "label": "30", - "position": 229.5125, + "position": 233.7, "value": 30, }, { "label": "35", - "position": 188.35625, + "position": 194.95, "value": 35, }, { "label": "40", - "position": 147.2, + "position": 156.2, "value": 40, }, ], @@ -781,21 +783,22 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, }, "chrome": { - "bottomAnchorY": 436.59999999999997, - "bottomHeight": 43.4, + "bottomAnchorY": 431.2, + "bottomHeight": 48.8, + "grownHeight": 0, "source": { "maxWidth": 755, "style": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, "textAnchor": "start", }, "text": "World Bank", "x": 20, - "y": 8, + "y": 12, }, "subtitle": { "maxWidth": 755, @@ -825,7 +828,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "x": 20, "y": 20, }, - "topHeight": 64.2, + "topHeight": 72.2, }, "crosshair": true, "dimensions": { @@ -835,19 +838,19 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "display": "full", "endpointLabels": { "bounds": { - "height": 263.4, + "height": 248, "width": 48.95985, "x": 727.04015, - "y": 147.2, + "y": 156.2, }, "entries": [ { "color": "#048398", - "dataY": 147.2, + "dataY": 156.2, "labelLines": [ "US", ], - "labelY": 147.2, + "labelY": 156.2, "marker": { "dataX": 709.04015, "fill": "transparent", @@ -855,7 +858,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "stroke": "#048398", "strokeWidth": 2, "x": 713.04015, - "y": 147.2, + "y": 156.2, }, "seriesKey": "US", "showLeader": false, @@ -863,11 +866,11 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, { "color": "#cc8020", - "dataY": 188.35625, + "dataY": 194.95, "labelLines": [ "UK", ], - "labelY": 181.95, + "labelY": 190.95, "marker": { "dataX": 709.04015, "fill": "transparent", @@ -875,7 +878,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "stroke": "#cc8020", "strokeWidth": 2, "x": 713.04015, - "y": 188.35625, + "y": 194.95, }, "seriesKey": "UK", "showLeader": false, @@ -883,11 +886,11 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, { "color": "#2c55a2", - "dataY": 295.36249999999995, + "dataY": 295.7, "labelLines": [ "FR", ], - "labelY": 288.86249999999995, + "labelY": 289.2, "marker": { "dataX": 709.04015, "fill": "transparent", @@ -895,7 +898,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "stroke": "#2c55a2", "strokeWidth": 2, "x": 713.04015, - "y": 295.36249999999995, + "y": 295.7, }, "seriesKey": "FR", "showLeader": false, @@ -903,11 +906,11 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, { "color": "#85315f", - "dataY": 245.975, + "dataY": 249.2, "labelLines": [ "DE", ], - "labelY": 239.475, + "labelY": 242.7, "marker": { "dataX": 709.04015, "fill": "transparent", @@ -915,7 +918,7 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "stroke": "#85315f", "strokeWidth": 2, "x": 713.04015, - "y": 245.975, + "y": 249.2, }, "seriesKey": "DE", "showLeader": false, @@ -947,9 +950,9 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "legend": { "bounds": { "height": 32, - "width": 196.5, + "width": 202.00000000000003, "x": 26, - "y": 90.19999999999999, + "y": 98.19999999999999, }, "entries": [ { @@ -981,41 +984,41 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "entryPositions": [ { "labelX": 44, - "labelY": 96.19999999999999, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, + "width": 50.668, "x": 26, - "y": 90.19999999999999, + "y": 98.19999999999999, }, { - "labelX": 93.279, - "labelY": 96.19999999999999, + "labelX": 94.668, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, - "x": 75.279, - "y": 90.19999999999999, + "width": 50.668, + "x": 76.668, + "y": 98.19999999999999, }, { - "labelX": 142.558, - "labelY": 96.19999999999999, + "labelX": 145.336, + "labelY": 104.19999999999999, "row": 0, - "width": 48.663, - "x": 124.55799999999999, - "y": 90.19999999999999, + "width": 49.995999999999995, + "x": 127.336, + "y": 98.19999999999999, }, { - "labelX": 191.221, - "labelY": 96.19999999999999, + "labelX": 195.332, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, - "x": 173.221, - "y": 90.19999999999999, + "width": 50.668, + "x": 177.332, + "y": 98.19999999999999, }, ], "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.3, @@ -1031,9 +1034,9 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line { "bounds": { "height": 32, - "width": 196.5, + "width": 202.00000000000003, "x": 26, - "y": 90.19999999999999, + "y": 98.19999999999999, }, "entries": [ { @@ -1065,41 +1068,41 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "entryPositions": [ { "labelX": 44, - "labelY": 96.19999999999999, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, + "width": 50.668, "x": 26, - "y": 90.19999999999999, + "y": 98.19999999999999, }, { - "labelX": 93.279, - "labelY": 96.19999999999999, + "labelX": 94.668, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, - "x": 75.279, - "y": 90.19999999999999, + "width": 50.668, + "x": 76.668, + "y": 98.19999999999999, }, { - "labelX": 142.558, - "labelY": 96.19999999999999, + "labelX": 145.336, + "labelY": 104.19999999999999, "row": 0, - "width": 48.663, - "x": 124.55799999999999, - "y": 90.19999999999999, + "width": 49.995999999999995, + "x": 127.336, + "y": 98.19999999999999, }, { - "labelX": 191.221, - "labelY": 96.19999999999999, + "labelX": 195.332, + "labelY": 104.19999999999999, "row": 0, - "width": 49.278999999999996, - "x": 173.221, - "y": 90.19999999999999, + "width": 50.668, + "x": 177.332, + "y": 98.19999999999999, }, ], "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.3, @@ -1151,8 +1154,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line ], "title": "2020", }, - "x": 47, - "y": 394.13749999999993, + "x": 48, + "y": 388.7, }, { "datum": { @@ -1176,25 +1179,25 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "title": "2021", }, "x": 709.04015, - "y": 147.2, + "y": 156.2, }, ], "interpolate": "monotone", "key": "US", "opacity": undefined, - "path": "M47,394.137L709.04,147.2", + "path": "M48,388.7L709.04,156.2", "pointKeys": [ "2020-01-01", "2021-01-01", ], "points": [ { - "x": 47, - "y": 394.13749999999993, + "x": 48, + "y": 388.7, }, { "x": 709.04015, - "y": 147.2, + "y": 156.2, }, ], "seriesKey": "US", @@ -1241,8 +1244,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line ], "title": "2020", }, - "x": 47, - "y": 352.98125, + "x": 48, + "y": 349.95, }, { "datum": { @@ -1266,25 +1269,25 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "title": "2021", }, "x": 709.04015, - "y": 188.35625, + "y": 194.95, }, ], "interpolate": "monotone", "key": "UK", "opacity": undefined, - "path": "M47,352.981L709.04,188.356", + "path": "M48,349.95L709.04,194.95", "pointKeys": [ "2020-01-01", "2021-01-01", ], "points": [ { - "x": 47, - "y": 352.98125, + "x": 48, + "y": 349.95, }, { "x": 709.04015, - "y": 188.35625, + "y": 194.95, }, ], "seriesKey": "UK", @@ -1331,8 +1334,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line ], "title": "2020", }, - "x": 47, - "y": 410.59999999999997, + "x": 48, + "y": 404.2, }, { "datum": { @@ -1356,25 +1359,25 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "title": "2021", }, "x": 709.04015, - "y": 295.36249999999995, + "y": 295.7, }, ], "interpolate": "monotone", "key": "FR", "opacity": undefined, - "path": "M47,410.6L709.04,295.362", + "path": "M48,404.2L709.04,295.7", "pointKeys": [ "2020-01-01", "2021-01-01", ], "points": [ { - "x": 47, - "y": 410.59999999999997, + "x": 48, + "y": 404.2, }, { "x": 709.04015, - "y": 295.36249999999995, + "y": 295.7, }, ], "seriesKey": "FR", @@ -1421,8 +1424,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line ], "title": "2020", }, - "x": 47, - "y": 377.67499999999995, + "x": 48, + "y": 373.2, }, { "datum": { @@ -1446,25 +1449,25 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "title": "2021", }, "x": 709.04015, - "y": 245.975, + "y": 249.2, }, ], "interpolate": "monotone", "key": "DE", "opacity": undefined, - "path": "M47,377.675L709.04,245.975", + "path": "M48,373.2L709.04,249.2", "pointKeys": [ "2020-01-01", "2021-01-01", ], "points": [ { - "x": 47, - "y": 377.67499999999995, + "x": 48, + "y": 373.2, }, { "x": 709.04015, - "y": 245.975, + "y": 249.2, }, ], "seriesKey": "DE", @@ -1476,12 +1479,13 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line ], "metrics": undefined, "theme": { + "_authored": {}, "_tokenPairs": undefined, "borderRadius": 8, "chrome": { "byline": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -1493,13 +1497,13 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, "footer": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, "source": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -1523,8 +1527,8 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "annotationMarkerFill": "#ffffff", "annotationMuted": "#6b7280", "annotationPlate": "#ffffff", - "annotationRangeFill": "#000000", - "annotationRule": "#666666", + "annotationRangeFill": "#f0c040", + "annotationRule": "#888888", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", @@ -1629,11 +1633,11 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "family": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", "mono": ""JetBrains Mono", "Fira Code", "Cascadia Code", monospace", "sizes": { - "axisTick": 11, + "axisTick": 12, "body": 13, "metricLabel": 11, "metricValue": 22, - "small": 11, + "small": 12, "subtitle": 14, "title": 26, }, @@ -1649,9 +1653,9 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "seriesStrategy": "palette", "spacing": { "axisMargin": 6, - "chartToFooter": 8, + "chartToFooter": 12, "chromeGap": 6, - "chromeToChart": 8, + "chromeToChart": 16, "padding": 20, "xAxisHeight": 26, "xAxisLabelPadding": 4, @@ -1664,14 +1668,14 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line "bottomPixel": 709.04015, "scaleType": "time", "topData": 1577836800000, - "topPixel": 47, + "topPixel": 48, }, "yInvert": { "bottomData": 40, - "bottomPixel": 147.2, + "bottomPixel": 156.2, "scaleType": "linear", "topData": 8, - "topPixel": 410.59999999999997, + "topPixel": 404.2, }, } `; @@ -1708,19 +1712,19 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "animation": undefined, "annotations": [], "area": { - "height": 234.3, - "width": 537.768, - "x": 39.232, - "y": 60.9, + "height": 218.50000000000003, + "width": 536.656, + "x": 40.344, + "y": 68.9, }, "axes": { "x": { "domainLine": undefined, "end": { "x": 577, - "y": 295.2, + "y": 287.40000000000003, }, - "extent": 26, + "extent": 27, "gridlines": [], "label": undefined, "labelFlush": undefined, @@ -1736,14 +1740,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "offset": undefined, "orient": undefined, "start": { - "x": 39.232, - "y": 295.2, + "x": 40.344, + "y": 287.40000000000003, }, "tickAngle": undefined, "tickLabelStyle": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.2, @@ -1753,22 +1757,22 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "ticks": [ { "label": "A", - "position": 118.31552941176471, + "position": 119.26399999999998, "value": "A", }, { "label": "B", - "position": 244.84917647058825, + "position": 245.53599999999997, "value": "B", }, { "label": "C", - "position": 371.3828235294118, + "position": 371.80799999999994, "value": "C", }, { "label": "D", - "position": 497.91647058823537, + "position": 498.0799999999999, "value": "D", }, ], @@ -1777,41 +1781,41 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "y": { "domainLine": undefined, "end": { - "x": 39.232, - "y": 295.2, + "x": 40.344, + "y": 287.40000000000003, }, "gridlines": [ { "major": true, - "position": 295.2, + "position": 287.40000000000003, }, { "major": true, - "position": 261.72857142857146, + "position": 256.1857142857143, }, { "major": true, - "position": 228.25714285714287, + "position": 224.9714285714286, }, { "major": true, - "position": 194.78571428571428, + "position": 193.75714285714287, }, { "major": true, - "position": 161.31428571428572, + "position": 162.54285714285717, }, { "major": true, - "position": 127.84285714285714, + "position": 131.32857142857142, }, { "major": true, - "position": 94.37142857142858, + "position": 100.11428571428573, }, { "major": true, - "position": 60.9, + "position": 68.9, }, ], "label": undefined, @@ -1828,14 +1832,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "offset": undefined, "orient": undefined, "start": { - "x": 39.232, - "y": 60.9, + "x": 40.344, + "y": 68.9, }, "tickAngle": undefined, "tickLabelStyle": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontVariant": "tabular-nums", "fontWeight": 400, "lineHeight": 1.2, @@ -1845,42 +1849,42 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "ticks": [ { "label": "0", - "position": 295.2, + "position": 287.40000000000003, "value": 0, }, { "label": "10", - "position": 261.72857142857146, + "position": 256.1857142857143, "value": 10, }, { "label": "20", - "position": 228.25714285714287, + "position": 224.9714285714286, "value": 20, }, { "label": "30", - "position": 194.78571428571428, + "position": 193.75714285714287, "value": 30, }, { "label": "40", - "position": 161.31428571428572, + "position": 162.54285714285717, "value": 40, }, { "label": "50", - "position": 127.84285714285714, + "position": 131.32857142857142, "value": 50, }, { "label": "60", - "position": 94.37142857142858, + "position": 100.11428571428573, "value": 60, }, { "label": "70", - "position": 60.9, + "position": 68.9, "value": 70, }, ], @@ -1888,35 +1892,36 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, }, "chrome": { - "bottomAnchorY": 321.2, - "bottomHeight": 61.8, + "bottomAnchorY": 314.40000000000003, + "bottomHeight": 68.6, "byline": { "maxWidth": 431, "style": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, "textAnchor": "start", }, "text": "By the engine", "x": 17, - "y": 29.4, + "y": 34.8, }, + "grownHeight": 0, "source": { "maxWidth": 431, "style": { "fill": "#71717a", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, "textAnchor": "start", }, "text": "Test data", "x": 17, - "y": 8, + "y": 12, }, "title": { "maxWidth": 561, @@ -1932,7 +1937,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "x": 17, "y": 17, }, - "topHeight": 37.9, + "topHeight": 45.9, }, "crosshair": false, "dimensions": { @@ -1952,7 +1957,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.3, }, @@ -1975,7 +1980,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "labelStyle": { "fill": "#09090b", "fontFamily": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.3, }, @@ -2015,7 +2020,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g ], "type": "linear", }, - "height": 100.41428571428571, + "height": 93.64285714285717, "key": "|A", "label": { "connector": undefined, @@ -2030,14 +2035,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "text": "30", "visible": true, - "x": 118.31552941176471, - "y": 182.78571428571428, + "x": 119.26399999999998, + "y": 181.75714285714287, }, "orient": "vertical", "type": "rect", - "width": 94.90023529411765, - "x": 70.8654117647059, - "y": 194.78571428571428, + "width": 94.704, + "x": 71.91199999999998, + "y": 193.75714285714287, }, { "aria": { @@ -2067,7 +2072,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g ], "type": "linear", }, - "height": 184.09285714285713, + "height": 171.67857142857144, "key": "|B", "label": { "connector": undefined, @@ -2082,14 +2087,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "text": "55", "visible": true, - "x": 244.84917647058825, - "y": 99.10714285714286, + "x": 245.53599999999997, + "y": 103.72142857142859, }, "orient": "vertical", "type": "rect", - "width": 94.90023529411765, - "x": 197.39905882352943, - "y": 111.10714285714286, + "width": 94.704, + "x": 198.18399999999997, + "y": 115.72142857142859, }, { "aria": { @@ -2119,7 +2124,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g ], "type": "linear", }, - "height": 234.29999999999998, + "height": 218.50000000000003, "key": "|C", "label": { "connector": undefined, @@ -2134,14 +2139,14 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "text": "70", "visible": true, - "x": 371.3828235294118, - "y": 48.9, + "x": 371.80799999999994, + "y": 56.900000000000006, }, "orient": "vertical", "type": "rect", - "width": 94.90023529411765, - "x": 323.93270588235293, - "y": 60.9, + "width": 94.704, + "x": 324.45599999999996, + "y": 68.9, }, { "aria": { @@ -2171,7 +2176,7 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g ], "type": "linear", }, - "height": 150.62142857142857, + "height": 140.46428571428575, "key": "|D", "label": { "connector": undefined, @@ -2186,24 +2191,25 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "text": "45", "visible": true, - "x": 497.91647058823537, - "y": 132.57857142857142, + "x": 498.0799999999999, + "y": 134.93571428571428, }, "orient": "vertical", "type": "rect", - "width": 94.90023529411765, - "x": 450.4663529411765, - "y": 144.57857142857142, + "width": 94.704, + "x": 450.72799999999995, + "y": 146.93571428571428, }, ], "metrics": undefined, "theme": { + "_authored": {}, "_tokenPairs": undefined, "borderRadius": 8, "chrome": { "byline": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -2215,13 +2221,13 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "footer": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, "source": { "color": "#71717a", - "fontSize": 11, + "fontSize": 12, "fontWeight": 400, "lineHeight": 1.4, }, @@ -2245,8 +2251,8 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "annotationMarkerFill": "#ffffff", "annotationMuted": "#6b7280", "annotationPlate": "#ffffff", - "annotationRangeFill": "#000000", - "annotationRule": "#666666", + "annotationRangeFill": "#f0c040", + "annotationRule": "#888888", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", @@ -2351,11 +2357,11 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "family": ""Inter Variable", Inter, -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, sans-serif", "mono": ""JetBrains Mono", "Fira Code", "Cascadia Code", monospace", "sizes": { - "axisTick": 11, + "axisTick": 12, "body": 13, "metricLabel": 11, "metricValue": 22, - "small": 11, + "small": 12, "subtitle": 14, "title": 26, }, @@ -2371,9 +2377,9 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "seriesStrategy": "palette", "spacing": { "axisMargin": 6, - "chartToFooter": 8, + "chartToFooter": 12, "chromeGap": 6, - "chromeToChart": 8, + "chromeToChart": 16, "padding": 20, "xAxisHeight": 26, "xAxisLabelPadding": 4, @@ -2444,20 +2450,20 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g "D", ], "positions": [ - 118.31552941176471, - 244.84917647058825, - 371.3828235294118, - 497.91647058823537, + 119.26399999999998, + 245.53599999999997, + 371.80799999999994, + 498.0799999999999, ], "topData": 0, - "topPixel": 39.232, + "topPixel": 40.344, }, "yInvert": { "bottomData": 70, - "bottomPixel": 60.9, + "bottomPixel": 68.9, "scaleType": "linear", "topData": 0, - "topPixel": 295.2, + "topPixel": 287.40000000000003, }, } `; diff --git a/packages/engine/src/__tests__/annotation-behavior.test.ts b/packages/engine/src/__tests__/annotation-behavior.test.ts index 27768c4f..58948856 100644 --- a/packages/engine/src/__tests__/annotation-behavior.test.ts +++ b/packages/engine/src/__tests__/annotation-behavior.test.ts @@ -37,7 +37,6 @@ import { DEFAULT_ANNOTATION_FONT_WEIGHT, DEFAULT_DOT_RADIUS, DEFAULT_DOT_STROKE_WIDTH, - LEDE_FONT_WEIGHT, MIN_CONNECTOR_LENGTH, SUBTITLE_FONT_WEIGHT, subtitleFontSize, @@ -467,19 +466,19 @@ describe('the words are styled the way the design says', () => { expect(a.label!.style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT); }); - test('adding a subtitle promotes the first line to a bold lede', () => { + test('adding a subtitle preserves the regular primary weight', () => { const a = first([ { type: 'text', x: 'Mar', y: 30, text: '+1.17°C', subtitle: 'the warmest year' }, ]); - // This is the pairing the whole lede rule exists for. It once shipped dead. - expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT); + // Both lines remain regular unless the author explicitly sets a weight. + expect(a.label!.style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT); expect(a.subtitle!.text).toBe('the warmest year'); expect(a.subtitle!.style.fontWeight).toBe(SUBTITLE_FONT_WEIGHT); expect(a.subtitle!.style.fontSize).toBe(subtitleFontSize(DEFAULT_ANNOTATION_FONT_SIZE)); }); - test('an explicit weight beats the lede rule', () => { + test('an explicit weight is preserved alongside a subtitle', () => { const a = first([ { type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', fontWeight: 500 }, ]); @@ -498,7 +497,7 @@ describe('the words are styled the way the design says', () => { { type: 'text', x: 'Mar', y: 30, text: 'Peak', subtitle: 'context', connector: 'drop-line' }, ]); expect(a.subtitle?.text).toBe('context'); - expect(a.label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT); + expect(a.label!.style.fontWeight).toBe(DEFAULT_ANNOTATION_FONT_WEIGHT); }); test('text is never center-aligned; it faces the data', () => { diff --git a/packages/engine/src/__tests__/axes.test.ts b/packages/engine/src/__tests__/axes.test.ts index 9b187df6..e9eeb2ff 100644 --- a/packages/engine/src/__tests__/axes.test.ts +++ b/packages/engine/src/__tests__/axes.test.ts @@ -681,7 +681,7 @@ describe('text-aware tick density', () => { it('applies the last-anchored stride when even vertical labels cannot fit', () => { // 40 short categories on a 360px chart: ~9px steps fit nothing at any // angle (a vertical label ribbon needs ~17px), so the safety net keeps - // every 2nd label counting back from the LAST category. + // every 3rd label counting back from the LAST category. const categories = Array.from({ length: 40 }, (_, i) => `C${i + 1}`); const barSpec: NormalizedChartSpec = { ...lineSpec, @@ -700,7 +700,7 @@ describe('text-aware tick density', () => { expect(axes.x!.tickAngle).toBe(-90); const kept = axes.x!.ticks.map((t) => t.label); - expect(kept.length).toBe(20); + expect(kept).toEqual(Array.from({ length: 14 }, (_, i) => `C${1 + i * 3}`)); // The stride anchors at the final category — the most recent one is // always labeled. expect(kept[kept.length - 1]).toBe('C40'); diff --git a/packages/engine/src/__tests__/compile-footnotes.test.ts b/packages/engine/src/__tests__/compile-footnotes.test.ts index 94c164d5..3fb288eb 100644 --- a/packages/engine/src/__tests__/compile-footnotes.test.ts +++ b/packages/engine/src/__tests__/compile-footnotes.test.ts @@ -54,7 +54,10 @@ describe('auto-thinning footnote reserve', () => { }); it('reserves bottom space so the footnote list clears the footer row', () => { - const withFootnotes = compileChart(crowdedSpec, { width: 200, height: 340 }); + const withFootnotes = compileChart( + { ...crowdedSpec, chromeLayout: 'subtract' }, + { width: 200, height: 340 }, + ); const footnotes = withFootnotes.chrome.footnotes ?? []; expect(footnotes.length).toBeGreaterThan(0); @@ -149,7 +152,8 @@ describe('auto-thinning footnote reserve', () => { const baseline = compileChart({ ...spec, annotations: [] }, options); const reserved = compileChart(spec, options); expect(reserved.chrome.noteBandHeight).toBeGreaterThan(0); - expect(reserved.area).toEqual(baseline.area); + for (const key of ['x', 'y', 'width', 'height'] as const) + expect(reserved.area[key]).toBeCloseTo(baseline.area[key], 8); expect(reserved.dimensions.height - baseline.dimensions.height).toBeCloseTo( reserved.chrome.noteBandHeight!, 8, @@ -316,7 +320,7 @@ describe('auto-thinning in faceted charts', () => { it.each([false, true])('grows facet chrome once with spec-level grow=%s', (onSpec) => { const options = { width: 640, height: 600 }; - const baseline = compileChart(facetedSpec, options); + const baseline = compileChart({ ...facetedSpec, chromeLayout: 'subtract' }, options); const grown = compileChart( onSpec ? { ...facetedSpec, chromeLayout: 'grow' } : facetedSpec, onSpec ? options : { ...options, chromeLayout: 'grow' }, @@ -393,18 +397,27 @@ describe('auto-thinning in faceted charts', () => { }); it('reserves a band for the footnote list below the grid', () => { - const withFootnotes = compileChart(facetedSpec, { width: 640, height: 400 }); + const withFootnotes = compileChart( + { ...facetedSpec, chromeLayout: 'subtract' }, + { width: 640, height: 400 }, + ); const footnotes = withFootnotes.chrome.footnotes ?? []; expect(footnotes.length).toBeGreaterThan(0); const { annotations: _dropped, ...noAnnotations } = facetedSpec; - const baseline = compileChart(noAnnotations as ChartSpec, { width: 640, height: 400 }); + const baseline = compileChart({ ...noAnnotations, chromeLayout: 'subtract' } as ChartSpec, { + width: 640, + height: 400, + }); const plotBottom = (l: typeof withFootnotes) => l.area.y + l.area.height; const pulledUpBy = plotBottom(baseline) - plotBottom(withFootnotes); const band = footnotes.length * withFootnotes.theme.fonts.sizes.small * 1.3 + 4; - expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9); + // Short facet panels retain a minimum plot budget. If it raises the figure + // height, that growth contributes to the reserved band as well. + const minimumPanelGrowth = withFootnotes.dimensions.height - baseline.dimensions.height; + expect(pulledUpBy + minimumPanelGrowth).toBeGreaterThanOrEqual(band - 1e-9); }); // Regression: footnote numbers were keyed on the annotation's position in the diff --git a/packages/engine/src/__tests__/facet-grow-opt-in.test.ts b/packages/engine/src/__tests__/facet-grow-opt-in.test.ts index d69a4e72..dba28086 100644 --- a/packages/engine/src/__tests__/facet-grow-opt-in.test.ts +++ b/packages/engine/src/__tests__/facet-grow-opt-in.test.ts @@ -24,7 +24,10 @@ const spec: ChartSpec = { }; describe('facet chrome growth', () => { it('appends one chrome/note band while preserving the non-chrome plot budget', () => { - const subtract = compileChart(spec, { width: 390, height: 650 }); + const subtract = compileChart( + { ...spec, chromeLayout: 'subtract' }, + { width: 390, height: 650 }, + ); const grow = compileChart({ ...spec, chromeLayout: 'grow' }, { width: 390, height: 650 }); const added = grow.chrome.topHeight + grow.chrome.bottomHeight + (grow.chrome.noteBandHeight ?? 0); @@ -36,10 +39,15 @@ describe('facet chrome growth', () => { grow.facet!.panels.every((panel) => panel.annotations.every((a) => a.footnoteIndex)), ).toBe(true); }); - it('keeps default/subtract identical and accepts the compile option', () => { + it('grows only notes by default and accepts the compile option', () => { const opts = { width: 800, height: 650 }; - expect(compileChart(spec, opts)).toEqual( - compileChart({ ...spec, chromeLayout: 'subtract' }, opts), + const defaultLayout = compileChart(spec, opts); + const subtract = compileChart({ ...spec, chromeLayout: 'subtract' }, opts); + expect(defaultLayout.dimensions.height - subtract.dimensions.height).toBeCloseTo( + defaultLayout.chrome.noteBandHeight ?? 0, + ); + expect(defaultLayout.chrome.grownHeight ?? 0).toBeCloseTo( + defaultLayout.chrome.noteBandHeight ?? 0, ); expect(compileChart(spec, { ...opts, chromeLayout: 'grow' })).toEqual( compileChart({ ...spec, chromeLayout: 'grow' }, opts), diff --git a/packages/engine/src/__tests__/known-bugs.test.ts b/packages/engine/src/__tests__/known-bugs.test.ts index dd5ddc6a..08bc539f 100644 --- a/packages/engine/src/__tests__/known-bugs.test.ts +++ b/packages/engine/src/__tests__/known-bugs.test.ts @@ -249,11 +249,8 @@ describe('known layout bugs', () => { }); }); - // Regression: `resolveTextAnnotation` bolts the lede rule on (a `subtitle` - // promotes the primary line to 700) BEFORE branching to the drop-line resolver, - // which used to resolve no subtitle at all. Net effect: authoring a subtitle on - // a drop-line silently threw the subtitle away but still bolded the primary -- - // a field whose only visible effect was a mystery weight change. + // Regression: drop-line resolution used to discard subtitle content. The v9 + // regular-weight default must preserve the subtitle on this connector path. describe('a drop-line annotation honors its subtitle', () => { // Annotate Mar, not the Feb maximum: a callout on the chart's top value clamps // against the chart edge and has nowhere to go, which is a separate (and @@ -290,8 +287,8 @@ describe('known layout bugs', () => { expect(annotation?.subtitle?.text).toBe('the highest month'); }); - test('still promotes the primary line to a bold lede', () => { - expect(dropLineWithSubtitle()?.label?.style.fontWeight).toBe(700); + test('keeps the primary line regular by default', () => { + expect(dropLineWithSubtitle()?.label?.style.fontWeight).toBe(400); }); test('renders the subtitle below the primary line, left edges aligned', () => { @@ -529,7 +526,7 @@ describe('known layout bugs', () => { return style; }; - test('a subtitle promotes the primary line to a bold lede', () => { + test('a subtitle keeps the primary line regular by default', () => { const style = styleOf({ type: 'text', x: '2025', @@ -537,7 +534,7 @@ describe('known layout bugs', () => { text: 'Peak', subtitle: 'the warmest year on record', }); - expect(style.fontWeight).toBe(700); + expect(style.fontWeight).toBe(400); }); test('a plain annotation stays regular weight', () => { @@ -550,7 +547,7 @@ describe('known layout bugs', () => { expect(style.fontSize).toBe(13); }); - test('an explicit fontWeight beats the lede rule', () => { + test('an explicit fontWeight overrides the regular default', () => { const style = styleOf({ type: 'text', x: '2025', diff --git a/packages/engine/src/__tests__/layout-invariants.test.ts b/packages/engine/src/__tests__/layout-invariants.test.ts index 6fa8ac55..3888586a 100644 --- a/packages/engine/src/__tests__/layout-invariants.test.ts +++ b/packages/engine/src/__tests__/layout-invariants.test.ts @@ -1,5 +1,4 @@ -import type { ChartLayout } from '@opendata-ai/openchart-core'; -import { afterAll, describe, expect, it } from 'vitest'; +import { describe, expect, it } from 'vitest'; import { compileChart } from '../compile'; import { checkLayoutInvariants } from './helpers/invariants'; @@ -62,20 +61,9 @@ const FULL_CHROME = { const WIDTHS = [320, 800] as const; const HEIGHT = 400; -let compileSkipCount = 0; - -/** - * Compile a spec and run layout invariants. Returns early (skip) if - * compileChart throws due to pre-existing compile failures from plan merges. - */ +/** Compile every fixture; a compile failure must fail the invariant test. */ function compileAndCheck(spec: Record, width: number, height = HEIGHT) { - let layout: ChartLayout; - try { - layout = compileChart(spec, { width, height }); - } catch { - compileSkipCount++; - return; - } + const layout = compileChart(spec, { width, height }); const violations = checkLayoutInvariants(layout, { svgWidth: width, svgHeight: height }); expect(violations, violations.join('\n')).toEqual([]); } @@ -85,12 +73,6 @@ function compileAndCheck(spec: Record, width: number, height = // --------------------------------------------------------------------------- describe('layout invariants', () => { - afterAll(() => { - if (compileSkipCount > 0) { - console.warn(`layout invariants: ${compileSkipCount} tests skipped due to compile errors`); - } - }); - // ----------------------------------------------------------------------- // Bar charts (horizontal: x=quantitative, y=nominal) // ----------------------------------------------------------------------- @@ -292,7 +274,7 @@ describe('layout invariants', () => { }); // ----------------------------------------------------------------------- - // Pie charts (arc mark: y=quantitative, color=nominal) + // Pie charts (arc mark: theta=quantitative, color=nominal) // ----------------------------------------------------------------------- describe('pie charts', () => { for (const width of WIDTHS) { @@ -302,7 +284,7 @@ describe('layout invariants', () => { mark: 'arc', data: makePieData(SHORT_LABELS), encoding: { - y: { field: 'value', type: 'quantitative' }, + theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, chrome: TITLE_ONLY, @@ -318,7 +300,7 @@ describe('layout invariants', () => { mark: 'arc', data: makePieData(SHORT_LABELS), encoding: { - y: { field: 'value', type: 'quantitative' }, + theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, legend: { position: 'bottom', show: true }, @@ -335,7 +317,7 @@ describe('layout invariants', () => { mark: 'arc', data: makePieData(LONG_LABELS), encoding: { - y: { field: 'value', type: 'quantitative' }, + theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, legend: { position: 'top', show: true }, @@ -351,7 +333,7 @@ describe('layout invariants', () => { mark: 'arc', data: makePieData(SHORT_LABELS), encoding: { - y: { field: 'value', type: 'quantitative' }, + theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, chrome: NO_CHROME, @@ -437,7 +419,7 @@ describe('layout invariants', () => { }); // ----------------------------------------------------------------------- - // Dot charts (circle mark: x=quantitative, y=quantitative) + // Dot charts (circle mark: x=quantitative, y=ordinal) // ----------------------------------------------------------------------- describe('dot charts', () => { for (const width of WIDTHS) { @@ -448,7 +430,7 @@ describe('layout invariants', () => { data: makeScatterData(['Alpha', 'Beta']), encoding: { x: { field: 'x', type: 'quantitative' }, - y: { field: 'y', type: 'quantitative' }, + y: { field: 'y', type: 'ordinal' }, color: { field: 'group', type: 'nominal' }, }, legend: { position: 'bottom', show: true }, @@ -466,7 +448,7 @@ describe('layout invariants', () => { data: makeScatterData(['Very Long Group Name A', 'Very Long Group Name B']), encoding: { x: { field: 'x', type: 'quantitative' }, - y: { field: 'y', type: 'quantitative' }, + y: { field: 'y', type: 'ordinal' }, color: { field: 'group', type: 'nominal' }, }, legend: { position: 'top', show: true }, diff --git a/packages/engine/src/__tests__/mark-keys.test.ts b/packages/engine/src/__tests__/mark-keys.test.ts index 531efe2e..b9e15bba 100644 --- a/packages/engine/src/__tests__/mark-keys.test.ts +++ b/packages/engine/src/__tests__/mark-keys.test.ts @@ -245,7 +245,7 @@ describe('pie chart mark keys', () => { { category: 'Tablet', share: 10 }, ], encoding: { - y: { field: 'share', type: 'quantitative' }, + theta: { field: 'share', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, }; diff --git a/packages/engine/src/__tests__/migrate-spec-v8.test.ts b/packages/engine/src/__tests__/migrate-spec-v8.test.ts index 59ec70f2..840ba33c 100644 --- a/packages/engine/src/__tests__/migrate-spec-v8.test.ts +++ b/packages/engine/src/__tests__/migrate-spec-v8.test.ts @@ -1,4 +1,5 @@ import { describe, expect, it } from 'vitest'; +import v8Fixtures from '../__test-fixtures__/v8-styling.json'; import { compileChart, compileLayer } from '../compile'; import { migrateSpecV8 } from '../migration/v8'; @@ -63,7 +64,7 @@ describe('migrateSpecV8 alias repairs', () => { expect(result.repairs.map((r) => r.path)).toContain('/layer/0/annotations/0/type'); }); - it('preserves rendering for nonvisual aliases', () => { + it('compiles the repaired aliases under v9', () => { const spec = { ...chart, animation: { preset: 'legacy' }, @@ -71,9 +72,10 @@ describe('migrateSpecV8 alias repairs', () => { theme: { chrome: { title: '#333333' } }, chrome: { title: 'Title' }, }; - expect(compileChart(migrateSpecV8(spec).spec, { width: 640, height: 400 })).toEqual( - compileChart(spec, { width: 640, height: 400 }), - ); + const layout = compileChart(migrateSpecV8(spec).spec, { width: 640, height: 400 }); + expect(layout.annotations[0].type).toBe('refline'); + expect(layout.theme.chrome.title.color).toBe('#333333'); + expect(layout.animation).toBeDefined(); }); it('replaces legacy labels and records each lost offset (explicit 8.10 exception)', () => { @@ -156,17 +158,10 @@ it('rewrites the parent endpoint owner of a legacy-label layer', () => { expect(migrateSpecV8(migrated.spec).repairs).toEqual([]); }); -it('warns once for legacy chrome colors supplied through compile options', () => { - const warnings: string[] = []; - const options = { - width: 640, - height: 400, - theme: { chrome: { title: 'red' } }, - onWarn: (message: string) => warnings.push(message), - }; - compileChart(chart, options); - compileChart(chart, options); - expect(warnings.filter((message) => message.includes('theme.chrome'))).toHaveLength(1); +it('rejects removed chrome color strings in compile options', () => { + expect(() => + compileChart(chart, { width: 640, height: 400, theme: { chrome: { title: 'red' } } } as never), + ).toThrow('theme.chrome'); }); describe('migrateSpecV8 default materialization', () => { @@ -221,48 +216,67 @@ describe('migrateSpecV8 default materialization', () => { ); if (responsive === false || responsive === undefined) expect(migrated.responsive).toBe(false); }); - it.each([ - { chrome: { title: { fontSize: 31 }, source: { fontSize: 16 } } }, - { fonts: { sizes: { small: 15, title: 32 } } }, - { fonts: { sizes: { body: 16 } }, chrome: { footer: { fontSize: 19 } } }, - ])('preserves authored typography %j', (theme) => { - const source = { - ...chart, - theme, - chrome: { title: 'Title', source: 'Source', footer: 'Footer' }, - }; - const migrated = migrateSpecV8(source).spec; - expect(compileChart(migrated, { width: 640, height: 500 }).chrome).toEqual( - compileChart(source, { width: 640, height: 500 }).chrome, - ); - }); }); -it('does not merge a parent theme into an authored partial child theme', () => { +it('can adopt v9 styling while performing every necessary alias repair', () => { const source = { - theme: { colors: { categorical: ['green'] } }, - layer: [chart, { ...chart, theme: { fonts: { sizes: { small: 16 } } } }], + ...chart, + darkMode: 'off', + annotations: [ + { type: 'rule', y: 0, label: 'Rule' }, + { type: 'text', x: 0, y: 0, text: 'Text', subtitle: 'Details' }, + ], }; - const opts = { width: 640, height: 500 }; - expect(compileLayer(migrateSpecV8(source).spec, opts).marks).toEqual( - compileLayer(source, opts).marks, - ); + const migrated = migrateSpecV8(source, { styling: 'v9' }); + expect(migrated.spec.annotations[0].type).toBe('refline'); + expect(migrated.spec).not.toHaveProperty('darkMode'); + expect(migrated.spec).not.toHaveProperty('theme'); + expect(migrated.spec.annotations[1]).not.toHaveProperty('fontWeight'); + expect(migrateSpecV8(migrated.spec, { styling: 'v9' }).repairs).toEqual([]); }); -it.each([false, true])('preserves first-leaf ownership in nested=%s layers', (nested) => { - const leaf = { - ...chart, - responsive: false, - theme: { colors: { background: 'pink' }, fonts: { sizes: { title: 40 } } }, - chrome: { title: 'Owned by first leaf' }, - annotations: [{ type: 'text', x: 0, y: 0, text: 'Hidden' }], +// Captured from released v8.9.1 (8baef9e8), not regenerated by v9 snapshots. +// Layout coordinates intentionally are not part of the styling compatibility promise. +it.each(v8Fixtures)('preserves released v8 styling: $name', ({ source, expected }) => { + const before = structuredClone(source); + const { spec } = migrateSpecV8(source); + const compile = 'layer' in source ? compileLayer : compileChart; + const layout = compile(spec as never, { width: 640, height: 500 }); + expect(source).toEqual(before); + expect(migrateSpecV8(spec)).toEqual({ spec, repairs: [] }); + expect(layout.theme.fonts).toEqual(expected.fonts); + expect(layout.theme.spacing).toEqual(expected.spacing); + expect(layout.theme.chrome).toEqual(expected.chrome); + expect(layout.theme.colors).toEqual(expected.colors); + const annotations = layout.annotations.map((a) => ({ + type: a.type, + label: a.label ? { text: a.label.text, style: a.label.style } : undefined, + subtitle: a.subtitle ? { text: a.subtitle.text, style: a.subtitle.style } : undefined, + connector: a.label?.connector + ? { arrow: a.label.connector.arrow, stroke: a.label.connector.stroke } + : undefined, + })); + expect(JSON.parse(JSON.stringify(annotations))).toEqual(expected.annotations); +}); + +it('repairs aliases in a line-layer before inspecting its legacy label policy', () => { + const source = { + layer: [ + { + ...chart, + mark: 'line', + annotations: [{ type: 'rule', y: 0, label: 'Baseline' }], + encoding: { + ...chart.encoding, + x: { field: 'x', type: 'ordinal' }, + radius: { field: 'unused' }, + }, + }, + ], }; - const source = { layer: [nested ? { layer: [leaf] } : leaf, chart] }; - const opts = { width: 390, height: 500 }; - const before = compileLayer(source, opts), - after = compileLayer(migrateSpecV8(source).spec, opts); - expect(after.annotations).toEqual(before.annotations); - expect(after.chrome).toEqual(before.chrome); - expect(after.theme.colors).toEqual(before.theme.colors); - expect(after.marks).toEqual(before.marks); + const result = migrateSpecV8(source); + expect(result.spec.layer[0].annotations[0].type).toBe('refline'); + expect(result.spec.layer[0].encoding).not.toHaveProperty('radius'); + expect(() => compileLayer(result.spec, { width: 640, height: 400 })).not.toThrow(); + expect(migrateSpecV8(result.spec).repairs).toEqual([]); }); diff --git a/packages/engine/src/__tests__/series-color-parity.test.ts b/packages/engine/src/__tests__/series-color-parity.test.ts index a9d02f8b..c1fdd3ac 100644 --- a/packages/engine/src/__tests__/series-color-parity.test.ts +++ b/packages/engine/src/__tests__/series-color-parity.test.ts @@ -3,8 +3,7 @@ * * A line's stroke is darkened on a light canvas (`adaptSeriesStroke`) because * the palette hues are tuned as fills. Every place that echoes a series color - * as chrome — the legend swatch, the endpoint-label column, the end-of-line - * direct label, the area's top edge — has to show the SAME rendered color, or + * as chrome — the legend swatch, the endpoint-label column, the area's top edge — has to show the SAME rendered color, or * the reader sees one cyan in the legend and a different one on the line. */ @@ -139,7 +138,7 @@ describe('series color parity', () => { }); it('legend swatches match line strokes in dark mode too', () => { - const layout = compileChart(lineSpec({ darkMode: 'force' }), SIZE); + const layout = compileChart(lineSpec(), { ...SIZE, darkMode: true }); const strokes = lineStrokes(layout); for (const entry of layout.legend.entries) { expect(entry.color).toBe(strokes.get(entry.label)); @@ -194,22 +193,55 @@ describe('series color parity', () => { } }); - it('end-of-line direct labels carry the rendered stroke color', () => { + it.each([ + false, + { show: false }, + ] as const)('does not recreate legacy line labels when both surfaces are hidden (%j)', (endpointLabels) => { + const warnings: string[] = []; const layout = compileChart( + lineSpec({ legend: { show: false }, endpointLabels, labels: { density: 'all' } }), + { ...SIZE, onWarn: (warning) => warnings.push(warning) }, + ); + const lines = layout.marks.filter((mark): mark is LineMark => mark.type === 'line'); + expect(lines).toHaveLength(2); + expect(lines.every((line) => line.label === undefined)).toBe(true); + expect(layout.endpointLabels?.entries ?? []).toEqual([]); + expect(layout.legend.entries).toEqual([]); + expect( + warnings.filter((warning) => warning.includes('Both legend and endpointLabels')), + ).toEqual([ + '[openchart a11y] Both legend and endpointLabels are disabled on a multi-series chart. Enable either to identify the series.', + ]); + }); + + it.each([ + { legend: { show: true }, endpointLabels: false }, + { legend: { show: false }, endpointLabels: true }, + ])('does not warn while a series identification surface is enabled', (identification) => { + const warnings: string[] = []; + const layout = compileChart(lineSpec(identification), { + ...SIZE, + onWarn: (warning) => warnings.push(warning), + }); + expect(layout.legend.entries.length + (layout.endpointLabels?.entries.length ?? 0)).toBe(2); + expect(warnings.some((warning) => warning.includes('Both legend and endpointLabels'))).toBe( + false, + ); + }); + + it('does not demand a series identification surface for a single series', () => { + const warnings: string[] = []; + compileChart( lineSpec({ + data: multiSeries.filter((row) => row.series === 'North'), legend: { show: false }, endpointLabels: false, - labels: { density: 'all' }, - } as Partial), - SIZE, + }), + { ...SIZE, onWarn: (warning) => warnings.push(warning) }, + ); + expect(warnings.some((warning) => warning.includes('Both legend and endpointLabels'))).toBe( + false, ); - const lines = layout.marks.filter((m): m is LineMark => m.type === 'line'); - const labelled = lines.filter((l) => l.label); - - expect(labelled.length).toBeGreaterThan(0); - for (const line of labelled) { - expect(line.label!.style.fill).toBe(line.stroke); - } }); }); @@ -222,7 +254,7 @@ describe('donut centerLabel', () => { { category: 'B', value: 60 }, ], encoding: { - y: { field: 'value', type: 'quantitative' }, + theta: { field: 'value', type: 'quantitative' }, color: { field: 'category', type: 'nominal' }, }, }) as ChartSpec; diff --git a/packages/engine/src/__tests__/spec-sugar.test.ts b/packages/engine/src/__tests__/spec-sugar.test.ts index 96f371f2..eabe6641 100644 --- a/packages/engine/src/__tests__/spec-sugar.test.ts +++ b/packages/engine/src/__tests__/spec-sugar.test.ts @@ -5,8 +5,8 @@ * Covers: data {values} unwrapping, top-level title/subtitle, bare value * defs, channel-level legend, axis: null, scale.scheme resolution, theta as * the arc value channel, count-without-field, VL sort forms, width/height - * fixed sizing, strokeDash on line marks, and the exactly-one-warning - * contract for v8-deprecated surface (radius/shape/href/order, $schema). + * fixed sizing, strokeDash on line marks, rejection of removed channels, and + * warning routing for retained sugar such as $schema. */ import type { LayerSpec, LineMark } from '@opendata-ai/openchart-core'; @@ -326,24 +326,21 @@ describe('theta on arc marks', () => { expect(warned()).toEqual([]); }); - it('warns once when only the deprecated y alias is used', () => { - const layout = compileChart( - { - mark: 'arc', - data, - encoding: { - y: { field: 'amount', type: 'quantitative' }, - color: { field: 'category', type: 'nominal' }, + it('rejects y-only arc specs with the canonical theta replacement', () => { + expect(() => + compileChart( + { + mark: 'arc', + data, + encoding: { + y: { field: 'amount', type: 'quantitative' }, + color: { field: 'category', type: 'nominal' }, + }, }, - }, - OPTIONS, - ); - const arcs = layout.marks.filter((m) => m.type === 'arc'); - expect(arcs.length).toBe(2); - const messages = warned().filter((m) => m.includes('encoding.y')); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain('v8'); - expect(messages[0]).toContain('theta'); + OPTIONS, + ), + ).toThrow(/encoding.y.*Use encoding.theta/); + expect(warned()).toEqual([]); }); it('prefers theta over y when both are present (theta is canonical)', () => { @@ -628,10 +625,10 @@ describe('strokeDash on line marks', () => { }); // --------------------------------------------------------------------------- -// Deprecation warnings (v8 clock) +// Removed spec fields and retained sugar warnings // --------------------------------------------------------------------------- -describe('deprecation warnings', () => { +describe('removed fields and retained warnings', () => { const base = { mark: 'bar', data: [{ cat: 'A', value: 1 }], @@ -646,17 +643,14 @@ describe('deprecation warnings', () => { 'shape', 'href', 'order', - ] as const)('warns exactly once for the dead %s channel, naming the removal version', (channel) => { - compileChart( - { - ...base, - encoding: { ...base.encoding, [channel]: { field: 'value', type: 'quantitative' } }, - }, - OPTIONS, - ); - const messages = warned().filter((m) => m.includes(`encoding.${channel}`)); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain('v8'); + ] as const)('rejects the removed %s channel before sugar strips it', (channel) => { + const spec = { + ...base, + encoding: { ...base.encoding, [channel]: { field: 'value', type: 'quantitative' } }, + }; + expect(() => compileChart(spec, OPTIONS)).toThrow(`encoding.${channel} was removed in v9`); + expect(() => expandSpecSugar(spec)).toThrow(`encoding.${channel} was removed in v9`); + expect(warned()).toEqual([]); }); it('warns once for $schema and strips it', () => { @@ -669,17 +663,17 @@ describe('deprecation warnings', () => { expect(warned().filter((m) => m.includes('$schema'))).toHaveLength(1); }); - it("warns once for the deprecated 'rule' annotation type, naming the refline replacement", () => { - compileChart( - { - ...base, - annotations: [{ type: 'rule', y: 1, label: 'target' }], - }, - OPTIONS, - ); - const messages = warned().filter((m) => m.includes("annotation type 'rule'")); - expect(messages).toHaveLength(1); - expect(messages[0]).toContain("'refline'"); + it("rejects the removed 'rule' annotation type with the refline replacement", () => { + expect(() => + compileChart( + { + ...base, + annotations: [{ type: 'rule', y: 1, label: 'target' }], + }, + OPTIONS, + ), + ).toThrow(/annotations\[0\].type.*Use refline/); + expect(warned()).toEqual([]); }); it("does not warn for the canonical 'refline' annotation type", () => { @@ -731,35 +725,24 @@ describe('layer specs', () => { } }); - it('emits each deprecation warning once per compile even across layer children', () => { - compileLayer( - { - data: [ - { cat: 'A', value: 1 }, - { cat: 'B', value: 2 }, - ], - layer: [ - { - mark: 'bar', - encoding: { - x: { field: 'cat', type: 'nominal' }, - y: { field: 'value', type: 'quantitative' }, - href: { field: 'cat', type: 'nominal' }, - }, - }, - { - mark: 'line', - encoding: { - x: { field: 'cat', type: 'ordinal' }, - y: { field: 'value', type: 'quantitative' }, - href: { field: 'cat', type: 'nominal' }, - }, - }, - ], - } as unknown as LayerSpec, - OPTIONS, - ); - expect(warned().filter((m) => m.includes('encoding.href'))).toHaveLength(1); + it('reports each removed field with its authored layer path', () => { + const spec = { + data: [ + { cat: 'A', value: 1 }, + { cat: 'B', value: 2 }, + ], + layer: ['bar', 'line'].map((mark) => ({ + mark, + encoding: { + x: { field: 'cat', type: 'nominal' }, + y: { field: 'value', type: 'quantitative' }, + href: { field: 'cat', type: 'nominal' }, + }, + })), + }; + expect(() => compileLayer(spec as LayerSpec, OPTIONS)).toThrow('layer[0].encoding.href'); + expect(() => compileLayer(spec as LayerSpec, OPTIONS)).toThrow('layer[1].encoding.href'); + expect(warned()).toEqual([]); }); }); @@ -769,13 +752,12 @@ describe('layer specs', () => { describe('options.onWarn warning sink', () => { const deprecated = { + $schema: 'https://vega.github.io/schema/vega-lite/v5.json', mark: 'bar', data: [{ cat: 'A', value: 1 }], encoding: { x: { field: 'cat', type: 'nominal' }, y: { field: 'value', type: 'quantitative' }, - // A dead v8 channel: guaranteed to produce a deprecation warning. - href: { field: 'value', type: 'quantitative' }, }, }; @@ -784,9 +766,9 @@ describe('options.onWarn warning sink', () => { compileChart(deprecated, { ...OPTIONS, onWarn: (m) => sink.push(m) }); // The warning reached the sink... - expect(sink.some((m) => m.includes('encoding.href'))).toBe(true); + expect(sink.some((m) => m.includes('$schema'))).toBe(true); // ...and console.warn was NOT touched (the spy caught nothing). - expect(warned().filter((m) => m.includes('encoding.href'))).toEqual([]); + expect(warned().filter((m) => m.includes('$schema'))).toEqual([]); }); it('a no-op sink silences warnings entirely (e.g. SSR)', () => { @@ -796,6 +778,6 @@ describe('options.onWarn warning sink', () => { it('falls back to console.warn when no sink is provided', () => { compileChart(deprecated, OPTIONS); - expect(warned().some((m) => m.includes('encoding.href'))).toBe(true); + expect(warned().some((m) => m.includes('$schema'))).toBe(true); }); }); diff --git a/packages/engine/src/__tests__/track-a-wrapping-and-dual-axis.test.ts b/packages/engine/src/__tests__/track-a-wrapping-and-dual-axis.test.ts index bb071504..006b59d2 100644 --- a/packages/engine/src/__tests__/track-a-wrapping-and-dual-axis.test.ts +++ b/packages/engine/src/__tests__/track-a-wrapping-and-dual-axis.test.ts @@ -68,7 +68,7 @@ describe('wrapped annotation notes', () => { expect(notes.every((note) => (note.lines?.length ?? 0) > 1)).toBe(true); for (const note of notes) { expect(note.lines!.join(' ').replaceAll(' ', '')).toBe( - `${note.index}. ${note.text}`.replaceAll(' ', ''), + `${note.index}. ${note.text}${note.subtitle ?? ''}`.replaceAll(' ', ''), ); for (const line of note.lines!) expect( diff --git a/packages/engine/src/annotations/__tests__/compute.test.ts b/packages/engine/src/annotations/__tests__/compute.test.ts index 573bf008..6589608e 100644 --- a/packages/engine/src/annotations/__tests__/compute.test.ts +++ b/packages/engine/src/annotations/__tests__/compute.test.ts @@ -311,7 +311,7 @@ describe('computeAnnotations', () => { expect(annotations[0].label!.text).toBe('Recession'); }); - it('range label defaults to 11px / weight 500', () => { + it('range label defaults to 12px / weight 500', () => { const spec = makeSpec([ { type: 'range', x1: '2020-01-01', x2: '2021-01-01', label: 'Period' }, ]); @@ -326,7 +326,7 @@ describe('computeAnnotations', () => { measure: annotationMeasureForTest, }); - expect(annotations[0].label!.style.fontSize).toBe(11); + expect(annotations[0].label!.style.fontSize).toBe(12); expect(annotations[0].label!.style.fontWeight).toBe(500); }); @@ -845,7 +845,7 @@ describe('computeAnnotations', () => { expect(annotations[0].strokeDasharray).toBeDefined(); }); - it('refline label defaults to fontSize 11', () => { + it('refline label defaults to fontSize 12', () => { const spec = makeSpec([{ type: 'refline', x: '2020-06-01', label: 'Event' }]); const scales = computeScales(spec, chartArea, spec.data); const annotations = computeAnnotations(spec, { @@ -858,7 +858,7 @@ describe('computeAnnotations', () => { measure: annotationMeasureForTest, }); - expect(annotations[0].label!.style.fontSize).toBe(11); + expect(annotations[0].label!.style.fontSize).toBe(12); }); it('refline label honors fontSize and fontWeight overrides', () => { diff --git a/packages/engine/src/annotations/__tests__/connector-crossings.test.ts b/packages/engine/src/annotations/__tests__/connector-crossings.test.ts new file mode 100644 index 00000000..bf3c446e --- /dev/null +++ b/packages/engine/src/annotations/__tests__/connector-crossings.test.ts @@ -0,0 +1,36 @@ +import type { ResolvedLabel } from '@opendata-ai/openchart-core'; +import { expect, it } from 'vitest'; +import { ConnectorCrossings } from '../connector-crossings'; + +const connector: NonNullable = { + from: { x: 0, y: 0 }, + to: { x: 100, y: 0 }, + endpoint: { x: 110, y: 0 }, + style: 'straight', +}; +it('penalizes unrelated labels and leaders while excluding contact with its target', () => { + const index = new ConnectorCrossings(); + index.addBox({ x: 95, y: -5, width: 20, height: 10, kind: 'mark' }); + expect(index.score(connector)).toBe(0); + index.addBox({ x: 45, y: -5, width: 10, height: 10, kind: 'annotation' }); + expect(index.score(connector)).toBeGreaterThan(0); + const leaders = new ConnectorCrossings(); + leaders.addConnector({ ...connector, from: { x: 50, y: -50 }, to: { x: 50, y: 50 } }); + expect(leaders.score(connector)).toBeGreaterThan(0); +}); +it('scores the rendered curve instead of its straight chord and stays deterministic', () => { + const index = new ConnectorCrossings(); + index.addBox({ x: 40, y: 45, width: 20, height: 10, kind: 'annotation' }); + expect(index.score(connector)).toBe(0); + const curve = { ...connector, style: 'curve' as const, control: { x: 50, y: 100 } }; + expect(index.score(curve)).toBeGreaterThan(0); + expect(index.score(curve)).toBe(index.score(curve)); +}); + +it('bounds grid work for extreme off-plot coordinates without missing crossings', () => { + const index = new ConnectorCrossings(); + index.addBox({ x: -1e12, y: -1, width: 2e12, height: 2, kind: 'annotation' }); + expect(index.score(connector)).toBeGreaterThan(0); + const enormous = { ...connector, from: { x: -1e12, y: 0 }, to: { x: 1e12, y: 0 } }; + expect(index.score(enormous)).toBeGreaterThan(0); +}); diff --git a/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts b/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts index 5d02d980..9cb28862 100644 --- a/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts +++ b/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts @@ -58,11 +58,12 @@ describe('annotation text layout opt-ins', () => { expect(a.subtitle!.style.fontSize).toBe(15); expect(a.bounds!.width).toBeLessThanOrEqual(85); }); - it('null and omission reproduce identical layout', () => { + it('null explicitly disables the v9 wrapping default', () => { const compile = (a: TextAnnotation) => compileChart({ ...base, annotations: [a] }, { width: 600, height: 500 }).annotations; - expect(compile({ ...annotation, maxWidth: null })).toEqual(compile(annotation)); - expect(compile({ ...annotation, subtitleFontSize: 11 })).toEqual(compile(annotation)); + expect(compile({ ...annotation, maxWidth: null })[0].label?.lines).toBeUndefined(); + expect(compile(annotation)[0].label!.lines!.length).toBeGreaterThan(1); + expect(compile({ ...annotation, subtitleFontSize: 12 })).toEqual(compile(annotation)); }); }); @@ -94,7 +95,9 @@ describe('compact annotation limit', () => { { width, height: 700 }, ); expect(compile(400, 0).annotations).toEqual(compile(400).annotations); - expect(compile(390, null).annotations).toEqual(compile(390).annotations); + expect( + compile(390).annotations.filter((a) => !a.footnoteIndex && a.id !== 'pinned').length, + ).toBeLessThanOrEqual(1); }); it('does not override D15 hide-all', () => { const result = compileChart( @@ -155,7 +158,7 @@ it('resolves authored annotation tokens and keeps explicit ink authoritative', ( anchor: 'left', offset: { dx: -60, dy: -100 }, fill: '#aabbcc', - connector: 'curve', + connector: { type: 'curve', arrow: true }, }, ], }, @@ -228,7 +231,7 @@ it.each([ it.each([ false, true, -])('preserves compiled rule/range defaults while honoring tokens (dark=%s)', (darkMode) => { +])('applies v9 rule/range defaults while honoring tokens (dark=%s)', (darkMode) => { const annotations: ChartSpec['annotations'] = [ { type: 'refline', id: 'rule', y: 3, label: 'Rule' }, { type: 'range', id: 'range', x1: 1, x2: 2, label: 'Range' }, @@ -239,11 +242,10 @@ it.each([ { ...annotations[1], fill: '#000000' }, ]; const defaults = compileChart({ ...base, annotations }, options); - expect(defaults.theme.colors.annotationRule).toBe('#666666'); - expect(defaults.theme.colors.annotationRangeFill).toBe('#000000'); - expect(defaults.annotations).toEqual( - compileChart({ ...base, annotations: legacyInk }, options).annotations, - ); + expect(defaults.theme.colors.annotationRule).toBe(darkMode ? '#9ca3af' : '#888888'); + expect(defaults.theme.colors.annotationRangeFill).toBe('#f0c040'); + expect(defaults.annotations[0].stroke).toBe(defaults.theme.colors.annotationRule); + expect(defaults.annotations[1].fill).toBe('#f0c040'); const theme = { colors: { annotationRule: { light: '#445566', dark: '#aabbcc' }, diff --git a/packages/engine/src/annotations/__tests__/resolve-text.test.ts b/packages/engine/src/annotations/__tests__/resolve-text.test.ts index 9b3a3214..3d31719c 100644 --- a/packages/engine/src/annotations/__tests__/resolve-text.test.ts +++ b/packages/engine/src/annotations/__tests__/resolve-text.test.ts @@ -20,7 +20,6 @@ import { DEFAULT_DOT_STROKE_WIDTH, DEFAULT_LINE_HEIGHT, FALLBACK_FONT_FAMILY, - LEDE_FONT_WEIGHT, LIGHT_CONNECTOR_STROKE, LIGHT_DOT_FILL, LIGHT_LABEL_BACKGROUND, @@ -148,7 +147,13 @@ describe('text annotation: dot', () => { // the data point. it('does not resolve a default dot when the connector has an arrowhead', () => { const spec = makeSpec([ - { type: 'text', x: '2020-01-01', y: 20, text: 'Arrowed', connector: 'curve' }, + { + type: 'text', + x: '2020-01-01', + y: 20, + text: 'Arrowed', + connector: { type: 'curve', arrow: true }, + }, ]); const scales = computeScales(spec, chartArea, spec.data); const annotations = computeAnnotations(spec, { @@ -785,7 +790,7 @@ describe('text annotation: connector suppression', () => { }); describe('text annotation: connector config', () => { - it('bare "curve" string defaults arrow to true', () => { + it('bare "curve" string defaults arrow to false', () => { const spec = makeSpec([ { type: 'text', @@ -809,7 +814,7 @@ describe('text annotation: connector config', () => { const c = annotations[0].label!.connector!; expect(c.style).toBe('curve'); - expect(c.arrow).toBe(true); + expect(c.arrow).toBe(false); }); it('bare "straight" string defaults arrow to false', () => { @@ -920,7 +925,7 @@ describe('text annotation: connector config', () => { expect(c.arrow).toBe(false); }); - it('object form { type: "curve" } defaults arrow to true', () => { + it('object form { type: "curve" } defaults arrow to false', () => { const spec = makeSpec([ { type: 'text', @@ -944,7 +949,7 @@ describe('text annotation: connector config', () => { const c = annotations[0].label!.connector!; expect(c.style).toBe('curve'); - expect(c.arrow).toBe(true); + expect(c.arrow).toBe(false); }); it('connector: false disables connector entirely', () => { @@ -1032,7 +1037,7 @@ describe('text annotation: typography', () => { }); // Lede rule: a subtitle turns the primary line into a lede, so it goes bold. - it('promotes the primary text to the lede weight when a subtitle is present', () => { + it('keeps primary text regular when a subtitle is present', () => { const spec = makeSpec([ { type: 'text', x: '2020-01-01', y: 20, text: 'Feb. 25', subtitle: '2015 maximum' }, ]); @@ -1047,7 +1052,7 @@ describe('text annotation: typography', () => { measure: annotationMeasureForTest, }); - expect(annotations[0].label!.style.fontWeight).toBe(LEDE_FONT_WEIGHT); + expect(annotations[0].label!.style.fontWeight).toBe(400); // The subtitle never inherits it. expect(annotations[0].subtitle!.style.fontWeight).toBe(SUBTITLE_FONT_WEIGHT); }); diff --git a/packages/engine/src/annotations/__tests__/thinning.test.ts b/packages/engine/src/annotations/__tests__/thinning.test.ts index b6118cae..418d4bd3 100644 --- a/packages/engine/src/annotations/__tests__/thinning.test.ts +++ b/packages/engine/src/annotations/__tests__/thinning.test.ts @@ -34,14 +34,14 @@ function makeSpec(text: string, overrides?: Partial): TextAnnota } describe('thinAnnotations', () => { - it('preserves a demoted subtitle on the same note line', () => { + it('preserves primary text and subtitle separately in notes', () => { const annotations = [makeResolved(100, 100, 'First'), makeResolved(105, 100, 'Second')]; const result = thinAnnotations( annotations, [makeSpec('First'), makeSpec('Second', { subtitle: 'Details' })], measure, ); - expect(result.footnotes).toEqual([{ index: 1, text: 'Second. Details' }]); + expect(result.footnotes).toEqual([{ index: 2, text: 'Second', subtitle: 'Details' }]); }); it('keeps source identity on notes after filtering', () => { const annotations = [ @@ -79,8 +79,8 @@ describe('thinAnnotations', () => { const specs: Annotation[] = [makeSpec('First label'), makeSpec('Second label')]; const result = thinAnnotations(annotations, specs, measure); expect(result.footnotes).toHaveLength(1); - expect(result.footnotes[0]).toEqual({ index: 1, text: 'Second label' }); - expect(result.annotations[1].footnoteIndex).toBe(1); + expect(result.footnotes[0]).toEqual({ index: 2, text: 'Second label' }); + expect(result.annotations[1].footnoteIndex).toBe(2); expect(result.annotations[0].footnoteIndex).toBeUndefined(); }); @@ -122,7 +122,7 @@ describe('thinAnnotations', () => { expect(result.footnotes).toHaveLength(0); }); - it('assigns sequential footnote indices', () => { + it('uses source ordinals with gaps for inline annotations', () => { const annotations = [ makeResolved(100, 100, 'Keep this'), makeResolved(105, 100, 'Demote 1'), @@ -131,8 +131,8 @@ describe('thinAnnotations', () => { const specs: Annotation[] = [makeSpec('Keep this'), makeSpec('Demote 1'), makeSpec('Demote 2')]; const result = thinAnnotations(annotations, specs, measure); expect(result.footnotes).toHaveLength(2); - expect(result.footnotes[0].index).toBe(1); - expect(result.footnotes[1].index).toBe(2); + expect(result.footnotes[0].index).toBe(2); + expect(result.footnotes[1].index).toBe(3); }); it('maintains correct index alignment with mixed annotation types', () => { @@ -153,7 +153,7 @@ describe('thinAnnotations', () => { expect(result.footnotes).toHaveLength(1); expect(result.footnotes[0].text).toBe('Second text'); expect(result.annotations[1].footnoteIndex).toBeUndefined(); - expect(result.annotations[2].footnoteIndex).toBe(1); + expect(result.annotations[2].footnoteIndex).toBe(2); }); describe('plot-area containment', () => { @@ -174,7 +174,7 @@ describe('thinAnnotations', () => { expect(result.footnotes).toHaveLength(1); expect(result.footnotes[0].text).toBe('Way too long to fit here'); expect(result.annotations[0].footnoteIndex).toBeUndefined(); - expect(result.annotations[1].footnoteIndex).toBe(1); + expect(result.annotations[1].footnoteIndex).toBe(2); }); it('keeps a label that fits inside the plot', () => { @@ -244,7 +244,7 @@ describe('thinAnnotations', () => { const result = thinAnnotations(annotations, specs, measure, insetPlot, chart); expect(result.footnotes).toHaveLength(1); - expect(result.annotations[1].footnoteIndex).toBe(1); + expect(result.annotations[1].footnoteIndex).toBe(2); }); }); diff --git a/packages/engine/src/annotations/__tests__/v9-defaults.test.ts b/packages/engine/src/annotations/__tests__/v9-defaults.test.ts new file mode 100644 index 00000000..9816d7a5 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/v9-defaults.test.ts @@ -0,0 +1,149 @@ +import type { ChartSpec, TextAnnotation } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../../compile'; +import { migrateSpecV8 } from '../../migration/v8'; + +const base: ChartSpec = { + mark: 'point', + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + chrome: { title: 'A figure', source: 'Source' }, + animation: false, +}; +const text: TextAnnotation = { + type: 'text', + x: 5, + y: 5, + text: 'A long **annotation phrase** that needs to wrap across several lines', + subtitle: 'Supporting context', + connector: 'curve', + responsive: false, + anchor: 'left', + offset: { dx: -30, dy: -90 }, +}; + +describe('v9 annotation defaults', () => { + it('places automatic text around the measured label of a range band', () => { + for (const y of [8, 9, 10]) { + const layout = compileChart( + { + ...base, + annotations: [ + { type: 'range', x1: 2, x2: 8, label: 'A labeled interval near the peak' }, + { type: 'text', x: 5, y, text: 'Peak value', subtitle: 'Supporting context' }, + ], + }, + { width: 800, height: 500 }, + ); + const range = layout.annotations.find((a) => a.type === 'range')!; + const text = layout.annotations.find((a) => a.type === 'text')!; + expect(range.bounds).toBeDefined(); + const a = range.bounds!; + const b = text.bounds!; + expect( + Math.min(a.x + a.width, b.x + b.width) - Math.max(a.x, b.x) > 1 && + Math.min(a.y + a.height, b.y + b.height) - Math.max(a.y, b.y) > 1, + ).toBe(false); + } + }); + it('uses regular text, a 12/13 subtitle, bounded wrapping, and a quiet curve with a marker', () => { + const a = compileChart({ ...base, annotations: [text] }, { width: 800, height: 600 }) + .annotations[0]; + expect(a.label!.style.fontWeight).toBe(400); + expect(a.subtitle!.style.fontSize).toBe(12); + expect(a.label!.lines!.length).toBeGreaterThan(1); + expect(a.bounds!.width).toBeLessThanOrEqual(240); + expect(a.label!.connector!.arrow).toBe(false); + expect(a.dot).toBeDefined(); + expect(a.label!.text).toBe(text.text); + }); + it('materialized v8 styling keeps the former text, arrow, and wrapping choices', () => { + const migrated = migrateSpecV8({ ...base, annotations: [text] }).spec as ChartSpec; + const a = compileChart(migrated, { width: 800, height: 600 }).annotations[0]; + expect(a.label!.style.fontWeight).toBe(700); + expect(a.subtitle!.style.fontSize).toBe(11); + expect(a.label!.lines).toBeUndefined(); + expect(a.label!.connector!.arrow).toBe(true); + }); + it('retains source text ordinals through filtering, priority and facet duplication', () => { + const annotations: ChartSpec['annotations'] = [ + { type: 'text', x: 'missing', y: 100, text: 'Unresolved position' }, + { type: 'refline', y: 4, label: 'Rule' }, + { type: 'text', x: 3, y: 3, text: '**Second**', subtitle: '**Context**', priority: 20 }, + { type: 'text', x: 7, y: 7, text: 'Third', priority: 1 }, + ]; + for (const facet of [false, true]) { + const spec: ChartSpec = { + ...base, + annotations, + responsive: { compactAnnotationLimit: 0 }, + ...(facet + ? { + data: ['A', 'B'].flatMap((group) => base.data.map((row) => ({ ...row, group }))), + encoding: { + ...base.encoding, + facet: { field: 'group', type: 'nominal', columns: 2 }, + }, + } + : {}), + }; + const layout = compileChart(spec, { width: 390, height: 700 }); + expect(layout.chrome.footnotes!.map((n) => n.index)).toEqual([2, 3]); + expect(layout.chrome.footnotes![0]).toMatchObject({ + text: '**Second**', + subtitle: '**Context**', + }); + expect(layout.chrome.footnotes![0].spans!.flat().some((s) => s.bold)).toBe(true); + } + }); + it.each([false, true])('grows only the note band by default (facet=%s)', (facet) => { + const spec: ChartSpec = { + ...base, + annotations: [ + { ...text, responsive: undefined }, + { ...text, x: 7 }, + ], + ...(facet + ? { + data: ['A', 'B'].flatMap((group) => base.data.map((row) => ({ ...row, group }))), + encoding: { ...base.encoding, facet: { field: 'group', type: 'nominal', columns: 2 } }, + } + : {}), + }; + const options = { width: 800, height: 800 }; + const current = compileChart(spec, options); + const subtract = compileChart({ ...spec, chromeLayout: 'subtract' }, options); + expect(current.chrome.grownHeight).toBeCloseTo(current.chrome.noteBandHeight!); + expect(current.dimensions.height).toBe(options.height + current.chrome.noteBandHeight!); + expect(current.area.height - subtract.area.height).toBeCloseTo(current.chrome.noteBandHeight!); + }); +}); + +it('keeps auto-placed subtitle blocks clear of their own point markers', () => { + const layout = compileChart( + { + ...base, + annotations: [ + { + type: 'text', + x: 5, + y: 8, + text: 'Callout', + subtitle: 'Context for the observation', + maxWidth: 130, + connector: 'curve', + }, + ], + }, + { width: 1280, height: 850 }, + ); + const a = layout.annotations[0]; + const point = a.label!.connector?.endpoint ?? a.dot!; + const b = a.bounds!; + expect( + point.x >= b.x && point.x <= b.x + b.width && point.y >= b.y && point.y <= b.y + b.height, + ).toBe(false); +}); diff --git a/packages/engine/src/annotations/compute.ts b/packages/engine/src/annotations/compute.ts index 658e7882..f5df7d28 100644 --- a/packages/engine/src/annotations/compute.ts +++ b/packages/engine/src/annotations/compute.ts @@ -1,3 +1,4 @@ +import { ConnectorCrossings } from './connector-crossings'; import { rememberAnnotationGeometry } from './edit-context'; import { placeReferenceLabel, @@ -34,7 +35,7 @@ import { } from './collisions'; import { SUBTITLE_FONT_WEIGHT, subtitleFontSize } from './constants'; import type { AnnotationMeasureTextFn } from './geometry'; -import { moveAnnotationTo } from './move'; +import { annotationBlockBounds, moveAnnotationTo } from './move'; import { findBestPlacement, isAutoPlacement, type PlacementObstacle } from './placement'; import { resolvePosition } from './position'; import { resolveRangeAnnotation } from './resolve-range'; @@ -43,6 +44,7 @@ import { makeAnnotationLabelStyle, resolveLedeFontWeight, resolveTextAnnotation, + textConnectorSeed, } from './resolve-text'; export interface AnnotationContext { @@ -58,6 +60,8 @@ export interface AnnotationContext { measure: AnnotationMeasureTextFn; /** Theme font stack, so annotation text is measured and rendered in the same face. */ fontFamily?: string; + padding?: number; + smallFontSize?: number; colors?: import('@opendata-ai/openchart-core').ThemeColors; debugPlacement?: boolean; onWarn?: (message: string) => void; @@ -106,7 +110,17 @@ export function computeAnnotations( }> = []; // ---- Pass 1: resolve explicit annotations and queue auto ones ---- - for (const [specIndex, annotation] of spec.annotations.entries()) { + for (const [specIndex, sourceAnnotation] of spec.annotations.entries()) { + const annotation = + sourceAnnotation.type === 'text' + ? { + ...sourceAnnotation, + maxWidth: + sourceAnnotation.maxWidth === undefined + ? Math.max(1, Math.min(240, ctx.chartArea.width - 2 * (ctx.padding ?? 16))) + : sourceAnnotation.maxWidth, + } + : { ...sourceAnnotation, fontSize: sourceAnnotation.fontSize ?? ctx.smallFontSize ?? 12 }; if (isCompact && annotation.responsive !== false) { continue; } @@ -165,6 +179,10 @@ export function computeAnnotations( } if (resolved) { + // Range labels are fixed obstacles too; their bands are not. Record the + // measured text block before automatic callouts choose a placement. + if (resolved.label && !resolved.bounds) + resolved.bounds = annotationBlockBounds(resolved, resolved.label, ctx.measure); if (annotation.type === 'text' && ctx.obstacles.length > 0) { nudgeAnnotationFromObstacles( resolved, @@ -215,11 +233,22 @@ export function computeAnnotations( const workingObstacles: PlacementObstacle[] = [...ctx.obstacles]; for (let i = 0; i < annotations.length; i++) { const a = annotations[i]; - if (a.bounds && !autoQueue.some((q) => q.index === i)) { + if (a.bounds && !autoQueue.some((q) => q.resolved === a)) { workingObstacles.push({ ...a.bounds, kind: 'annotation' } as PlacementObstacle); } } + const crossingIndex = new ConnectorCrossings(); + for (const obstacle of workingObstacles) crossingIndex.addBox(obstacle); + for (const placed of annotations) { + if (!autoQueue.some((entry) => entry.resolved === placed)) + crossingIndex.addConnector(placed.label?.connector); + } + autoQueue.sort( + (a, b) => + (a.annotation.priority ?? Number.MAX_SAFE_INTEGER) - + (b.annotation.priority ?? Number.MAX_SAFE_INTEGER) || a.index - b.index, + ); for (const entry of autoQueue) { const { annotation, anchorX, anchorY, resolved } = entry; // Must mirror resolveTextAnnotation exactly (lede weight, theme font): @@ -259,6 +288,18 @@ export function computeAnnotations( svgRect, ctx.measure, ctx.debugPlacement, + (x, y, anchor) => { + const candidate = structuredClone(resolved); + if (!candidate.label) return 0; + candidate.label.connector ??= textConnectorSeed( + annotation, + ctx.scales, + ctx.isDark, + ctx.colors, + ); + moveAnnotationTo(candidate, candidate.label, x, y, ctx.measure, anchor); + return crossingIndex.score(candidate.label.connector); + }, ); // Commit the placement. `moveAnnotationTo` owns every piece of the block's @@ -284,6 +325,8 @@ export function computeAnnotations( // search's own estimate of it. One of them has to be the source of truth. const placedBounds = resolved.bounds ?? result.bounds; workingObstacles.push({ ...placedBounds, kind: 'annotation' } as PlacementObstacle); + crossingIndex.addBox({ ...placedBounds, kind: 'annotation' }); + crossingIndex.addConnector(resolved.label?.connector); } } @@ -324,7 +367,18 @@ export function computeAnnotations( for (const resolved of annotations) { const authored = spec.annotations[resolved.specIndex!]; - if (authored?.type === 'text') rememberAnnotationGeometry(resolved, authored, ctx); + if (authored?.type === 'text') + rememberAnnotationGeometry( + resolved, + { + ...authored, + maxWidth: + authored.maxWidth === undefined + ? Math.max(1, Math.min(240, ctx.chartArea.width - 2 * (ctx.padding ?? 16))) + : authored.maxWidth, + }, + ctx, + ); } return annotations; } diff --git a/packages/engine/src/annotations/connector-crossings.ts b/packages/engine/src/annotations/connector-crossings.ts new file mode 100644 index 00000000..9c1fe3c7 --- /dev/null +++ b/packages/engine/src/annotations/connector-crossings.ts @@ -0,0 +1,128 @@ +import type { Point, Rect, ResolvedLabel } from '@opendata-ai/openchart-core'; +import type { PlacementObstacle } from './placement'; + +type Segment = { a: Point; b: Point }; +type Obstacle = { bounds: Rect; segment?: Segment; kind: PlacementObstacle['kind'] }; +const CELL = 64; +const contains = (r: Rect, p: Point) => + p.x >= r.x && p.x <= r.x + r.width && p.y >= r.y && p.y <= r.y + r.height; +const bounds = ({ a, b }: Segment): Rect => ({ + x: Math.min(a.x, b.x), + y: Math.min(a.y, b.y), + width: Math.abs(a.x - b.x), + height: Math.abs(a.y - b.y), +}); + +function intersects(a: Segment, b: Segment): boolean { + const cross = (p: Point, q: Point, r: Point) => + (q.x - p.x) * (r.y - p.y) - (q.y - p.y) * (r.x - p.x); + const c1 = cross(a.a, a.b, b.a), + c2 = cross(a.a, a.b, b.b); + const c3 = cross(b.a, b.b, a.a), + c4 = cross(b.a, b.b, a.b); + if (c1 * c2 > 0 || c3 * c4 > 0) return false; + const ra = bounds(a), + rb = bounds(b); + return ( + ra.x <= rb.x + rb.width && + rb.x <= ra.x + ra.width && + ra.y <= rb.y + rb.height && + rb.y <= ra.y + ra.height + ); +} +function crossesRect(s: Segment, r: Rect): boolean { + if (contains(r, s.a) || contains(r, s.b)) return true; + const points = [ + { x: r.x, y: r.y }, + { x: r.x + r.width, y: r.y }, + { x: r.x + r.width, y: r.y + r.height }, + { x: r.x, y: r.y + r.height }, + ]; + return points.some((a, i) => intersects(s, { a, b: points[(i + 1) % 4] })); +} +/** Bounded curve sampling uses the same resolved control point as rendering. */ +function segments(connector: NonNullable): Segment[] { + if (connector.style !== 'curve' || !connector.control) + return [{ a: connector.from, b: connector.to }]; + const result: Segment[] = []; + let previous = connector.from; + for (let i = 1; i <= 16; i++) { + const t = i / 16, + u = 1 - t; + const next = { + x: u * u * connector.from.x + 2 * u * t * connector.control.x + t * t * connector.to.x, + y: u * u * connector.from.y + 2 * u * t * connector.control.y + t * t * connector.to.y, + }; + result.push({ a: previous, b: next }); + previous = next; + } + return result; +} + +/** Spatially indexed leader/label/mark crossings for bounded placement search. */ +export class ConnectorCrossings { + private cells = new Map>(); + private all = new Set(); + private oversized = new Set(); + private keys(r: Rect): string[] | undefined { + const count = + (Math.floor((r.x + r.width) / CELL) - Math.floor(r.x / CELL) + 1) * + (Math.floor((r.y + r.height) / CELL) - Math.floor(r.y / CELL) + 1); + if (!Number.isFinite(count) || count > 1024) return undefined; + const keys: string[] = []; + for (let x = Math.floor(r.x / CELL); x <= Math.floor((r.x + r.width) / CELL); x++) + for (let y = Math.floor(r.y / CELL); y <= Math.floor((r.y + r.height) / CELL); y++) + keys.push(`${x},${y}`); + return keys; + } + private insert(obstacle: Obstacle): void { + this.all.add(obstacle); + const keys = this.keys(obstacle.bounds); + if (!keys) { + this.oversized.add(obstacle); + return; + } + for (const key of keys) { + let cell = this.cells.get(key); + if (!cell) { + cell = new Set(); + this.cells.set(key, cell); + } + cell.add(obstacle); + } + } + addBox(box: PlacementObstacle): void { + this.insert({ bounds: box, kind: box.kind }); + } + addConnector(connector: ResolvedLabel['connector']): void { + if (connector) + for (const segment of segments(connector)) + this.insert({ bounds: bounds(segment), segment, kind: 'annotation' }); + } + score(connector: ResolvedLabel['connector']): number { + if (!connector) return 0; + const hit = new Set(); + for (const segment of segments(connector)) { + const keys = this.keys(bounds(segment)); + const candidates = keys + ? new Set([...this.oversized, ...keys.flatMap((key) => [...(this.cells.get(key) ?? [])])]) + : this.all; + for (const obstacle of candidates) { + // The observation being explained is not an unrelated obstacle. + if ( + ['mark', 'line', 'area-fill'].includes(obstacle.kind) && + connector.endpoint && + contains(obstacle.bounds, connector.endpoint) + ) + continue; + if ( + obstacle.segment + ? intersects(segment, obstacle.segment) + : crossesRect(segment, obstacle.bounds) + ) + hit.add(obstacle); + } + } + return hit.size * 2; + } +} diff --git a/packages/engine/src/annotations/constants.ts b/packages/engine/src/annotations/constants.ts index 6f766639..fb2e5a2a 100644 --- a/packages/engine/src/annotations/constants.ts +++ b/packages/engine/src/annotations/constants.ts @@ -67,7 +67,7 @@ export const DEFAULT_DOT_STROKE_WIDTH = 1.5; export const SUBTITLE_GAP = 2; /** Subtitle font size multiplier (relative to primary annotation font size). */ -export const SUBTITLE_FONT_SIZE_RATIO = 0.85; +export const SUBTITLE_FONT_SIZE_RATIO = 12 / 13; /** * The subtitle's resolved font size. Rounded, and shared by every caller: the diff --git a/packages/engine/src/annotations/notes.ts b/packages/engine/src/annotations/notes.ts index b3c9eaba..c08533b4 100644 --- a/packages/engine/src/annotations/notes.ts +++ b/packages/engine/src/annotations/notes.ts @@ -4,7 +4,9 @@ import type { MeasureTextFn, ResolvedTheme, } from '@opendata-ai/openchart-core'; -import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core'; +import { estimateTextWidth } from '@opendata-ai/openchart-core'; +import { parseAnnotationSpans } from './rich-text'; +import { wrapAnnotationText } from './wrap-text'; /** Resolve note lines once, so figure sizing, SVG rendering and export agree. */ export function wrapAnnotationNotes( @@ -16,24 +18,17 @@ export function wrapAnnotationNotes( const size = theme.fonts.sizes.small; const weight = theme.fonts.weights.normal; const maxWidth = Math.max(1, width - 2 * theme.spacing.padding); - const measure = (text: string) => - measureText?.(text, size, weight).width ?? estimateTextWidth(text, size, weight); + const measure = (text: string, style: { fontSize: number; fontWeight?: number | string }) => + measureText?.(text, style.fontSize, Number(style.fontWeight) || weight).width ?? + estimateTextWidth(text, style.fontSize, Number(style.fontWeight) || weight); return notes.map((note) => { - const text = `${note.index}. ${note.text}`; - const lines = wrapText(text, size, weight, maxWidth, measureText).flatMap((line) => { - // Word wrapping alone cannot contain an unbroken URL or identifier. - const chunks: string[] = []; - let chunk = ''; - for (const character of line) { - if (chunk && measure(chunk + character) > maxWidth) { - chunks.push(chunk); - chunk = character; - } else chunk += character; - } - chunks.push(chunk); - return chunks; - }); - return lines.length > 1 ? { ...note, lines } : note; + const lines = wrapAnnotationText( + `${note.index}. ${note.text}${note.subtitle ? `\n${note.subtitle}` : ''}`, + maxWidth, + { fontSize: size, fontWeight: weight }, + measure, + ); + return { ...note, lines, spans: lines.map(parseAnnotationSpans) }; }); } @@ -55,15 +50,14 @@ export function annotationNoteCapacity( ? [ { specIndex, - text: annotation.subtitle - ? `${annotation.text}. ${annotation.subtitle}` - : annotation.text, + text: annotation.text, + subtitle: annotation.subtitle, }, ] : [], ); - // Priority and placement determine the eventual numbering. Budget every source - // with the widest possible prefix, including proportional numeral fonts. + // Reserve the widest source ordinal so placement and drag changes cannot + // change capacity, including with proportional numeral fonts. const size = theme.fonts.sizes.small; const weight = theme.fonts.weights.normal; const measure = (text: string) => diff --git a/packages/engine/src/annotations/placement.ts b/packages/engine/src/annotations/placement.ts index d1105e0c..04c3ec0b 100644 --- a/packages/engine/src/annotations/placement.ts +++ b/packages/engine/src/annotations/placement.ts @@ -450,6 +450,7 @@ export function findBestPlacement( svgRect: Rect, measure: AnnotationMeasureTextFn = heuristicMeasure, debug = false, + connectorScore?: (x: number, y: number, anchor: 'start' | 'end') => number, ): PlacementResult { const isUpperHalf = anchorY < chartArea.y + chartArea.height / 2; const dirOrder = isUpperHalf ? UPPER_HALF_ORDER : LOWER_HALF_ORDER; @@ -466,7 +467,23 @@ export function findBestPlacement( }, measure, ); - const boxHeight = sampleBounds.height; + const subtitleSample = + subtitleText && subtitleStyle + ? computeTextBlockBounds( + 0, + subtitleBaselineY( + style.fontSize, + style.fontSize * style.lineHeight, + text.split('\n').length, + ), + subtitleText, + { ...subtitleStyle, textAnchor: 'start' }, + measure, + ) + : undefined; + const boxHeight = subtitleSample + ? unionRects(sampleBounds, subtitleSample).height + : sampleBounds.height; // Centering a `*-center` attach needs the width of the WHOLE block, not just // the primary line: a subtitle is often the wider of the two, and centering on @@ -566,7 +583,7 @@ export function findBestPlacement( box, directionIndex: di, ring: ring as 1 | 2 | 3, - score, + score: score + (connectorScore?.(labelX, labelY, dir.textAnchor) ?? 0), candidateIdx: idx, }); } @@ -603,6 +620,7 @@ export function findBestPlacement( ); return { ...breakdown!, + total: c.score, direction: dirNames[c.directionIndex], ring: c.ring, }; diff --git a/packages/engine/src/annotations/resolve-text.ts b/packages/engine/src/annotations/resolve-text.ts index c2d34b07..f643e148 100644 --- a/packages/engine/src/annotations/resolve-text.ts +++ b/packages/engine/src/annotations/resolve-text.ts @@ -28,7 +28,6 @@ import { DEFAULT_LINE_HEIGHT, DROP_LINE_TOP_GAP, FALLBACK_FONT_FAMILY, - LEDE_FONT_WEIGHT, LIGHT_CONNECTOR_STROKE, LIGHT_DOT_FILL, LIGHT_LABEL_BACKGROUND, @@ -123,7 +122,7 @@ function resolveConnectorConfig( if (typeof connector === 'object' && connector !== null && 'type' in connector) { const cfg = connector as ConnectorConfig; - const arrow = cfg.type === 'drop-line' ? false : (cfg.arrow ?? cfg.type === 'curve'); + const arrow = cfg.type === 'drop-line' ? false : (cfg.arrow ?? false); return { type: cfg.type, arrow, @@ -131,7 +130,7 @@ function resolveConnectorConfig( }; } - if (connector === 'curve') return { type: 'curve', arrow: true }; + if (connector === 'curve') return { type: 'curve', arrow: false }; if (connector === 'drop-line') return { type: 'drop-line', arrow: false }; // true, 'straight', or undefined all map to straight with no arrow @@ -215,13 +214,9 @@ export function makeAnnotationLabelStyle( }; } -/** - * The primary text's resolved weight. A subtitle turns the primary line into a - * lede, so it goes bold unless the author asked for a specific weight. - */ +/** Primary text stays regular unless the author supplies a weight. */ export function resolveLedeFontWeight(annotation: TextAnnotation): number | undefined { - if (annotation.fontWeight !== undefined) return annotation.fontWeight; - return annotation.subtitle ? LEDE_FONT_WEIGHT : undefined; + return annotation.fontWeight; } /** Resolve wrapped display geometry, retaining authored text as the identity. */ diff --git a/packages/engine/src/annotations/thinning.ts b/packages/engine/src/annotations/thinning.ts index 63739447..be755148 100644 --- a/packages/engine/src/annotations/thinning.ts +++ b/packages/engine/src/annotations/thinning.ts @@ -162,14 +162,15 @@ export function thinAnnotations( const overBudget = plotAreaSize > 0 && usedArea + candidateArea > coverageBudget; if (overLimit || overlaps || !fitsWithin(bounds, containment) || overBudget) { - const footnoteIndex = footnotes.length + 1; + const footnoteIndex = specs + .slice(0, (entry.resolved.specIndex ?? entry.index) + 1) + .filter((a) => a.type === 'text').length; footnotes.push({ ...(entry.resolved.specIndex !== undefined ? { specIndex: entry.resolved.specIndex } : {}), ...(entry.resolved.id !== undefined ? { id: entry.resolved.id } : {}), index: footnoteIndex, - text: entry.spec?.subtitle - ? `${entry.resolved.label!.text}. ${entry.spec.subtitle}` - : entry.resolved.label!.text, + text: entry.resolved.label!.text, + ...(entry.spec?.subtitle ? { subtitle: entry.spec.subtitle } : {}), }); entry.resolved.footnoteIndex = footnoteIndex; } else { @@ -178,7 +179,7 @@ export function thinAnnotations( } } - return { annotations, footnotes }; + return { annotations, footnotes: footnotes.sort((a, b) => a.index - b.index) }; } /** diff --git a/packages/engine/src/barlist/__tests__/compile-barlist.test.ts b/packages/engine/src/barlist/__tests__/compile-barlist.test.ts index 5db4e0c2..55a9c7d0 100644 --- a/packages/engine/src/barlist/__tests__/compile-barlist.test.ts +++ b/packages/engine/src/barlist/__tests__/compile-barlist.test.ts @@ -136,8 +136,13 @@ describe('compileBarList', () => { expect(() => compileBarList(spec, BASE_OPTIONS)).toThrow(/empty/i); }); - it('formats values with valueFormat', () => { - const spec = makeSpec({ valueFormat: '$,.0f' }); + it('formats values with encoding.value.format', () => { + const spec = makeSpec({ + encoding: { + ...makeSpec().encoding, + value: { ...makeSpec().encoding.value, format: '$,.0f' }, + }, + }); const layout = compileBarList(spec, BASE_OPTIONS); expect(layout.rows[0].formattedValue).toBe('$100'); }); @@ -148,7 +153,7 @@ describe('compileBarList', () => { { label: 'Big', count: 1500 }, { label: 'Small', count: 200 }, ], - valueFormat: '~s', + encoding: { ...makeSpec().encoding, value: { ...makeSpec().encoding.value, format: '~s' } }, }); const layout = compileBarList(spec, BASE_OPTIONS); expect(layout.rows[0].formattedValue).toBe('1.5k'); diff --git a/packages/engine/src/barlist/compile-barlist.ts b/packages/engine/src/barlist/compile-barlist.ts index 28835900..6e8b9894 100644 --- a/packages/engine/src/barlist/compile-barlist.ts +++ b/packages/engine/src/barlist/compile-barlist.ts @@ -181,7 +181,7 @@ export function compileBarList(spec: unknown, options: CompileOptions): BarListL // Value formatter: prefer encoding-level format (v8 canonical), fall back to deprecated valueFormat const formatter = resolveFieldFormatter({ - surfaceFormat: barlistSpec.encoding.value.format ?? barlistSpec.valueFormat, + surfaceFormat: barlistSpec.encoding.value.format, values: validRows.map((r) => r[valueField]), }); diff --git a/packages/engine/src/barlist/types.ts b/packages/engine/src/barlist/types.ts index 25d73c1f..d9583a36 100644 --- a/packages/engine/src/barlist/types.ts +++ b/packages/engine/src/barlist/types.ts @@ -5,7 +5,6 @@ import type { AnimationSpec, BarListEncoding, - DarkMode, DataRow, ThemeConfig, } from '@opendata-ai/openchart-core'; @@ -21,8 +20,6 @@ export interface NormalizedBarListSpec { maxItems: number; chrome: NormalizedChrome; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; animation?: AnimationSpec; - valueFormat?: string; } diff --git a/packages/engine/src/charts/line/__tests__/compute.test.ts b/packages/engine/src/charts/line/__tests__/compute.test.ts index 82130a50..f13deb91 100644 --- a/packages/engine/src/charts/line/__tests__/compute.test.ts +++ b/packages/engine/src/charts/line/__tests__/compute.test.ts @@ -5,7 +5,6 @@ import type { NormalizedChartSpec } from '../../../compiler/types'; import { computeScales } from '../../../layout/scales'; import { computeAreaMarks } from '../area'; import { computeLineMarks } from '../compute'; -import { computeLineLabels } from '../labels'; const DARK_THEME = adaptTheme(resolveTheme()); @@ -22,13 +21,6 @@ const fullStrategy: LayoutStrategy = { axisLabelDensity: 'full', }; -const compactStrategy: LayoutStrategy = { - labelMode: 'none', - legendPosition: 'top', - annotationPosition: 'tooltip-only', - axisLabelDensity: 'minimal', -}; - function makeSingleSeriesSpec(): NormalizedChartSpec { return { markType: 'line', @@ -882,121 +874,6 @@ describe('computeAreaMarks', () => { }); }); -// --------------------------------------------------------------------------- -// Label computation tests -// --------------------------------------------------------------------------- - -describe('computeLineLabels', () => { - it('produces labels for multi-series line marks', () => { - const spec = makeMultiSeriesSpec(); - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy); - - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, fullStrategy); - - expect(labelMap.size).toBe(2); - expect(labelMap.has('US')).toBe(true); - expect(labelMap.has('UK')).toBe(true); - }); - - it('labels are positioned at end of line', () => { - const spec = makeMultiSeriesSpec(); - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy); - - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, fullStrategy); - - for (const lineMark of lineMarks) { - if (!lineMark.seriesKey) continue; - const lastPoint = lineMark.points[lineMark.points.length - 1]; - const label = labelMap.get(lineMark.seriesKey); - expect(label).toBeDefined(); - // Label x should be to the right of the last point - expect(label!.x).toBeGreaterThan(lastPoint.x); - } - }); - - it('labels are visible at full width', () => { - const spec = makeMultiSeriesSpec(); - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy); - - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, fullStrategy); - - // At least some labels should be visible - const labels = Array.from(labelMap.values()); - expect(labels.some((l) => l.visible)).toBe(true); - }); - - it('labels collapse at compact breakpoint', () => { - const spec = makeMultiSeriesSpec(); - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, compactStrategy); - - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, compactStrategy); - - // At compact, no labels should be produced - expect(labelMap.size).toBe(0); - }); - - it('collision detection resolves overlapping labels', () => { - // Create a spec where series end at the same y position - const spec: NormalizedChartSpec = { - markType: 'line', - markDef: { type: 'line', point: true }, - data: [ - { date: '2020-01-01', value: 10, country: 'A' }, - { date: '2021-01-01', value: 30, country: 'A' }, - { date: '2020-01-01', value: 10, country: 'B' }, - { date: '2021-01-01', value: 30, country: 'B' }, - { date: '2020-01-01', value: 10, country: 'C' }, - { date: '2021-01-01', value: 30, country: 'C' }, - ], - encoding: { - x: { field: 'date', type: 'temporal' }, - y: { field: 'value', type: 'quantitative' }, - color: { field: 'country', type: 'nominal' }, - }, - chrome: {}, - annotations: [], - responsive: true, - theme: {}, - darkMode: 'off', - labels: { density: 'auto', format: '' }, - }; - - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy); - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, fullStrategy); - - expect(labelMap.size).toBe(3); - - // At least one label should be offset or demoted due to collision - const labels = Array.from(labelMap.values()); - const positions = labels.map((l) => `${l.x},${l.y}`); - const uniquePositions = new Set(positions); - // If collision worked, positions should differ even though anchor points are the same - expect(uniquePositions.size).toBeGreaterThanOrEqual(2); - }); - - it('skips single series with no seriesKey', () => { - const spec = makeSingleSeriesSpec(); - const scales = computeScales(spec, chartArea, spec.data); - const allMarks = computeLineMarks(spec, scales, chartArea, fullStrategy); - - const lineMarks = allMarks.filter((m): m is LineMark => m.type === 'line'); - const labelMap = computeLineLabels(lineMarks, fullStrategy); - - // Single series has no seriesKey, so no label - expect(labelMap.size).toBe(0); - }); -}); - // --------------------------------------------------------------------------- // seriesStyles tests // --------------------------------------------------------------------------- diff --git a/packages/engine/src/charts/line/__tests__/labels.test.ts b/packages/engine/src/charts/line/__tests__/labels.test.ts deleted file mode 100644 index 2ccb8ba4..00000000 --- a/packages/engine/src/charts/line/__tests__/labels.test.ts +++ /dev/null @@ -1,94 +0,0 @@ -import type { LayoutStrategy, LineMark } from '@opendata-ai/openchart-core'; -import { describe, expect, it } from 'vitest'; -import { computeLineLabels } from '../labels'; - -// --------------------------------------------------------------------------- -// Fixtures -// --------------------------------------------------------------------------- - -const fullStrategy: LayoutStrategy = { - labelMode: 'all', - legendPosition: 'right', - annotationPosition: 'inline', - axisLabelDensity: 'full', -}; - -const compactStrategy: LayoutStrategy = { - labelMode: 'none', - legendPosition: 'top', - annotationPosition: 'tooltip-only', - axisLabelDensity: 'minimal', -}; - -function makeLine(series: string, color: string, yOffset: number): LineMark { - return { - markType: 'line', - markDef: { type: 'line' }, - points: [ - { x: 50, y: 100 + yOffset }, - { x: 150, y: 80 + yOffset }, - { x: 250, y: 120 + yOffset }, - { x: 350, y: 60 + yOffset }, - ], - stroke: color, - strokeWidth: 2, - seriesKey: series, - data: [], - aria: { label: `${series} trend line` }, - }; -} - -const marks: LineMark[] = [ - makeLine('US', '#4e79a7', 0), - makeLine('UK', '#f28e2c', 30), - makeLine('Canada', '#e15759', 60), -]; - -// --------------------------------------------------------------------------- -// Tests -// --------------------------------------------------------------------------- - -describe('computeLineLabels density modes', () => { - it('density "auto" produces labels with collision detection', () => { - const labelMap = computeLineLabels(marks, fullStrategy, 'auto'); - expect(labelMap.size).toBeGreaterThan(0); - // Each label should have a visibility flag - for (const label of labelMap.values()) { - expect(typeof label.visible).toBe('boolean'); - } - }); - - it('density "all" shows every series label as visible', () => { - const labelMap = computeLineLabels(marks, fullStrategy, 'all'); - expect(labelMap.size).toBe(3); - expect(labelMap.has('US')).toBe(true); - expect(labelMap.has('UK')).toBe(true); - expect(labelMap.has('Canada')).toBe(true); - for (const label of labelMap.values()) { - expect(label.visible).toBe(true); - } - }); - - it('density "none" returns empty map', () => { - const labelMap = computeLineLabels(marks, fullStrategy, 'none'); - expect(labelMap.size).toBe(0); - }); - - it('density "endpoints" works like auto for line charts', () => { - const autoMap = computeLineLabels(marks, fullStrategy, 'auto'); - const endpointsMap = computeLineLabels(marks, fullStrategy, 'endpoints'); - // Line labels are already endpoint labels (end-of-line), so same behavior - expect(endpointsMap.size).toBe(autoMap.size); - }); - - it('compact strategy suppresses labels regardless of density', () => { - const labelMap = computeLineLabels(marks, compactStrategy, 'all'); - expect(labelMap.size).toBe(0); - }); - - it('default density is "auto"', () => { - const withAuto = computeLineLabels(marks, fullStrategy, 'auto'); - const withDefault = computeLineLabels(marks, fullStrategy); - expect(withDefault.size).toBe(withAuto.size); - }); -}); diff --git a/packages/engine/src/charts/line/index.ts b/packages/engine/src/charts/line/index.ts index 9bec2287..d027d2fc 100644 --- a/packages/engine/src/charts/line/index.ts +++ b/packages/engine/src/charts/line/index.ts @@ -17,7 +17,6 @@ import type { ChartRenderer } from '../registry'; import { resolveKnockoutColor } from '../utils'; import { computeAreaMarks } from './area'; import { computeLineMarks } from './compute'; -import { computeLineLabels } from './labels'; /** Radius for area-chart data points, matching the line renderer. */ const AREA_POINT_RADIUS = 3; @@ -35,28 +34,6 @@ const AREA_POINT_RADIUS = 3; export const lineRenderer: ChartRenderer = (spec, scales, chartArea, strategy, theme) => { const marks = computeLineMarks(spec, scales, chartArea, strategy, theme); - // Extract just the line marks for label computation - const lineMarks = marks.filter((m): m is LineMark => m.type === 'line'); - - // Compute and attach labels to line marks by seriesKey lookup. Passing the - // spec engages the shared suppression truth table so end-of-line labels - // hide when the legend or endpoint column is showing. - const labelMap = computeLineLabels( - lineMarks, - strategy, - spec.labels.density, - spec.labels.offsets, - spec, - ); - for (const mark of marks) { - if (mark.type === 'line' && mark.seriesKey) { - const label = labelMap.get(mark.seriesKey); - if (label) { - mark.label = label; - } - } - } - return marks as Mark[]; }; @@ -181,4 +158,3 @@ function pointsFromAreas( export { computeAreaMarks } from './area'; export { computeLineMarks } from './compute'; -export { computeLineLabels } from './labels'; diff --git a/packages/engine/src/charts/line/labels.ts b/packages/engine/src/charts/line/labels.ts deleted file mode 100644 index 29596988..00000000 --- a/packages/engine/src/charts/line/labels.ts +++ /dev/null @@ -1,177 +0,0 @@ -/** - * Line chart label computation. - * - * Produces end-of-line labels (series name at the last data point) - * and feeds them through the core collision engine. At compact - * breakpoints, labels are suppressed in favor of the legend. - * - * Respects the spec's label density setting: - * - 'all': show every label, skip collision detection - * - 'auto': existing behavior (collision detection) - * - 'endpoints': first and last data point labels per series - * - 'none': return empty map - */ - -import type { - LabelCandidate, - LabelDensity, - LayoutStrategy, - LineMark, - ResolvedLabel, -} from '@opendata-ai/openchart-core'; -import { - EXTENDED_OFFSET_STRATEGIES, - estimateTextWidth, - resolveCollisions, -} from '@opendata-ai/openchart-core'; - -import type { NormalizedChartSpec } from '../../compiler/types'; -import { countColorSeries, resolveSuppression } from '../../legend/suppression'; - -// --------------------------------------------------------------------------- -// Constants -// --------------------------------------------------------------------------- - -/** Default label font size. */ -const LABEL_FONT_SIZE = 11; - -/** Default label font weight. */ -const LABEL_FONT_WEIGHT = 600; - -/** Horizontal offset from last point to label. */ -const LABEL_OFFSET_X = 6; - -// --------------------------------------------------------------------------- -// Public API -// --------------------------------------------------------------------------- - -/** - * Compute end-of-line labels for line marks. - * - * For each series, places a label at the position of the last data point. - * At compact breakpoints (labelMode === 'none'), all labels are hidden - * so the legend takes over. Labels go through collision detection to - * avoid overlap. - * - * Returns a Map keyed by seriesKey so callers can look up labels by - * mark identity instead of relying on positional indices. - * - * @param marks - Line marks (only processes marks with type === 'line'). - * @param strategy - Layout strategy from the responsive breakpoint. - * @param density - Label density mode from spec.labels.density. - * @param labelOffsets - Per-series user offsets applied after collision resolution. - * @param spec - Optional normalized spec. When provided, the shared suppression - * truth table gates rendering: end-of-line labels are the last-resort series - * identifier and only render when both the traditional legend and endpoint - * labels column are off. - * @returns Map of seriesKey -> ResolvedLabel after collision detection. - */ -export function computeLineLabels( - marks: LineMark[], - strategy: LayoutStrategy, - density: LabelDensity = 'auto', - labelOffsets?: Record, - spec?: NormalizedChartSpec, -): Map { - const result = new Map(); - - // 'none': no labels - if (density === 'none') return result; - - // At compact breakpoint, suppress inline labels entirely - if (strategy.labelMode === 'none') { - return result; - } - - // Suppression truth table: when the spec is available, defer to the shared - // helper. End-of-line labels render only when both the legend and endpoint - // column are off — they're the last-resort series identifier. - if (spec) { - // The 'none' branches above already returned, so by here labelMode and - // density are not 'none'. Pass false explicitly to keep the helper pure. - const suppression = resolveSuppression(spec, { - seriesCount: countColorSeries(spec), - labelsHiddenByStrategy: false, - labelsDensityNone: false, - }); - if (!suppression.showEndOfLineLabels) { - // Single-series charts have no series-name end-of-line label to hide - // (computeLineLabels returns nothing when seriesKey is empty). For - // multi-series, the suppression result tells us to skip explicitly. - const isMultiSeries = !!spec.encoding.color && countColorSeries(spec) >= 2; - if (isMultiSeries) return result; - } - } - - const candidates: LabelCandidate[] = []; - const seriesOrder: string[] = []; - - for (const mark of marks) { - if (mark.points.length === 0) continue; - - const labelText = mark.seriesKey ?? ''; - if (!labelText) continue; - - const lastPoint = mark.points[mark.points.length - 1]; - const textWidth = estimateTextWidth(labelText, LABEL_FONT_SIZE, LABEL_FONT_WEIGHT); - const textHeight = LABEL_FONT_SIZE * 1.2; - - candidates.push({ - text: labelText, - anchorX: lastPoint.x + LABEL_OFFSET_X, - anchorY: lastPoint.y - textHeight / 2, - width: textWidth, - height: textHeight, - priority: 'data', - style: { - fontFamily: 'system-ui, -apple-system, sans-serif', - fontSize: LABEL_FONT_SIZE, - fontWeight: LABEL_FONT_WEIGHT, - fill: mark.stroke, - lineHeight: 1.2, - textAnchor: 'start', - dominantBaseline: 'central', - }, - }); - - seriesOrder.push(labelText); - } - - if (candidates.length === 0) return result; - - // 'all': skip collision detection, mark everything visible - if (density === 'all') { - for (let i = 0; i < candidates.length; i++) { - const c = candidates[i]; - const seriesKey = seriesOrder[i]; - const userOffset = labelOffsets?.[seriesKey]; - result.set(seriesKey, { - text: c.text, - x: c.anchorX + (userOffset?.dx ?? 0), - y: c.anchorY + (userOffset?.dy ?? 0), - style: c.style, - visible: true, - }); - } - return result; - } - - // 'endpoints': for line charts, endpoints means showing just the end-of-line - // label (which is what we already compute). This is the same as 'auto' for lines - // since we only compute the endpoint label per series. - - const resolved = resolveCollisions(candidates, EXTENDED_OFFSET_STRATEGIES); - for (let i = 0; i < resolved.length; i++) { - const seriesKey = seriesOrder[i]; - const label = resolved[i]; - // Apply user-provided per-series label offset after collision resolution - const userOffset = labelOffsets?.[seriesKey]; - if (userOffset) { - label.x += userOffset.dx ?? 0; - label.y += userOffset.dy ?? 0; - } - result.set(seriesKey, label); - } - - return result; -} diff --git a/packages/engine/src/charts/waffle/__tests__/compute.test.ts b/packages/engine/src/charts/waffle/__tests__/compute.test.ts index c3384318..7fbd3285 100644 --- a/packages/engine/src/charts/waffle/__tests__/compute.test.ts +++ b/packages/engine/src/charts/waffle/__tests__/compute.test.ts @@ -31,6 +31,7 @@ function makeWaffleSpec(overrides: Partial = {}): Normalize markDef: { type: 'waffle' }, data: [...energyMix], encoding: { + // Normalized renderers consume the internal theta-to-y mapping. y: { field: 'share', type: 'quantitative' }, color: { field: 'source', type: 'nominal' }, }, @@ -320,7 +321,7 @@ describe('waffle "other" bucketing (opt-in)', () => { mark, data: longTail, encoding: { - y: { field: 'share', type: 'quantitative' }, + theta: { field: 'share', type: 'quantitative' }, color: { field: 'source', type: 'nominal' }, }, }, @@ -379,7 +380,7 @@ describe('waffle "other" bucketing (opt-in)', () => { { source: 'Tidal', share: 2 }, ], encoding: { - y: { field: 'share', type: 'quantitative' }, + theta: { field: 'share', type: 'quantitative' }, color: { field: 'source', type: 'nominal' }, }, }, diff --git a/packages/engine/src/compile.ts b/packages/engine/src/compile.ts index 84e61cf8..328d9a45 100644 --- a/packages/engine/src/compile.ts +++ b/packages/engine/src/compile.ts @@ -1,4 +1,5 @@ import { mergeThemeConfig } from '@opendata-ai/openchart-core'; +import { seriesIdentificationWarnings } from './compiler/a11y-warnings'; import { diagnoseChart, noteCapacityDiagnostic } from './design/diagnostics'; /** * Main compile API: the public entry points for the engine. @@ -133,7 +134,7 @@ function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions { width: typeof sized.width === 'number' ? sized.width : options.width, height: typeof sized.height === 'number' && - !(options?.facetHeightGrown && (sized.chromeLayout ?? options.chromeLayout) === 'grow') + !(options?.facetHeightGrown && (sized.chromeLayout ?? options.chromeLayout) !== 'subtract') ? sized.height : options.height, }; @@ -602,6 +603,8 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char theme = adaptTheme(theme); } + emitSpecWarnings(seriesIdentificationWarnings(chartSpec), options.onWarn); + // Reserve from source text before placement. A drag can pin a label and change // the demoted set; neither the plot nor the SVG clamp may move at commit. // Include pinned sources because fresh compiles cannot distinguish authored @@ -878,13 +881,6 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char ? renderer(renderSpec, scales, chartArea, strategy, theme, options.width) : []; - if (marks.some((mark) => mark.type === 'line' && mark.label?.visible)) { - emitSpecWarnings( - ['[openchart] Legacy end-of-line labels are deprecated; use endpointLabels.'], - options.onWarn, - ); - } - // Opt-in per-series fill patterns (mark.fillPattern: 'auto'). Mutates // filled marks in place; assignment is deterministic and theme-aware. applyFillPatterns(marks, chartSpec.markDef, theme); @@ -1003,6 +999,8 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char measure: annotationMeasure, fontFamily: theme.fonts.family, colors: theme.colors, + padding: theme.spacing.padding, + smallFontSize: theme.fonts.sizes.small, onWarn: (message) => emitSpecWarnings([message], options.onWarn), autoThin: chartSpec.autoThin, // Hand-placed blocks skip every nudge pass, so the clamp is the only lever @@ -1512,6 +1510,8 @@ function compileFaceted( measure: annotationMeasure, fontFamily: theme.fonts.family, colors: theme.colors, + padding: theme.spacing.padding, + smallFontSize: theme.fonts.sizes.small, onWarn: (message) => emitSpecWarnings([message], options.onWarn), autoThin: panelSpecWithDomains.autoThin, topBound: dims.chrome.topHeight > 0 ? dims.topPad + dims.chrome.topHeight : undefined, @@ -1540,32 +1540,13 @@ function compileFaceted( options.width < 400 && options.height >= 200 ? chartSpec.compactAnnotationLimit : undefined, ); panelAnnotations = thinned.annotations; - // `thinAnnotations` numbers footnotes 1..n *within the panel it was given*, - // so restamp each demoted annotation with its figure-level number. Take the - // text from `thinned.footnotes` (the 1-based panel-local index it just - // assigned) rather than re-reading `label.text`: thinning owns that string, - // and re-deriving it here would be a second producer to drift out of sync. - // - // Key on `specIndex`, NOT on position: with `resolve.scale.x: 'independent'` - // each panel derives its own domain, so an annotation can resolve in one - // panel and be dropped in another. Position i then means a different spec - // annotation in each panel, and the markers point at other panels' text. - for (const a of panelAnnotations) { - const panelIndex = a.footnoteIndex; - if (panelIndex == null || a.specIndex === undefined) continue; - let num = footnoteNumberBySpecIndex.get(a.specIndex); - if (num === undefined) { - num = allFootnotes.length + 1; - footnoteNumberBySpecIndex.set(a.specIndex, num); - allFootnotes.push({ - specIndex: a.specIndex, - ...(a.id !== undefined ? { id: a.id } : {}), - index: num, - text: thinned.footnotes[panelIndex - 1]?.text ?? '', - }); - } - a.footnoteIndex = num; + // Every facet instance shares its source text ordinal and a single note. + for (const note of thinned.footnotes) { + if (note.specIndex === undefined || footnoteNumberBySpecIndex.has(note.specIndex)) continue; + footnoteNumberBySpecIndex.set(note.specIndex, note.index); + allFootnotes.push(note); } + allFootnotes.sort((a, b) => a.index - b.index); } allMarks.push(...panelMarks); @@ -1610,13 +1591,6 @@ function compileFaceted( // Compute tooltip descriptors from all marks across panels const tooltipDescriptors = computeTooltipDescriptors(panelChartSpec, allMarks); - if (allMarks.some((mark) => mark.type === 'line' && mark.label?.visible)) { - emitSpecWarnings( - ['[openchart] Legacy end-of-line labels are deprecated; use endpointLabels.'], - options.onWarn, - ); - } - // Opt-in per-series fill patterns across all panels at once so a series // gets the same pattern in every panel. applyFillPatterns(allMarks, chartSpec.markDef, theme); diff --git a/packages/engine/src/compile/__tests__/fill-patterns.test.ts b/packages/engine/src/compile/__tests__/fill-patterns.test.ts index 85273cc1..480764d4 100644 --- a/packages/engine/src/compile/__tests__/fill-patterns.test.ts +++ b/packages/engine/src/compile/__tests__/fill-patterns.test.ts @@ -138,7 +138,7 @@ describe('fill patterns on arcs', () => { ], encoding: { color: { field: 'browser', type: 'nominal' }, - y: { field: 'share', type: 'quantitative' }, + theta: { field: 'share', type: 'quantitative' }, }, }; const layout = compileChart(spec, OPTS); diff --git a/packages/engine/src/compile/layer.ts b/packages/engine/src/compile/layer.ts index 424f2b1e..2b9df1e8 100644 --- a/packages/engine/src/compile/layer.ts +++ b/packages/engine/src/compile/layer.ts @@ -146,7 +146,6 @@ export function compileLayer( const themedLeaf = { ...withSharedDomains(leaf as ChartSpec, sharedDomains), theme: mergeThemeConfig(spec.theme, (leaf as ChartSpec).theme), - darkMode: (leaf as ChartSpec).darkMode ?? spec.darkMode, }; const leafLayout = compileChart(themedLeaf as unknown, leafOptions); @@ -361,7 +360,7 @@ function compileLayerIndependent( endpointWidth - legendReserve - referenceReserve.right; - const grow = (primary0.chromeLayout ?? options.chromeLayout) === 'grow'; + const grow = (primary0.chromeLayout ?? options.chromeLayout) !== 'subtract'; const bottom = grow ? layout0.area.y + layout0.area.height : Math.min(layout0.area.y + layout0.area.height, layout1.area.y + layout1.area.height); @@ -1057,7 +1056,6 @@ export function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): Cha legend: layerSpec.legend ?? leaves[0].legend, responsive: layerSpec.responsive ?? leaves[0].responsive, theme: mergeThemeConfig(layerSpec.theme, leaves[0].theme), - darkMode: layerSpec.darkMode ?? leaves[0].darkMode, watermark: layerSpec.watermark ?? leaves[0].watermark, hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries, endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels, diff --git a/packages/engine/src/compile/spec-sugar.ts b/packages/engine/src/compile/spec-sugar.ts index 0540aad5..0f11b8f6 100644 --- a/packages/engine/src/compile/spec-sugar.ts +++ b/packages/engine/src/compile/spec-sugar.ts @@ -1,4 +1,5 @@ import { assertMarkOptions } from '../compiler/mark-options'; +import { collectRemovedSpecErrors } from '../compiler/removed-spec-fields'; /** * Pre-validation spec sugar expansion. * @@ -260,25 +261,6 @@ function stripIgnoredKeys( return rest; } -/** - * `mark.render` was removed in v8: backend selection is host policy, passed as - * the `renderer` compile/mount option (mirroring vega-embed). Warned once and - * stripped so pre-v8 specs keep compiling. - */ -function stripRemovedMarkRender( - spec: Record, - warnings: string[], -): Record { - const mark = spec.mark; - if (!mark || typeof mark !== 'object' || Array.isArray(mark)) return spec; - if (!('render' in mark)) return spec; - warnings.push( - '[openchart] mark.render was removed in v8. This field is stripped for backward compatibility. Pass { renderer } to createChart() or the compile options instead.', - ); - const { render: _render, ...restMark } = mark as Record; - return { ...spec, mark: restMark }; -} - /** * Fixed-size specs (both `width` and `height` set) imply `responsive: false` * unless the user set `responsive` explicitly. The width/height override @@ -295,46 +277,6 @@ function applyFixedSizeDefault(spec: Record): Record, - warnings: string[], -): Record { - const annotations = spec.annotations; - if (!Array.isArray(annotations) || annotations.length === 0) return spec; - - let changed = false; - const updated = annotations.map((ann) => { - if (ann && typeof ann === 'object' && (ann as Record).type === 'rule') { - warnings.push("[openchart] annotation type 'rule' is deprecated; use 'refline'."); - changed = true; - return { ...(ann as Record), type: 'refline' }; - } - return ann; - }); - - return changed ? { ...spec, annotations: updated } : spec; -} - -// --------------------------------------------------------------------------- -// Channel-level sugar (value defs, legend, axis null, scheme, theta, dead channels) -// --------------------------------------------------------------------------- - -/** Channels declared in the spec types with zero engine implementation, warned and stripped. */ -const DEAD_CHANNEL_MESSAGES: Record = { - radius: - '[openchart] encoding.radius was removed in v8. This channel is stripped for backward compatibility. Use mark.innerRadius / mark.outerRadius to control donut radii.', - shape: - '[openchart] encoding.shape was removed in v8. This channel is stripped for backward compatibility. Differentiate series with encoding.color or encoding.strokeDash instead.', - href: '[openchart] encoding.href was removed in v8. This channel is stripped for backward compatibility. Handle link navigation in the host application instead.', - order: - '[openchart] encoding.order was removed in v8. This channel is stripped for backward compatibility. Use encoding..sort or pre-sorted data order instead.', -}; - /** * Expand channel-level VL sugar. Mark-dependent sugar (value defs, theta) only * runs when the spec has a mark, so the same function works on the shared @@ -451,12 +393,6 @@ function expandChannelSugar( } updated.y = updated.theta; changed = true; - } else if (thetaMark && hasY && !hasTheta) { - updated.theta = updated.y; - warnings.push( - `[openchart] encoding.y on ${markType} marks is deprecated in v8; use encoding.theta for the value channel.`, - ); - changed = true; } else if (hasTheta && !thetaMark) { warnings.push( '[openchart] encoding.theta is only meaningful on arc, waffle, and parliament marks and was ignored.', @@ -481,15 +417,6 @@ function expandChannelSugar( } } - // Dead channels: warn once and strip (behavior-identical; the engine ignores them) - for (const channel of Object.keys(DEAD_CHANNEL_MESSAGES)) { - if (updated[channel] !== undefined) { - warnings.push(DEAD_CHANNEL_MESSAGES[channel]); - delete updated[channel]; - changed = true; - } - } - if (!changed) return spec; const result: Record = { ...spec, encoding: updated }; @@ -913,9 +840,7 @@ function expandChartSugar( out = expandTopLevelTitle(out); out = expandDescriptionSugar(out); out = stripIgnoredKeys(out, warnings); - out = stripRemovedMarkRender(out, warnings); out = applyFixedSizeDefault(out); - out = expandAnnotationSugar(out, warnings); out = expandChannelSugar(out, warnings); out = expandHistogramMark(out); out = expandDensityMark(out); @@ -935,7 +860,6 @@ function expandLayerSugar( let out = unwrapDataValues(spec); out = expandTopLevelTitle(out); out = stripIgnoredKeys(out, warnings); - out = expandAnnotationSugar(out, warnings); // Shared encoding gets the mark-independent channel sugar (axis, scheme, // legend, dead channels) and sort resolution; mark-dependent sugar (value // defs, theta) resolves per leaf after encoding inheritance. @@ -977,11 +901,11 @@ function expandValueFormatSugar( warnings: string[], ): Record { const type = spec.type as string | undefined; - if (type !== 'sankey' && type !== 'tilemap' && type !== 'barlist' && type !== 'map') return spec; + if (type !== 'tilemap') return spec; if (typeof spec.valueFormat !== 'string') return spec; const encoding = spec.encoding as Record | undefined; - const channelKey = type === 'map' ? 'color' : 'value'; + const channelKey = 'value'; const valueCh = encoding?.[channelKey] as Record | undefined; // Only expand into encoding when encoding.value already exists (the shorthand @@ -1021,6 +945,8 @@ export function expandSpecSugar( checkMarkOptions = true, ): Record { if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return spec; + const removed = collectRemovedSpecErrors(spec); + if (removed.length) throw new Error(removed.map((error) => error.message).join('\n')); if (checkMarkOptions) assertMarkOptions(spec); if (Array.isArray(spec.layer) && typeof spec.type !== 'string') { return expandLayerSugar(spec, warnings); diff --git a/packages/engine/src/compiler/__tests__/compile.test.ts b/packages/engine/src/compiler/__tests__/compile.test.ts index 27d9105a..8f97b98e 100644 --- a/packages/engine/src/compiler/__tests__/compile.test.ts +++ b/packages/engine/src/compiler/__tests__/compile.test.ts @@ -27,7 +27,7 @@ describe('compile (validate + normalize pipeline)', () => { const result = compile(validSpec); const spec = result.spec as NormalizedChartSpec; expect(spec.responsive).toBe(true); - expect(spec.darkMode).toBe('off'); + expect(spec).not.toHaveProperty('darkMode'); expect(spec.annotations).toEqual([]); }); diff --git a/packages/engine/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json b/packages/engine/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json index 70c4ebfa..c0be27da 100644 --- a/packages/engine/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json +++ b/packages/engine/src/compiler/__tests__/fixtures/vl-parity/layer-theta.json @@ -1,26 +1,61 @@ { "layered": true, - "options": { "width": 600, "height": 400 }, + "options": { + "width": 600, + "height": 400 + }, "vl": { - "data": [{ "category": "A", "amount": 30 }, { "category": "B", "amount": 70 }], + "data": { + "values": [ + { + "category": "A", + "amount": 30 + }, + { + "category": "B", + "amount": 70 + } + ] + }, "layer": [ { "mark": "arc", "encoding": { - "theta": { "field": "amount", "type": "quantitative" }, - "color": { "field": "category", "type": "nominal" } + "theta": { + "field": "amount", + "type": "quantitative" + }, + "color": { + "field": "category", + "type": "nominal" + } } } ] }, "canonical": { - "data": [{ "category": "A", "amount": 30 }, { "category": "B", "amount": 70 }], + "data": [ + { + "category": "A", + "amount": 30 + }, + { + "category": "B", + "amount": 70 + } + ], "layer": [ { "mark": "arc", "encoding": { - "y": { "field": "amount", "type": "quantitative" }, - "color": { "field": "category", "type": "nominal" } + "color": { + "field": "category", + "type": "nominal" + }, + "theta": { + "field": "amount", + "type": "quantitative" + } } } ] diff --git a/packages/engine/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json b/packages/engine/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json index ac5e7f4e..83fe4783 100644 --- a/packages/engine/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json +++ b/packages/engine/src/compiler/__tests__/fixtures/vl-parity/theta-arc.json @@ -1,19 +1,54 @@ { - "options": { "width": 600, "height": 400 }, + "options": { + "width": 600, + "height": 400 + }, "vl": { "mark": "arc", - "data": [{ "category": "A", "amount": 30 }, { "category": "B", "amount": 70 }], + "data": { + "values": [ + { + "category": "A", + "amount": 30 + }, + { + "category": "B", + "amount": 70 + } + ] + }, "encoding": { - "theta": { "field": "amount", "type": "quantitative" }, - "color": { "field": "category", "type": "nominal" } + "theta": { + "field": "amount", + "type": "quantitative" + }, + "color": { + "field": "category", + "type": "nominal" + } } }, "canonical": { "mark": "arc", - "data": [{ "category": "A", "amount": 30 }, { "category": "B", "amount": 70 }], + "data": [ + { + "category": "A", + "amount": 30 + }, + { + "category": "B", + "amount": 70 + } + ], "encoding": { - "y": { "field": "amount", "type": "quantitative" }, - "color": { "field": "category", "type": "nominal" } + "color": { + "field": "category", + "type": "nominal" + }, + "theta": { + "field": "amount", + "type": "quantitative" + } } } } diff --git a/packages/engine/src/compiler/__tests__/mark-render-mode.test.ts b/packages/engine/src/compiler/__tests__/mark-render-mode.test.ts index d22d1ba6..519ef784 100644 --- a/packages/engine/src/compiler/__tests__/mark-render-mode.test.ts +++ b/packages/engine/src/compiler/__tests__/mark-render-mode.test.ts @@ -352,22 +352,18 @@ describe('compileChart markRenderMode', () => { expect(denseWarnings).toEqual([]); }); - it('warns on and strips the removed mark.render spec field', () => { + it('rejects the removed mark.render field before choosing a backend', () => { const onWarn = vi.fn(); - const layout = compileChart( - { - // Pre-v8 spec shape: render lived on the mark def. Sugar strips it - // with a migration warning; it no longer selects the backend. - mark: { type: 'point', render: 'canvas' } as never, - data: SCATTER_DATA, - encoding: SCATTER_ENCODING, - }, - { width: 600, height: 400, onWarn }, - ); - expect('markRenderMode' in layout).toBe(false); - const stripWarnings = onWarn.mock.calls.filter((c) => - String(c[0]).includes('mark.render was removed'), - ); - expect(stripWarnings).toHaveLength(1); + expect(() => + compileChart( + { + mark: { type: 'point', render: 'canvas' }, + data: SCATTER_DATA, + encoding: SCATTER_ENCODING, + }, + { width: 600, height: 400, onWarn }, + ), + ).toThrow(/mark.render.*renderer mount option/); + expect(onWarn).not.toHaveBeenCalled(); }); }); diff --git a/packages/engine/src/compiler/__tests__/normalize.test.ts b/packages/engine/src/compiler/__tests__/normalize.test.ts index 06b8ec3a..28762535 100644 --- a/packages/engine/src/compiler/__tests__/normalize.test.ts +++ b/packages/engine/src/compiler/__tests__/normalize.test.ts @@ -46,14 +46,13 @@ describe('normalizeSpec', () => { const result = normalizeSpec(lineSpec, warnings) as NormalizedChartSpec; expect(result.responsive).toBe(true); - expect(result.darkMode).toBe('off'); + expect(result).not.toHaveProperty('darkMode'); expect(result.annotations).toEqual([]); expect(result.theme).toEqual({}); expect(result.labels).toEqual({ density: 'auto', format: '', prefix: '', - offsets: undefined, }); }); @@ -72,12 +71,12 @@ describe('normalizeSpec', () => { const spec: ChartSpec = { ...lineSpec, responsive: false, - darkMode: 'force', + chromeLayout: 'subtract', }; const result = normalizeSpec(spec) as NormalizedChartSpec; expect(result.responsive).toBe(false); - expect(result.darkMode).toBe('force'); + expect(result.chromeLayout).toBe('subtract'); }); it('normalizes chrome strings to ChromeText objects', () => { @@ -164,7 +163,6 @@ describe('normalizeSpec', () => { density: 'none', format: ',.0f', prefix: '', - offsets: undefined, }); }); @@ -178,7 +176,6 @@ describe('normalizeSpec', () => { density: 'endpoints', format: '', prefix: '', - offsets: undefined, }); }); @@ -238,9 +235,10 @@ describe('normalizeSpec', () => { expect(result.search).toBe(false); expect(result.pagination).toBe(false); expect(result.stickyFirstColumn).toBe(false); - expect(result.compact).toBe(false); + expect(result.density).toBe('regular'); + expect(result).not.toHaveProperty('compact'); expect(result.responsive).toBe(true); - expect(result.darkMode).toBe('off'); + expect(result).not.toHaveProperty('darkMode'); }); it('watermark defaults to true when not specified', () => { @@ -280,7 +278,7 @@ describe('normalizeSpec', () => { // -300), so normalize no longer injects them. expect(result.layout).toEqual({ type: 'force' }); expect(result.annotations).toEqual([]); - expect(result.darkMode).toBe('off'); + expect(result).not.toHaveProperty('darkMode'); }); it('watermark defaults to true when not specified', () => { diff --git a/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts b/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts new file mode 100644 index 00000000..a5a36e36 --- /dev/null +++ b/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts @@ -0,0 +1,45 @@ +import { expect, it } from 'vitest'; +import { compileChart } from '../../compile'; +import { migrateSpecV8 } from '../../migration/v8'; +import { validateSpec } from '../validate'; + +const base = { + mark: 'point', + data: [{ x: 1, y: 2 }], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, +}; +it('rejects retired breakpoint annotations even when another breakpoint is active', () => { + const spec = { + ...base, + overrides: { compact: { annotations: [{ type: 'rule', y: 2 }], animation: 'legacy' } }, + }; + expect(validateSpec(spec).errors.map((error) => error.path)).toContain( + 'overrides.compact.annotations[0].type', + ); + expect(() => compileChart(spec, { width: 800, height: 400 })).toThrow(/removed in v9/); + const migrated = migrateSpecV8(spec); + expect(migrated.spec.overrides.compact.annotations[0].type).toBe('refline'); + expect(migrated.spec.overrides.compact.animation).toBe(true); + expect(migrated.repairs.map((repair) => repair.path)).toContain( + '/overrides/compact/annotations/0/type', + ); + for (const width of [390, 800]) + expect(() => compileChart(migrated.spec, { width, height: 400 })).not.toThrow(); + expect(migrateSpecV8(migrated.spec).repairs).toEqual([]); +}); +it.each([ + { darkMode: 'off' }, + { labels: { offsets: {} } }, + { animation: 'legacy' }, + { mark: { type: 'point', render: 'canvas' } }, + { annotations: [{ type: 'rule', y: 2 }] }, + { theme: { chrome: { title: '#000000' } } }, + ...['radius', 'shape', 'href', 'order'].map((key) => ({ + encoding: { ...base.encoding, [key]: { field: 'x' } }, + })), +])('rejects retired forms and accepts their migrated result: %j', (patch) => { + const spec = { ...base, ...patch }; + expect(validateSpec(spec).valid).toBe(false); + expect(() => compileChart(spec, { width: 600, height: 400 })).toThrow(/removed in v9/); + expect(() => compileChart(migrateSpecV8(spec).spec, { width: 600, height: 400 })).not.toThrow(); +}); diff --git a/packages/engine/src/compiler/__tests__/validate.test.ts b/packages/engine/src/compiler/__tests__/validate.test.ts index e5ad4312..1d3cfe1c 100644 --- a/packages/engine/src/compiler/__tests__/validate.test.ts +++ b/packages/engine/src/compiler/__tests__/validate.test.ts @@ -43,7 +43,7 @@ const validArcSpec = { { label: 'Bananas', amount: 20 }, ], encoding: { - y: { field: 'amount', type: 'quantitative' }, + theta: { field: 'amount', type: 'quantitative' }, color: { field: 'label', type: 'nominal' }, }, }; @@ -244,16 +244,23 @@ describe('validateSpec', () => { expect(numError!.suggestion).toContain('nominal'); }); - it('rejects invalid darkMode with INVALID_VALUE code', () => { + it.each([ + 'auto', + 'force', + 'off', + 'maybe', + ])('rejects removed spec darkMode %s with a migration instruction', (darkMode) => { const result = validateSpec({ ...validLineSpec, - darkMode: 'maybe', + darkMode, }); expect(result.valid).toBe(false); const dmError = result.errors.find((e) => e.message.includes('darkMode')); expect(dmError).toBeDefined(); expect(dmError!.code).toBe('INVALID_VALUE'); - expect(dmError!.suggestion).toContain('auto'); + expect(dmError!.path).toBe('darkMode'); + expect(dmError!.message).toContain('mount option'); + expect(dmError!.suggestion).toContain('migrateSpecV8'); }); it('rejects missing encoding channel field with MISSING_FIELD code', () => { diff --git a/packages/engine/src/compiler/a11y-warnings.ts b/packages/engine/src/compiler/a11y-warnings.ts index 67d4d28d..2d58bb11 100644 --- a/packages/engine/src/compiler/a11y-warnings.ts +++ b/packages/engine/src/compiler/a11y-warnings.ts @@ -108,3 +108,21 @@ export function collectContrastWarnings( return warnings; } + +/** Warn when a multi-series author deliberately removes both identification surfaces. */ +export function seriesIdentificationWarnings( + spec: import('./types').NormalizedChartSpec, +): string[] { + if ((spec.markType !== 'line' && spec.markType !== 'area') || spec.legend?.show !== false) + return []; + const hidden = + spec.endpointLabels === false || + (typeof spec.endpointLabels === 'object' && spec.endpointLabels.show === false); + const color = spec.encoding.color; + if (!hidden || !color || !('field' in color) || color.type === 'quantitative') return []; + return new Set(spec.data.map((row) => row[color.field])).size > 1 + ? [ + '[openchart a11y] Both legend and endpointLabels are disabled on a multi-series chart. Enable either to identify the series.', + ] + : []; +} diff --git a/packages/engine/src/compiler/animation.ts b/packages/engine/src/compiler/animation.ts index c081b666..bbb87dc9 100644 --- a/packages/engine/src/compiler/animation.ts +++ b/packages/engine/src/compiler/animation.ts @@ -74,10 +74,10 @@ export function resolveAnimation(spec: AnimationSpec | undefined): ResolvedAnima // AnimationConfig object const config: AnimationConfig = typeof spec === 'string' ? { preset: spec } : spec; - const preset = config.preset ?? 'legacy'; + const preset = config.preset; const spring = preset === 'fluid' ? { ...FLUID_SPRING } : undefined; const base = (defaults: ResolvedAnimationPhase): ResolvedAnimationPhase => - preset === 'legacy' + preset === undefined ? defaults : { ...defaults, duration: spring ? springDuration(spring) : 250, spring, staggerDelay: 0 }; @@ -86,7 +86,7 @@ export function resolveAnimation(spec: AnimationSpec | undefined): ResolvedAnima // Exits are brief fades; only an authored phase opts into spring timing. const exit = resolvePhase( config.exit, - preset === 'legacy' ? EXIT_DEFAULTS : { ...EXIT_DEFAULTS, duration: 160 }, + preset === undefined ? EXIT_DEFAULTS : { ...EXIT_DEFAULTS, duration: 160 }, ); // maxMarks is update-only, so it is applied here rather than in the @@ -113,7 +113,8 @@ export function resolveAnimation(spec: AnimationSpec | undefined): ResolvedAnima enter: enter ?? undefined, update: update ?? undefined, exit: exit ?? undefined, - annotationDelay: config.annotationDelay ?? (preset === 'legacy' ? DEFAULT_ANNOTATION_DELAY : 0), + annotationDelay: + config.annotationDelay ?? (preset === undefined ? DEFAULT_ANNOTATION_DELAY : 0), }; } diff --git a/packages/engine/src/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index 7c92df4c..2d2e59bf 100644 --- a/packages/engine/src/compiler/normalize.ts +++ b/packages/engine/src/compiler/normalize.ts @@ -17,19 +17,18 @@ import type { ChromeText, DataRow, Encoding, - FieldType, GeoMapSpec, GraphLayoutConfig, GraphSpec, LabelSpec, LayerSpec, SankeySpec, - TableDensity, TableSpec, TileMapSpec, VizSpec, } from '@opendata-ai/openchart-core'; import { + inferFieldType, isBarListSpec, isChartSpec, isGeoMapSpec, @@ -83,57 +82,6 @@ function normalizeChrome(chrome: Chrome | undefined): NormalizedChrome { // Type inference // --------------------------------------------------------------------------- -/** Sample values from a data column and infer the field type. */ -function inferFieldType(data: DataRow[], field: string): FieldType { - // Sample up to 50 rows for more reliable inference on mixed/messy data - const sampleSize = Math.min(50, data.length); - let numericCount = 0; - let dateCount = 0; - let totalNonNull = 0; - - for (let i = 0; i < sampleSize; i++) { - const value = data[i][field]; - if (value == null) continue; - totalNonNull++; - - // Check numeric - if (typeof value === 'number' && Number.isFinite(value)) { - numericCount++; - continue; - } - - // Check date-like strings - if (typeof value === 'string') { - // Try as number first - const num = Number(value); - if (!Number.isNaN(num) && Number.isFinite(num) && value.trim() !== '') { - numericCount++; - continue; - } - - // Try as date - const date = new Date(value); - if (!Number.isNaN(date.getTime())) { - dateCount++; - continue; - } - } - - if (value instanceof Date && !Number.isNaN(value.getTime())) { - dateCount++; - } - } - - if (totalNonNull === 0) return 'nominal'; - - // If >80% of sampled values are dates, it's temporal - if (dateCount / totalNonNull > 0.8) return 'temporal'; - // If >80% are numeric, it's quantitative - if (numericCount / totalNonNull > 0.8) return 'quantitative'; - // Otherwise it's nominal - return 'nominal'; -} - /** Infer types for encoding channels that don't have one specified. */ function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: string[]): Encoding { const result = { ...encoding }; @@ -214,6 +162,8 @@ function normalizeAnnotations( case 'refline': return { ...ann, + labelPlacement: + ann.labelPlacement ?? (ann.labelAnchor === undefined ? 'auto' : undefined), style: ann.style ?? 'dashed', strokeWidth: ann.strokeWidth ?? 1, opacity: ann.opacity ?? 0.8, @@ -236,7 +186,6 @@ function normalizeLabels(labels?: LabelSpec): NormalizedChartSpec['labels'] { format: labels.format ?? '', prefix: labels.prefix ?? '', suffix: labels.suffix, - offsets: labels.offsets, color: labels.color, fontSize: labels.fontSize, }; @@ -498,7 +447,7 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar data, encoding, chrome: normalizeChrome(spec.chrome), - chromeLayout: spec.chromeLayout, + chromeLayout: spec.chromeLayout ?? 'notes', metrics: spec.metrics, seriesSearch: normalizeSeriesSearch(spec, encoding, warnings), youDrawIt: normalizeYouDrawIt(spec), @@ -507,14 +456,15 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar legend: spec.legend, endpointLabels: spec.endpointLabels, compactAnnotationLimit: - typeof spec.responsive === 'object' ? spec.responsive.compactAnnotationLimit : undefined, + typeof spec.responsive === 'object' && spec.responsive.compactAnnotationLimit !== undefined + ? spec.responsive.compactAnnotationLimit + : 1, responsive: typeof spec.responsive === 'object' ? true : (spec.responsive ?? true), autoThin: typeof spec.responsive === 'object' ? (spec.responsive.autoThin ?? true) : (spec.responsive ?? true), theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', hiddenSeries: spec.hiddenSeries ?? [], seriesStyles: spec.seriesStyles ?? {}, watermark: spec.watermark ?? true, @@ -538,20 +488,8 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar }; } -/** - * Resolve row density. `density` wins; the deprecated `compact` flag maps to - * `'condensed'` and warns once. - */ -function normalizeTableDensity(spec: TableSpec): TableDensity { - if (spec.density) return spec.density; - if (spec.compact) { - return 'condensed'; - } - return 'regular'; -} - function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTableSpec { - const density = normalizeTableDensity(spec); + const density = spec.density ?? 'regular'; const totalRow = spec.totalRow === true ? { label: 'Total' } @@ -566,7 +504,6 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab rowKey: spec.rowKey, chrome: normalizeChrome(spec.chrome), theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', search: spec.search ?? false, pagination: spec.pagination ?? false, stickyFirstColumn: spec.stickyFirstColumn ?? false, @@ -574,7 +511,6 @@ function normalizeTableSpec(spec: TableSpec, _warnings: string[]): NormalizedTab striped: spec.striped ?? false, sort: spec.sort, totalRow, - compact: density === 'condensed', responsive: spec.responsive ?? true, animation: spec.animation, watermark: spec.watermark ?? true, @@ -596,9 +532,7 @@ function normalizeSankeySpec(spec: SankeySpec, _warnings: string[]): NormalizedS chrome: normalizeChrome(spec.chrome), legend: spec.legend, theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', animation: spec.animation, - valueFormat: spec.valueFormat, linkOpacity: spec.linkOpacity, other: spec.other, watermark: spec.watermark ?? true, @@ -636,7 +570,6 @@ function normalizeGraphSpec(spec: GraphSpec, warnings: string[]): NormalizedGrap chrome: normalizeChrome(spec.chrome), annotations: normalizeAnnotations(spec.annotations, warnings), theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', watermark: spec.watermark ?? true, animation: spec.animation, interaction: spec.interaction, @@ -709,7 +642,6 @@ function normalizeTileMapSpec(spec: TileMapSpec, warnings: string[]): Normalized chrome: normalizeChrome(spec.chrome), legend: spec.legend, theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', watermark: spec.watermark ?? true, animation: spec.animation, valueFormat: spec.valueFormat, @@ -733,10 +665,8 @@ function normalizeGeoMapSpec(spec: GeoMapSpec, _warnings: string[]): NormalizedG chrome: normalizeChrome(spec.chrome), legend: spec.legend, theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', watermark: spec.watermark ?? true, animation: spec.animation, - valueFormat: spec.valueFormat, points: spec.points, }; } @@ -751,10 +681,8 @@ function normalizeBarListSpec(spec: BarListSpec, _warnings: string[]): Normalize maxItems: spec.maxItems ?? 20, chrome: normalizeChrome(spec.chrome), theme: spec.theme ?? {}, - darkMode: spec.darkMode ?? 'off', watermark: spec.watermark ?? true, animation: spec.animation ?? true, - valueFormat: spec.valueFormat, }; } @@ -771,26 +699,6 @@ function normalizeBarListSpec(spec: BarListSpec, _warnings: string[]): Normalize */ export function normalizeSpec(spec: VizSpec, warnings: string[] = []): NormalizedSpec { const authored = spec as unknown as Record; - if (authored.darkMode !== undefined) - warnings.push( - '[openchart] spec darkMode is deprecated and has no effect; use the mount darkMode option.', - ); - const themeChrome = (authored.theme as { chrome?: Record } | undefined)?.chrome; - if (themeChrome && Object.values(themeChrome).some((value) => typeof value === 'string')) - warnings.push('[openchart] theme.chrome string colors are deprecated; use { color } objects.'); - if ( - authored.animation === 'legacy' || - (authored.animation as { preset?: string } | undefined)?.preset === 'legacy' - ) - warnings.push('[openchart] animation legacy is deprecated; omit preset to retain v8 timing.'); - if ((authored.labels as { offsets?: unknown } | undefined)?.offsets !== undefined) - warnings.push( - '[openchart] labels.offsets is deprecated; use endpointLabels. Migration drops manual offsets.', - ); - if (authored.type === 'table' && 'compact' in authored) - warnings.push( - '[openchart] table "compact" is deprecated; use density: "condensed" (true) or "regular" (false).', - ); const annotations = authored.annotations as Annotation[] | undefined; if (Array.isArray(annotations)) { const ids = annotations.map((a) => a.id).filter((id) => id !== undefined); diff --git a/packages/engine/src/compiler/removed-spec-fields.ts b/packages/engine/src/compiler/removed-spec-fields.ts new file mode 100644 index 00000000..bfdfc85f --- /dev/null +++ b/packages/engine/src/compiler/removed-spec-fields.ts @@ -0,0 +1,51 @@ +import type { ValidationError } from './types'; + +type Node = Record; +const node = (value: unknown): Node | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Node) + : undefined; + +/** Reject retired authoring forms before sugar can hide them. Migration accepts raw v8 input. */ +export function collectRemovedSpecErrors(value: unknown, prefix = ''): ValidationError[] { + const spec = node(value); + if (!spec) return []; + const errors: ValidationError[] = []; + const reject = (path: string, replacement: string) => + errors.push({ + code: 'INVALID_VALUE', + path: prefix + path, + message: `${prefix}${path} was removed in v9. ${replacement}`, + suggestion: 'Run migrateSpecV8 on older documents before compiling.', + }); + if ('darkMode' in spec) reject('darkMode', 'Set darkMode on the mount option.'); + if (spec.type === 'table' && 'compact' in spec) reject('compact', 'Use density: "condensed".'); + if (['sankey', 'map', 'barlist'].includes(String(spec.type)) && 'valueFormat' in spec) + reject('valueFormat', `Use encoding.${spec.type === 'map' ? 'color' : 'value'}.format.`); + if (node(spec.labels) && 'offsets' in node(spec.labels)!) + reject('labels.offsets', 'Use endpointLabels.'); + if (spec.animation === 'legacy' || node(spec.animation)?.preset === 'legacy') + reject('animation', 'Omit the preset for the former timing.'); + const mark = node(spec.mark); + if (mark && 'render' in mark) reject('mark.render', 'Use the renderer mount option.'); + const encoding = node(spec.encoding); + for (const key of ['radius', 'shape', 'href', 'order']) + if (encoding && key in encoding) reject(`encoding.${key}`, 'This channel has no renderer.'); + const markType = typeof spec.mark === 'string' ? spec.mark : mark?.type; + if (['arc', 'waffle', 'parliament'].includes(String(markType)) && encoding?.y && !encoding.theta) + reject('encoding.y', 'Use encoding.theta.'); + const chrome = node(node(spec.theme)?.chrome); + for (const [key, entry] of Object.entries(chrome ?? {})) + if (typeof entry === 'string') reject(`theme.chrome.${key}`, 'Use a { color } object.'); + if (Array.isArray(spec.annotations)) + spec.annotations.forEach((entry, index) => { + if (node(entry)?.type === 'rule') reject(`annotations[${index}].type`, 'Use refline.'); + }); + if (Array.isArray(spec.layer)) + spec.layer.forEach((entry, index) => { + errors.push(...collectRemovedSpecErrors(entry, `${prefix}layer[${index}].`)); + }); + for (const [breakpoint, override] of Object.entries(node(spec.overrides) ?? {})) + errors.push(...collectRemovedSpecErrors(override, `${prefix}overrides.${breakpoint}.`)); + return errors; +} diff --git a/packages/engine/src/compiler/types.ts b/packages/engine/src/compiler/types.ts index 83c0eb0d..57c31e4b 100644 --- a/packages/engine/src/compiler/types.ts +++ b/packages/engine/src/compiler/types.ts @@ -13,7 +13,6 @@ import type { AxisConfig, ChromeText, ColumnConfig, - DarkMode, DataRow, Display, Encoding, @@ -128,7 +127,7 @@ export interface NormalizedChartSpec { * `'subtract'` (default) shrinks the plot to fit chrome. Carried through from * the spec so the layout pipeline can honor it. */ - chromeLayout?: 'subtract' | 'grow'; + chromeLayout: 'subtract' | 'grow' | 'notes'; /** Optional KPI metric cells, passed through unchanged. */ metrics?: import('@opendata-ai/openchart-core').Metric[]; /** @@ -141,7 +140,7 @@ export interface NormalizedChartSpec { annotations: Annotation[]; /** Normalized label configuration with defaults applied. density, format, and prefix are always set; offsets and color stay optional. */ labels: Required> & - Pick; + Pick; /** Legend configuration (position override). */ legend?: LegendConfig; /** Right-side endpoint labels column config (multi-series line/area only). */ @@ -151,7 +150,6 @@ export interface NormalizedChartSpec { autoThin: boolean; compactAnnotationLimit?: number | null; theme: ThemeConfig; - darkMode: DarkMode; /** Whether the OpenData watermark is enabled. */ watermark: boolean; /** Series names to hide from rendering. */ @@ -186,7 +184,6 @@ export interface NormalizedTableSpec { rowKey?: string; chrome: NormalizedChrome; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; search: boolean; pagination: boolean | { pageSize: number }; @@ -196,7 +193,6 @@ export interface NormalizedTableSpec { sort?: SortState; totalRow?: { label: string }; /** Mirrors `density === 'condensed'` for back-compat. */ - compact: boolean; responsive: boolean; animation?: AnimationSpec; } @@ -214,7 +210,6 @@ export interface NormalizedGraphSpec { chrome: NormalizedChrome; annotations: Annotation[]; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; animation?: GraphAnimationSpec; interaction?: GraphInteractionConfig; diff --git a/packages/engine/src/compiler/validate.ts b/packages/engine/src/compiler/validate.ts index eefe9d38..8df42b73 100644 --- a/packages/engine/src/compiler/validate.ts +++ b/packages/engine/src/compiler/validate.ts @@ -1,4 +1,5 @@ import { collectMarkOptionErrors } from './mark-options'; +import { collectRemovedSpecErrors } from './removed-spec-fields'; /** * Runtime spec validation. * @@ -30,8 +31,6 @@ import type { ValidationError, ValidationResult } from './types'; const VALID_FIELD_TYPES = new Set(['quantitative', 'temporal', 'nominal', 'ordinal']); -const VALID_DARK_MODES = new Set(['auto', 'force', 'off']); - /** Check if a value looks like a parseable date. */ function isParseableDate(value: unknown): boolean { if (value instanceof Date) return !Number.isNaN(value.getTime()); @@ -1028,17 +1027,6 @@ function validateChartSpec(spec: Record, errors: ValidationErro } } - // Validate darkMode if provided - if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) { - errors.push({ - message: `Spec error: darkMode must be "auto", "force", or "off"`, - path: 'darkMode', - code: 'INVALID_VALUE', - suggestion: - 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)', - }); - } - // Validate youDrawIt: line marks only, single-series only, "from" required. if (spec.youDrawIt !== undefined && spec.youDrawIt !== false) { if (markType !== 'line') { @@ -1531,17 +1519,6 @@ function validateSankeySpec(spec: Record, errors: ValidationErr 'sankey node colors cycle the theme categorical palette', 'Remove scale.scheme. To customize sankey colors, set theme.colors.categorical.', ); - - // Validate darkMode if provided - if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) { - errors.push({ - message: 'Spec error: darkMode must be "auto", "force", or "off"', - path: 'darkMode', - code: 'INVALID_VALUE', - suggestion: - 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)', - }); - } } // --------------------------------------------------------------------------- @@ -1657,17 +1634,6 @@ function validateTileMapSpec(spec: Record, errors: ValidationEr `Remove scale.scheme and set the top-level palette property instead, e.g. palette: "green". Supported: ${MAP_PALETTE_NAMES}.`, ); } - - // Validate darkMode if provided - if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) { - errors.push({ - message: 'Spec error: darkMode must be "auto", "force", or "off"', - path: 'darkMode', - code: 'INVALID_VALUE', - suggestion: - 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)', - }); - } } // --------------------------------------------------------------------------- @@ -2061,16 +2027,6 @@ function validateBarListSpec(spec: Record, errors: ValidationEr 'bar list colors cycle the built-in barlist palette', 'Remove scale.scheme. Bar list colors are not configurable via encoding; they cycle the built-in palette.', ); - - if (spec.darkMode !== undefined && !VALID_DARK_MODES.has(spec.darkMode as string)) { - errors.push({ - message: 'Spec error: darkMode must be "auto", "force", or "off"', - path: 'darkMode', - code: 'INVALID_VALUE', - suggestion: - 'Use one of: "auto" (system preference), "force" (always dark), or "off" (always light)', - }); - } } // --------------------------------------------------------------------------- @@ -2174,7 +2130,7 @@ function validateLayerSpec(spec: Record, errors: ValidationErro */ function validateAnimation(spec: Record, errors: ValidationError[]): void { const animation = spec.animation; - if (typeof animation === 'string' && !['fluid', 'subtle', 'legacy'].includes(animation)) { + if (typeof animation === 'string' && !['fluid', 'subtle'].includes(animation)) { errors.push({ code: 'INVALID_VALUE', suggestion: 'Use a named preset or separate valid spring and tween phase settings.', @@ -2185,15 +2141,12 @@ function validateAnimation(spec: Record, errors: ValidationErro if (!animation || typeof animation !== 'object' || Array.isArray(animation)) return; const config = animation as Record; - if ( - config.preset !== undefined && - !['fluid', 'subtle', 'legacy'].includes(String(config.preset)) - ) { + if (config.preset !== undefined && !['fluid', 'subtle'].includes(String(config.preset))) { errors.push({ code: 'INVALID_VALUE', suggestion: 'Use a named preset or separate valid spring and tween phase settings.', path: 'animation.preset', - message: 'Animation preset must be fluid, subtle, or legacy.', + message: 'Animation preset must be fluid or subtle.', }); } for (const phase of ['enter', 'update', 'exit']) { @@ -2434,6 +2387,7 @@ function validateSpecShape(spec: unknown): ValidationResult { } const obj = spec as Record; + errors.push(...collectRemovedSpecErrors(obj)); validateAnnotationLayout(obj, errors); validateFieldMetadata(obj, errors); if (obj.chrome !== undefined) { diff --git a/packages/engine/src/design/__tests__/design.test.ts b/packages/engine/src/design/__tests__/design.test.ts index 33418828..4c2cb6bd 100644 --- a/packages/engine/src/design/__tests__/design.test.ts +++ b/packages/engine/src/design/__tests__/design.test.ts @@ -15,6 +15,7 @@ import { recommendCompositions } from '../recommend'; const spec: ChartSpec = { mark: 'bar', + theme: { fonts: { sizes: { axisTick: 11 } } }, data: [ { id: 'a', category: 'Alpha', value: 10 }, { id: 'b', category: 'Beta', value: 20 }, @@ -88,7 +89,7 @@ describe('design contracts', () => { const patch = diagnoseChart(spec, layout).find((item) => item.fix)!.fix!; const next = applySpecPatch(spec, patch); expect(next.theme?.fonts?.sizes?.axisTick).toBe(12); - expect(spec.theme).toBeUndefined(); + expect(spec.theme?.fonts?.sizes?.axisTick).toBe(11); expect(() => applySpecPatch(next, patch)).toThrow('stale'); expect(() => applySpecPatch(spec, { ...patch, changes: [{ path: '/__proto__/polluted', value: true }] }), diff --git a/packages/engine/src/endpoint-labels/compute.ts b/packages/engine/src/endpoint-labels/compute.ts index 68aa276f..b395d495 100644 --- a/packages/engine/src/endpoint-labels/compute.ts +++ b/packages/engine/src/endpoint-labels/compute.ts @@ -271,7 +271,6 @@ export function computeEndpointLabels( // The 'none' branch above returned unless the user explicitly opted in, // in which case resolveSuppression's explicit-on override applies anyway. labelsHiddenByStrategy: false, - labelsDensityNone: spec.labels.density === 'none', endpointLabelsDemoted, }); const epExplicitlyOff = diff --git a/packages/engine/src/geo/__tests__/compile-geo-map.test.ts b/packages/engine/src/geo/__tests__/compile-geo-map.test.ts index cb1ce56d..17e58a69 100644 --- a/packages/engine/src/geo/__tests__/compile-geo-map.test.ts +++ b/packages/engine/src/geo/__tests__/compile-geo-map.test.ts @@ -237,7 +237,7 @@ describe('compileGeoMap', () => { expect(layout.categoricalLegend).toBeNull(); }); - it('encoding.color.format takes precedence over deprecated valueFormat', () => { + it('encoding.color.format formats tooltip values', () => { const layout = compileGeoMap( { type: 'map', @@ -247,7 +247,6 @@ describe('compileGeoMap', () => { key: { field: 'fips', type: 'nominal' }, color: { field: 'value', type: 'quantitative', format: '$,.0f' }, }, - valueFormat: '.2f', }, DEFAULT_OPTIONS, ); @@ -256,23 +255,22 @@ describe('compileGeoMap', () => { expect(ca!.fields.map((f) => f.value)).toContain('$1,500'); }); - it('deprecated valueFormat still formats tooltips when no encoding format is set', () => { - const layout = compileGeoMap( - { - type: 'map', - geo: { features: MINI_TOPO, projection: 'mercator' }, - data: [{ fips: '06', value: 1500 }], - encoding: { - key: { field: 'fips', type: 'nominal' }, - color: { field: 'value', type: 'quantitative' }, + it('rejects the removed valueFormat field with the replacement channel', () => { + expect(() => + compileGeoMap( + { + type: 'map', + geo: { features: MINI_TOPO, projection: 'mercator' }, + data: [{ fips: '06', value: 1500 }], + encoding: { + key: { field: 'fips', type: 'nominal' }, + color: { field: 'value', type: 'quantitative' }, + }, + valueFormat: '$,.0f', }, - valueFormat: '$,.0f', - }, - DEFAULT_OPTIONS, - ); - - const ca = layout.tooltipDescriptors.get('06'); - expect(ca!.fields.map((f) => f.value)).toContain('$1,500'); + DEFAULT_OPTIONS, + ), + ).toThrow(/valueFormat.*encoding.color.format/); }); it('classes the default legend on round breaks', () => { diff --git a/packages/engine/src/geo/compile-geo-map.ts b/packages/engine/src/geo/compile-geo-map.ts index 1e8d83b9..6502d73c 100644 --- a/packages/engine/src/geo/compile-geo-map.ts +++ b/packages/engine/src/geo/compile-geo-map.ts @@ -504,8 +504,7 @@ export function compileGeoMap(spec: unknown, options: CompileOptions): GeoMapLay const droppedFeatures: string[] = []; // 13. Build color scale + feature marks - const formatter = - buildD3Formatter(mapSpec.encoding?.color?.format ?? mapSpec.valueFormat) ?? formatNumber; + const formatter = buildD3Formatter(mapSpec.encoding?.color?.format) ?? formatNumber; const formatQuantitative = (value: unknown): string => { const number = finiteNumber(value); return number === undefined ? 'No data' : formatter(number); diff --git a/packages/engine/src/geo/types.ts b/packages/engine/src/geo/types.ts index a2ef1336..2e09a0b9 100644 --- a/packages/engine/src/geo/types.ts +++ b/packages/engine/src/geo/types.ts @@ -1,6 +1,5 @@ import type { AnimationSpec, - DarkMode, GeoMapEncoding, GeoMapGeo, GeoMapPointsLayer, @@ -22,9 +21,7 @@ export interface NormalizedGeoMapSpec { chrome: NormalizedChrome; legend?: LegendConfig; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; animation?: AnimationSpec; - valueFormat?: string; points?: GeoMapPointsLayer; } diff --git a/packages/engine/src/layout/dimensions.ts b/packages/engine/src/layout/dimensions.ts index 1c2db272..5e13e8a0 100644 --- a/packages/engine/src/layout/dimensions.ts +++ b/packages/engine/src/layout/dimensions.ts @@ -28,7 +28,6 @@ import type { import { BREAKPOINT_COMPACT_MAX, computeChrome, - estimateTextWidth, getHeightClass, HPAD_COMPACT_FRACTION, HPAD_COMPACT_MIN, @@ -261,8 +260,8 @@ export function computeDimensions( const effectiveHeight = chromeLayout === 'grow' ? height + chrome.topHeight + chrome.bottomHeight + (options.footnoteReserve ?? 0) - : height; - if (chromeLayout === 'grow') chrome.grownHeight = effectiveHeight - height; + : height + (chromeLayout === 'notes' ? (options.footnoteReserve ?? 0) : 0); + if (chromeLayout !== 'subtract') chrome.grownHeight = effectiveHeight - height; // Sparkline mode: produce a near-edge-to-edge layout. Only stroke-width-based // safety padding plus chrome (if user-explicit). Skip axis space, label @@ -377,27 +376,16 @@ export function computeDimensions( left: hPad + (isRadial ? hPad : axisMargin), }; - // Right-margin reservation for the three-way label suppression truth table: - // - // 1. Endpoint-labels column (predicted width, default ON for ≥2-series - // line/area). Reserves chart-width + ENDPOINT_COLUMN_GAP + col-width. - // 2. Legacy end-of-line labels — only when the truth table resolves to - // `showEndOfLineLabels: true` (legend hidden AND endpoint column off). - const labelDensity = spec.labels.density; + // Reserve the endpoint column when the series identification policy shows it. const labelsHiddenByStrategy = strategy?.labelMode === 'none'; const seriesCount = countColorSeries(spec); const sup = resolveSuppression(spec, { seriesCount, labelsHiddenByStrategy, - labelsDensityNone: labelDensity === 'none', endpointLabelsDemoted: plan.endpointLabelsDemoted, }); - // (1) Endpoint-labels column reservation. predictEndpointLabelsWidth returns 0 - // when the column would be suppressed. `labels.density` is intentionally - // not checked here — that switch controls only the legacy end-of-line labels. - // No extra strategy check either: `sup.showEndpointLabels` already accounts - // for the compact strip (and its explicit-opt-in override). + // The policy accounts for compact demotion and explicit author opt-ins. let endpointWidth = 0; if (sup.showEndpointLabels) { endpointWidth = predictEndpointLabelsWidth(spec, theme); @@ -411,36 +399,6 @@ export function computeDimensions( } } - // (2) Legacy end-of-line label reservation — fires only in the truth-table - // cell where end-of-line labels still render (legend hidden AND endpoint - // column off AND ≥2 series AND labels visible). When the endpoint column - // is on, this reservation is redundant and is skipped. - if ( - endpointWidth === 0 && - sup.showEndOfLineLabels && - (spec.markType === 'line' || spec.markType === 'area') && - labelDensity !== 'none' && - !labelsHiddenByStrategy - ) { - const colorEnc = encoding.color; - const colorField = colorEnc && 'field' in colorEnc ? colorEnc.field : undefined; - if (colorField) { - let maxLabelWidth = 0; - const seen = new Set(); - for (const row of spec.data) { - const label = String(row[colorField] ?? ''); - if (!seen.has(label)) { - seen.add(label); - const w = estimateTextWidth(label, 11, 600); - if (w > maxLabelWidth) maxLabelWidth = w; - } - } - if (maxLabelWidth > 0) { - margins.right = Math.max(margins.right, hPad + maxLabelWidth + 8); - } - } - } - // Annotation placement must not feed back into plot dimensions: committing an // explicit anchor/offset would otherwise move the data point underneath it. // Callout bounds are handled by placement and the figure-boundary clamp. diff --git a/packages/engine/src/layout/shared.ts b/packages/engine/src/layout/shared.ts index df7abb51..bfa03543 100644 --- a/packages/engine/src/layout/shared.ts +++ b/packages/engine/src/layout/shared.ts @@ -53,7 +53,7 @@ export const INLINE_TICK_OVERHANG_PAD = 6; /** * Resolve the effective chrome-layout mode for a spec. * - * `'subtract'` (default): chrome occupies part of the height budget, shrinking + * `'notes'` (internal default): only notes grow. `'subtract'`: chrome occupies the height budget, shrinking * the plot. `'grow'`: the plot keeps the full height budget and the final SVG * grows by the chrome height. * @@ -61,8 +61,9 @@ export const INLINE_TICK_OVERHANG_PAD = 6; * chart or facet assembly owns growth exactly once. */ export function resolveChromeLayout( - spec: { chromeLayout?: 'subtract' | 'grow'; encoding?: unknown } | undefined, + spec: { chromeLayout?: 'subtract' | 'grow' | 'notes'; encoding?: unknown } | undefined, options: Pick | undefined, -): 'subtract' | 'grow' { - return spec?.chromeLayout ?? options?.chromeLayout ?? 'subtract'; +): 'subtract' | 'grow' | 'notes' { + if (spec?.chromeLayout === 'notes') return options?.chromeLayout ?? 'notes'; + return spec?.chromeLayout ?? options?.chromeLayout ?? 'notes'; } diff --git a/packages/engine/src/legend/__tests__/suppression.test.ts b/packages/engine/src/legend/__tests__/suppression.test.ts index 0216160b..189248a0 100644 --- a/packages/engine/src/legend/__tests__/suppression.test.ts +++ b/packages/engine/src/legend/__tests__/suppression.test.ts @@ -59,7 +59,6 @@ function makeMultiSeriesLineSpec( const baseCtx = { seriesCount: 2, labelsHiddenByStrategy: false, - labelsDensityNone: false, }; // --------------------------------------------------------------------------- @@ -102,7 +101,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(false); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 2: legend: { show: true }, endpointLabels unset @@ -112,7 +110,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 3: legend unset, endpointLabels: false @@ -122,12 +119,11 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 4: legend: { show: false }, endpointLabels: false // -> all legend surfaces off, end-of-line labels are last resort - it('cell 4 (legend: false, endpointLabels: false): only end-of-line labels show', () => { + it('cell 4 (legend: false, endpointLabels: false): both surfaces remain hidden', () => { const spec = makeMultiSeriesLineSpec({ legend: { show: false }, endpointLabels: false, @@ -135,7 +131,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(false); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(true); }); // Cell 5: legend: { show: true }, endpointLabels: false @@ -148,7 +143,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 6: legend: { show: false }, endpointLabels: true @@ -161,7 +155,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(false); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 7: legend: { show: true }, endpointLabels: true @@ -174,7 +167,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); // Cell 8 (the implicit eighth cell): unset + endpointLabels: true @@ -184,7 +176,6 @@ describe('resolveSuppression - 8-cell truth table', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(false); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); }); @@ -193,7 +184,6 @@ describe('resolveSuppression - special cases', () => { const spec = makeMultiSeriesLineSpec(); const result = resolveSuppression(spec, { ...baseCtx, seriesCount: 1 }); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); expect(result.showTraditionalLegend).toBe(true); }); @@ -201,7 +191,6 @@ describe('resolveSuppression - special cases', () => { const spec = makeMultiSeriesLineSpec({ markType: 'bar' }); const result = resolveSuppression(spec, baseCtx); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); it('compact strategy (labelsHiddenByStrategy): column off, end-of-line off', () => { @@ -211,27 +200,21 @@ describe('resolveSuppression - special cases', () => { labelsHiddenByStrategy: true, }); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); - it('labels.density === none: scoped to end-of-line labels only (column + legend follow truth table)', () => { - // labels.density: 'none' is the legacy switch for end-of-line labels. - // It must NOT suppress the endpoint column or the traditional legend — - // those follow the truth table independently. End-of-line labels stay off. - const spec = makeMultiSeriesLineSpec(); - const result = resolveSuppression(spec, { ...baseCtx, labelsDensityNone: true }); + it('labels.density does not suppress the endpoint column or legend', () => { + const spec = makeMultiSeriesLineSpec({ labels: { density: 'none' } }); + const result = resolveSuppression(spec, baseCtx); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); // Cell 1 (legend unset, endpointLabels unset): traditional legend hidden. expect(result.showTraditionalLegend).toBe(false); }); it('labels.density === none + legend.show: true: legend stays shown', () => { const spec = makeMultiSeriesLineSpec({ legend: { show: true } }); - const result = resolveSuppression(spec, { ...baseCtx, labelsDensityNone: true }); + const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(true); - expect(result.showEndOfLineLabels).toBe(false); }); it('stacked area: legend shown by default, column off', () => { @@ -247,7 +230,6 @@ describe('resolveSuppression - special cases', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); it('stacked area with explicit endpointLabels: true: column shown', () => { @@ -289,7 +271,6 @@ describe('resolveSuppression - special cases', () => { const result = resolveSuppression(spec, baseCtx); expect(result.showTraditionalLegend).toBe(true); expect(result.showEndpointLabels).toBe(false); - expect(result.showEndOfLineLabels).toBe(false); }); it('endpointLabels: { show: false } counts as explicit off', () => { diff --git a/packages/engine/src/legend/compute.ts b/packages/engine/src/legend/compute.ts index 3e3b8efd..0b94d495 100644 --- a/packages/engine/src/legend/compute.ts +++ b/packages/engine/src/legend/compute.ts @@ -412,7 +412,6 @@ export function computeLegendContent( const suppression = resolveSuppression(spec, { seriesCount, labelsHiddenByStrategy: strategy.labelMode === 'none', - labelsDensityNone: spec.labels.density === 'none', endpointLabelsDemoted, }); if (!suppression.showTraditionalLegend) { diff --git a/packages/engine/src/legend/suppression.ts b/packages/engine/src/legend/suppression.ts index 5f3ee307..dcc236d6 100644 --- a/packages/engine/src/legend/suppression.ts +++ b/packages/engine/src/legend/suppression.ts @@ -1,33 +1,4 @@ -/** - * Single source of truth for legend / endpoint-column / end-of-line label - * suppression on multi-series line/area charts. - * - * Three independent toggles drive the chart's series identification surface: - * - * - `legend.show` — the traditional swatch legend (top/bottom/right). - * - `endpointLabels` — the right-side per-series column with last value. - * - end-of-line labels — the legacy series-name label glued to the last point. - * - * For ≥2-series line/area charts the resolution table is: - * - * | `legend.show` | `endpointLabels` | Traditional legend | Endpoint column | End-of-line labels | - * |--|--|--|--|--| - * | unset | unset | hidden (auto-suppressed) | shown | hidden | - * | true | unset | shown | shown | hidden | - * | unset | false | shown (auto-revoked) | hidden | hidden | - * | false | false | hidden | hidden | shown | - * | true | false | shown | hidden | hidden | - * | false | true | hidden | shown | hidden | - * | true | true | shown | shown | hidden | - * - * Single-series and non-line/area charts: column always hidden, legend hidden - * (single-series rule), end-of-line labels never render. - * - * Rule of thumb: end-of-line labels are the fallback identifier of last resort. - * They render only when neither the traditional legend nor the endpoint column - * is showing. The endpoint column "owns" the right zone and absorbs the - * suppression role the traditional legend used to have for end-of-line labels. - */ +/** Series identification uses two independent surfaces: legend and endpoint column. */ import type { Encoding } from '@opendata-ai/openchart-core'; @@ -39,8 +10,6 @@ export interface SuppressionResult { showTraditionalLegend: boolean; /** Whether the right-side endpoint column should render. */ showEndpointLabels: boolean; - /** Whether the legacy end-of-line labels should render. */ - showEndOfLineLabels: boolean; } /** Inputs needed by the resolution helper. */ @@ -49,8 +18,6 @@ export interface SuppressionContext { seriesCount: number; /** True when responsive strategy strips inline labels (compact breakpoint). */ labelsHiddenByStrategy: boolean; - /** True when spec.labels.density === 'none'. */ - labelsDensityNone: boolean; /** When true, endpoint labels are demoted because they don't fit at the current width. */ endpointLabelsDemoted?: boolean; } @@ -106,10 +73,9 @@ function getMaxSeries(spec: NormalizedChartSpec): number { } /** - * Resolve the three booleans from spec + context. + * Resolve legend and endpoint visibility from spec + context. * - * Pure: no theme, no chart area, no scales. Both `computeLegend` and - * `computeLineLabels` and `computeEndpointLabels` share this exact logic. + * Pure: no theme, no chart area, no scales. `computeLegend` and `computeEndpointLabels` share this exact logic. */ export function resolveSuppression( spec: NormalizedChartSpec, @@ -120,15 +86,11 @@ export function resolveSuppression( const isMultiSeries = isLineOrArea && hasColorEncoding && ctx.seriesCount >= 2; // Single-series, non-line/area, or compact strategy: never show endpoint - // column or end-of-line labels. Legend follows its own rules elsewhere. + // column. Legend follows its own rules elsewhere. // Explicit user opt-in (endpointLabels config) overrides the compact // strip: slope/bump recipes carry their values on the labels, so an // author who wrote endpointLabels config keeps the column at 320px. // - // Note: `labelsDensityNone` is intentionally NOT in this short-circuit. - // `labels.density: 'none'` is the legacy switch for end-of-line labels and - // must not affect the endpoint column or the traditional legend (those - // are independent concerns governed by the truth table below). const strategyHidesEndpoint = ctx.labelsHiddenByStrategy && !endpointLabelsExplicitlyOn(spec); if (!isMultiSeries || strategyHidesEndpoint) { return { @@ -136,7 +98,6 @@ export function resolveSuppression( // doesn't override the existing legend behavior for non-multi-series. showTraditionalLegend: !legendHiddenExplicitly(spec), showEndpointLabels: false, - showEndOfLineLabels: false, }; } @@ -158,7 +119,6 @@ export function resolveSuppression( return { showTraditionalLegend: !legendHiddenExplicitly(spec), showEndpointLabels: explicitOn, - showEndOfLineLabels: false, }; } @@ -191,17 +151,9 @@ export function resolveSuppression( showTraditionalLegend = !showEndpointLabels; } - // End-of-line labels are the last-resort series identifier: they render - // only when both the traditional legend and the endpoint column are off. - // `labels.density: 'none'` is the legacy switch that explicitly disables - // end-of-line labels regardless of the other toggles. - const showEndOfLineLabels = - !showTraditionalLegend && !showEndpointLabels && !ctx.labelsDensityNone; - return { showTraditionalLegend, showEndpointLabels, - showEndOfLineLabels, }; } diff --git a/packages/engine/src/migration/v8.ts b/packages/engine/src/migration/v8.ts index 9b300912..4d494e93 100644 --- a/packages/engine/src/migration/v8.ts +++ b/packages/engine/src/migration/v8.ts @@ -1,5 +1,5 @@ import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; -import { legacyEditorial } from '@opendata-ai/openchart-core'; +import { legacyEditorial, resolveTheme } from '@opendata-ai/openchart-core'; import { buildPrimarySpec } from '../compile/layer'; import { expandSpecSugar } from '../compile/spec-sugar'; import { flattenLayers, normalizeSpec } from '../compiler/normalize'; @@ -25,7 +25,10 @@ const escapePointer = (value: string) => value.replace(/~/g, '~0').replace(/\//g * Legacy end-of-line labels become endpoint labels; their manual offsets are * reported as lost repairs because the two placement models are different. */ -export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { +export function migrateSpecV8( + input: T, + options: { styling?: 'v8' | 'v9' } = {}, +): { spec: T; repairs: SpecRepair[] } { const repairs: SpecRepair[] = []; const clone = (value: unknown): unknown => Array.isArray(value) @@ -58,6 +61,15 @@ export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { rewrite('density', current.compact ? 'condensed' : 'regular', 'Use table density.'); rewrite('compact', undefined, 'Replaced by density.'); } + // v8's layer-level theme replaced a leaf theme as a whole. Make that + // formerly effective choice explicit before v9's deep parent/leaf merge. + if ( + options.styling !== 'v9' && + current.theme !== undefined && + inherited.theme !== undefined && + JSON.stringify(current.theme) !== JSON.stringify(inherited.theme) + ) + rewrite('theme', clone(inherited.theme), 'Preserve the v8 parent theme precedence.'); const theme = node(current.theme); const chrome = node(theme?.chrome); if (chrome) @@ -129,111 +141,153 @@ export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { if (annotation?.type === 'rule') replace(annotation, 'type', 'refline', `${path}/annotations/${index}`, 'Use refline.'); } - // Materialize only omitted v8 defaults. An authored null/false remains an opt-out. - if (Array.isArray(current.annotations)) - for (const [index, value] of current.annotations.entries()) { - const annotation = node(value); - if (!annotation) continue; - const at = `${path}/annotations/${index}`; - const setDefault = (key: string, value: unknown) => { - if (annotation[key] === undefined) - replace(annotation, key, value, at, 'Preserve the v8 annotation default.'); - }; - if (annotation.type === 'text') { - setDefault('maxWidth', null); - if (annotation.subtitle) { - setDefault('fontWeight', 700); - setDefault( - 'subtitleFontSize', - Math.round( - (typeof annotation.fontSize === 'number' ? annotation.fontSize : 13) * 0.85, - ), - ); + if (options.styling !== 'v9') { + // Materialize only omitted v8 defaults. An authored null/false remains an opt-out. + if (Array.isArray(current.annotations)) + for (const [index, value] of current.annotations.entries()) { + const annotation = node(value); + if (!annotation) continue; + const at = `${path}/annotations/${index}`; + const setDefault = (key: string, value: unknown) => { + if (annotation[key] === undefined) + replace(annotation, key, value, at, 'Preserve the v8 annotation default.'); + }; + if (annotation.type === 'text') { + setDefault('maxWidth', null); + if (annotation.subtitle) { + setDefault('fontWeight', 700); + setDefault( + 'subtitleFontSize', + Math.round( + (typeof annotation.fontSize === 'number' ? annotation.fontSize : 13) * 0.85, + ), + ); + } + if (annotation.connector === 'curve') + replace( + annotation, + 'connector', + { type: 'curve', arrow: true }, + at, + 'Preserve the v8 curve arrow.', + ); + const connector = node(annotation.connector); + if (connector?.type === 'curve' && connector.arrow === undefined) + replace(connector, 'arrow', true, `${at}/connector`, 'Preserve the v8 curve arrow.'); + } + if ( + annotation.type === 'refline' && + annotation.label && + annotation.labelAnchor === undefined && + annotation.labelPlacement === undefined + ) { + setDefault('labelAnchor', annotation.y !== undefined ? 'top' : 'left'); } - if (annotation.connector === 'curve') - replace( - annotation, - 'connector', - { type: 'curve', arrow: true }, - at, - 'Preserve the v8 curve arrow.', - ); - const connector = node(annotation.connector); - if (connector?.type === 'curve' && connector.arrow === undefined) - replace(connector, 'arrow', true, `${at}/connector`, 'Preserve the v8 curve arrow.'); } - if ( - annotation.type === 'refline' && - annotation.label && - annotation.labelAnchor === undefined && - annotation.labelPlacement === undefined - ) { - setDefault('labelAnchor', annotation.y !== undefined ? 'top' : 'left'); + const isChart = current.mark !== undefined || Array.isArray(current.layer); + const isLayer = Array.isArray(current.layer); + if (isChart && !isLayer && current.chromeLayout === undefined) + rewrite('chromeLayout', 'subtract', 'Preserve the v8 fixed figure budget.'); + if ( + isChart && + (!isLayer || + current.responsive !== undefined || + (typeof current.width === 'number' && typeof current.height === 'number')) + ) { + const responsive = current.responsive; + if (responsive !== false) { + if ( + responsive === undefined && + typeof current.width === 'number' && + typeof current.height === 'number' + ) + rewrite('responsive', false, 'Preserve the fixed-size responsive opt-out.'); + else if (node(responsive)?.compactAnnotationLimit === undefined) + rewrite( + 'responsive', + { ...node(responsive), compactAnnotationLimit: null }, + 'Preserve unlimited v8 compact annotations.', + ); } } - const isChart = current.mark !== undefined || Array.isArray(current.layer); - const isLayer = Array.isArray(current.layer); - if (isChart && !isLayer && current.chromeLayout === undefined) - rewrite('chromeLayout', 'subtract', 'Preserve the v8 fixed figure budget.'); - if ( - isChart && - (!isLayer || - current.responsive !== undefined || - (typeof current.width === 'number' && typeof current.height === 'number')) - ) { - const responsive = current.responsive; - if (responsive !== false) { - if ( - responsive === undefined && - typeof current.width === 'number' && - typeof current.height === 'number' - ) - rewrite('responsive', false, 'Preserve the fixed-size responsive opt-out.'); - else if (node(responsive)?.compactAnnotationLimit === undefined) + // A theme-less layer inherits its parent. Adding a partial leaf theme would + // change the v8 shallow merge, so only materialize at an actual theme owner. + if ( + (!isLayer && (isChart || current.type !== undefined) && inherited.theme === undefined) || + current.theme !== undefined + ) { + const oldTheme = { ...node(current.theme) }; + const fonts = node(oldTheme.fonts) ?? {}; + const sizes = node(fonts.sizes) ?? {}; + const chromeDefaults = node(oldTheme.chrome) ?? {}; + const newSizes = { ...sizes }; + // v9 gives explicit chrome sizes precedence, so materialized font sizes + // can also preserve note/axis typography without overriding chrome. + for (const [key, value] of Object.entries(legacyEditorial.fonts!.sizes!)) { + if (newSizes[key] === undefined) newSizes[key] = value; + } + const legacySurface = resolveTheme(oldTheme).isDark ? '#0a0a0a' : '#ffffff'; + const newChrome = { ...chromeDefaults }; + for (const key of ['byline', 'source', 'footer']) { + const entry = node(newChrome[key]) ?? {}; + if (entry.fontSize === undefined) + newChrome[key] = { ...entry, fontSize: sizes.small ?? 11 }; + } + const materialized = { + ...oldTheme, + colors: { + annotationRule: { light: '#666666', dark: '#666666' }, + annotationRangeFill: { light: '#000000', dark: '#000000' }, + annotationMarkerFill: { light: legacySurface, dark: '#0a0a0a' }, + annotationPlate: { light: legacySurface, dark: '#0a0a0a' }, + ...(Array.isArray(oldTheme.colors) + ? { categorical: oldTheme.colors } + : node(oldTheme.colors)), + }, + fonts: { ...fonts, sizes: newSizes }, + spacing: { ...legacyEditorial.spacing, ...node(oldTheme.spacing) }, + chrome: newChrome, + }; + if (JSON.stringify(materialized) !== JSON.stringify(current.theme)) rewrite( - 'responsive', - { ...node(responsive), compactAnnotationLimit: null }, - 'Preserve unlimited v8 compact annotations.', + 'theme', + materialized, + 'Preserve v8 typography without overriding authored chrome sizes.', ); } } - // A theme-less layer inherits its parent. Adding a partial leaf theme would - // change the v8 shallow merge, so only materialize at an actual theme owner. - if ( - (!isLayer && (isChart || current.type !== undefined) && inherited.theme === undefined) || - current.theme !== undefined - ) { - const oldTheme = { ...node(current.theme) }; - const fonts = node(oldTheme.fonts) ?? {}; - const sizes = node(fonts.sizes) ?? {}; - const chromeDefaults = node(oldTheme.chrome) ?? {}; - const newSizes = { ...sizes }; - for (const [key, value] of Object.entries(legacyEditorial.fonts!.sizes!)) { - const matching = key === 'small' ? ['source', 'byline', 'footer'] : [key]; - if ( - newSizes[key] === undefined && - !matching.some((name) => node(chromeDefaults[name])?.fontSize !== undefined) - ) - newSizes[key] = value; - } - const newChrome = { ...chromeDefaults }; - for (const key of ['byline', 'source', 'footer']) { - const entry = node(newChrome[key]) ?? {}; - if (entry.fontSize === undefined) - newChrome[key] = { ...entry, fontSize: sizes.small ?? 11 }; - } - const materialized = { ...oldTheme, fonts: { ...fonts, sizes: newSizes }, chrome: newChrome }; - if (JSON.stringify(materialized) !== JSON.stringify(current.theme)) - rewrite( - 'theme', - materialized, - 'Preserve v8 typography without overriding authored chrome sizes.', - ); - } const data = current.data ?? inherited.data; const effectiveEncoding = { ...node(inherited.encoding), ...encoding }; const labels = node(current.labels); const endpoint = current.endpointLabels ?? inherited.endpointLabels; + for (const [breakpoint, override] of Object.entries(node(current.overrides) ?? {})) { + const overrideNode = node(override); + if (overrideNode) + visit(overrideNode, `${path}/overrides/${escapePointer(breakpoint)}`, { + ...inherited, + ...current, + data, + encoding: effectiveEncoding, + // An override is a shallow patch, not another container to traverse. + overrides: undefined, + layer: undefined, + }); + } + if (Array.isArray(current.layer)) + current.layer.forEach((child, index) => { + const childNode = node(child); + if (childNode) + visit(childNode, `${path}/layer/${index}`, { + data, + theme: current.theme ?? inherited.theme, + responsive: current.responsive ?? inherited.responsive, + chromeLayout: current.chromeLayout ?? inherited.chromeLayout, + encoding: effectiveEncoding, + endpointLabels: current.endpointLabels ?? endpoint, + legend: current.legend ?? inherited.legend, + labels: current.labels ?? inherited.labels, + }); + }); let legacyLabels = false; const effective: Node = { ...inherited, @@ -256,14 +310,29 @@ export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { ? migrationSource.mark : node(migrationSource.mark)?.type; if (!['line', 'area'].includes(String(sourceMark))) return false; + const migrationLabels = { ...node(migrationSource.labels) }; + delete migrationLabels.offsets; const normalized = normalizeSpec( - expandSpecSugar(migrationSource as unknown as Node) as unknown as ChartSpec, + expandSpecSugar({ + ...migrationSource, + labels: migrationLabels, + } as unknown as Node) as unknown as ChartSpec, ) as NormalizedChartSpec; - return resolveSuppression(normalized, { + const suppression = resolveSuppression(normalized, { seriesCount: countColorSeries(normalized), labelsHiddenByStrategy: false, - labelsDensityNone: normalized.labels.density === 'none', - }).showEndOfLineLabels; + }); + const stackedArea = + sourceMark === 'area' && + normalized.encoding.y?.stack !== null && + normalized.encoding.y?.stack !== false; + return ( + !stackedArea && + countColorSeries(normalized) >= 2 && + normalized.labels.density !== 'none' && + !suppression.showTraditionalLegend && + !suppression.showEndpointLabels + ); }); if (legacyLabels) rewrite( @@ -283,21 +352,6 @@ export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { ); delete labels.offsets; } - if (Array.isArray(current.layer)) - current.layer.forEach((child, index) => { - const childNode = node(child); - if (childNode) - visit(childNode, `${path}/layer/${index}`, { - data, - theme: current.theme ?? inherited.theme, - responsive: current.responsive ?? inherited.responsive, - chromeLayout: current.chromeLayout ?? inherited.chromeLayout, - encoding: effectiveEncoding, - endpointLabels: current.endpointLabels ?? endpoint, - legend: current.legend ?? inherited.legend, - labels: current.labels ?? inherited.labels, - }); - }); } const root = node(spec); if (root) visit(root, ''); diff --git a/packages/engine/src/sankey/__tests__/compile-sankey.test.ts b/packages/engine/src/sankey/__tests__/compile-sankey.test.ts index 423ade1a..693be221 100644 --- a/packages/engine/src/sankey/__tests__/compile-sankey.test.ts +++ b/packages/engine/src/sankey/__tests__/compile-sankey.test.ts @@ -408,9 +408,12 @@ describe('compileSankey', () => { }); }); - describe('valueFormat', () => { - it('formats tooltip values when valueFormat is set', () => { - const spec = { ...basicSpec, valueFormat: '.0f%' }; + describe('encoding.value.format', () => { + it('formats tooltip values when the channel format is set', () => { + const spec = { + ...basicSpec, + encoding: { ...basicSpec.encoding, value: { ...basicSpec.encoding.value, format: '.0f%' } }, + }; const result = compileSankey(spec, defaultOptions); // Check that node tooltips use the format @@ -418,7 +421,7 @@ describe('compileSankey', () => { expect(nodeTooltip?.fields[0].value).toContain('%'); }); - it('uses default formatting when valueFormat is undefined', () => { + it('uses default formatting when the channel format is omitted', () => { const result = compileSankey(basicSpec, defaultOptions); const nodeTooltip = result.tooltipDescriptors.get('node-C'); @@ -426,7 +429,13 @@ describe('compileSankey', () => { }); it('falls back to default on invalid format string', () => { - const spec = { ...basicSpec, valueFormat: 'not-a-format' }; + const spec = { + ...basicSpec, + encoding: { + ...basicSpec.encoding, + value: { ...basicSpec.encoding.value, format: 'not-a-format' }, + }, + }; // Should not throw const result = compileSankey(spec, defaultOptions); expect(result.nodes.length).toBeGreaterThan(0); diff --git a/packages/engine/src/sankey/compile-sankey.ts b/packages/engine/src/sankey/compile-sankey.ts index f1fbd788..83289abd 100644 --- a/packages/engine/src/sankey/compile-sankey.ts +++ b/packages/engine/src/sankey/compile-sankey.ts @@ -332,7 +332,7 @@ export function compileSankey(spec: unknown, options: CompileOptions): SankeyLay const sankeySpec = normalized as NormalizedSankeySpec; // Resolve format: encoding-level (v8 canonical) wins over deprecated top-level valueFormat - const resolvedValueFormat = sankeySpec.encoding.value.format ?? sankeySpec.valueFormat; + const resolvedValueFormat = sankeySpec.encoding.value.format; // Resolve watermark: explicit spec value wins, then options fallback, then default true. const rawWatermark = (spec as Record).watermark; diff --git a/packages/engine/src/sankey/types.ts b/packages/engine/src/sankey/types.ts index 8504ab83..3e16a6c6 100644 --- a/packages/engine/src/sankey/types.ts +++ b/packages/engine/src/sankey/types.ts @@ -8,7 +8,6 @@ import type { AnimationSpec, - DarkMode, LegendConfig, SankeyEncoding, SankeyLinkColor, @@ -33,10 +32,8 @@ export interface NormalizedSankeySpec { chrome: NormalizedChrome; legend?: LegendConfig; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; animation?: AnimationSpec; - valueFormat?: string; linkOpacity?: number; other?: number | { threshold: number; label?: string }; } diff --git a/packages/engine/src/tables/__tests__/compile-table.test.ts b/packages/engine/src/tables/__tests__/compile-table.test.ts index 662e92fc..61c6ff6c 100644 --- a/packages/engine/src/tables/__tests__/compile-table.test.ts +++ b/packages/engine/src/tables/__tests__/compile-table.test.ts @@ -34,7 +34,8 @@ describe('compileTable', () => { expect(layout.chrome).toBeDefined(); expect(layout.search.enabled).toBe(false); expect(layout.stickyFirstColumn).toBe(false); - expect(layout.compact).toBe(false); + expect(layout.density).toBe('regular'); + expect(layout).not.toHaveProperty('compact'); }); it('formats cell values correctly', () => { diff --git a/packages/engine/src/tables/__tests__/table-design.test.ts b/packages/engine/src/tables/__tests__/table-design.test.ts index 15ae699f..62a2606a 100644 --- a/packages/engine/src/tables/__tests__/table-design.test.ts +++ b/packages/engine/src/tables/__tests__/table-design.test.ts @@ -259,25 +259,16 @@ describe('density', () => { expect(layout.striped).toBe(false); }); - it('maps the deprecated compact flag to condensed with a warning', () => { - const warnings: string[] = []; - const layout = compileTable( - { ...base, compact: true }, - { ...options, onWarn: (m) => warnings.push(m) }, - ); + it('uses condensed density without exposing a compact alias', () => { + const layout = compileTable({ ...base, density: 'condensed' }, options); expect(layout.density).toBe('condensed'); - expect(layout.compact).toBe(true); - expect(warnings.join('\n')).toContain('density: "condensed"'); + expect(layout).not.toHaveProperty('compact'); }); - it('lets density win and warns about the authored compact alias', () => { - const warnings: string[] = []; - const layout = compileTable( - { ...base, compact: true, density: 'relaxed' }, - { ...options, onWarn: (m) => warnings.push(m) }, + it.each([undefined, 'relaxed'] as const)('rejects compact even with density %s', (density) => { + expect(() => compileTable({ ...base, compact: true, density }, options)).toThrow( + /compact.*removed in v9/, ); - expect(layout.density).toBe('relaxed'); - expect(warnings.some((warning) => warning.includes('compact'))).toBe(true); }); }); diff --git a/packages/engine/src/tables/compile-table.ts b/packages/engine/src/tables/compile-table.ts index d6b255ba..0922d1e5 100644 --- a/packages/engine/src/tables/compile-table.ts +++ b/packages/engine/src/tables/compile-table.ts @@ -489,8 +489,7 @@ export function compileTableLayout( data, options.width, theme, - // The deprecated `compact` flag renders condensed padding too. - spec.density ?? (spec.compact ? 'condensed' : undefined), + spec.density, columnWarnings, ); emitSpecWarnings(columnWarnings, options.onWarn); @@ -675,7 +674,6 @@ export function compileTableLayout( totalRow: spec.totalRow ? buildTotalRow(spec.totalRow.label, spec.columns, resolvedColumns, filteredData) : undefined, - compact: spec.compact, a11y: { caption, summary: `${resolvedColumns.length} columns, ${totalFiltered} rows`, diff --git a/packages/engine/src/tilemap/compile-tilemap.ts b/packages/engine/src/tilemap/compile-tilemap.ts index 6c89e178..4d47833c 100644 --- a/packages/engine/src/tilemap/compile-tilemap.ts +++ b/packages/engine/src/tilemap/compile-tilemap.ts @@ -110,7 +110,7 @@ function blendOnSurface(fill: string, fillOpacity: number, theme: ResolvedTheme) */ function growContentHeight( contentHeight: number, - chromeLayout: 'subtract' | 'grow', + chromeLayout: 'subtract' | 'grow' | 'notes', budgetHeight: number, chrome: { topHeight: number; bottomHeight: number }, ): number { @@ -261,7 +261,7 @@ function compileQuantitative( fullArea: { x: number; y: number; width: number; height: number }, isDarkMode: boolean | undefined, watermark: boolean, - chromeLayout: 'subtract' | 'grow', + chromeLayout: 'subtract' | 'grow' | 'notes', ): TileMapLayout { const stateField = tilemapSpec.encoding.state.field; const valueField = tilemapSpec.encoding.value.field; @@ -450,7 +450,7 @@ function compileCategorical( fullArea: { x: number; y: number; width: number; height: number }, isDarkMode: boolean | undefined, watermark: boolean, - chromeLayout: 'subtract' | 'grow', + chromeLayout: 'subtract' | 'grow' | 'notes', ): TileMapLayout { const stateField = tilemapSpec.encoding.state.field; const colorField = tilemapSpec.encoding.color?.field ?? tilemapSpec.encoding.value.field; diff --git a/packages/engine/src/tilemap/types.ts b/packages/engine/src/tilemap/types.ts index 5815bbf8..84d4744c 100644 --- a/packages/engine/src/tilemap/types.ts +++ b/packages/engine/src/tilemap/types.ts @@ -7,7 +7,6 @@ import type { AnimationSpec, - DarkMode, LegendConfig, ThemeConfig, TileMapEncoding, @@ -26,7 +25,6 @@ export interface NormalizedTileMapSpec { chrome: NormalizedChrome; legend?: LegendConfig; theme: ThemeConfig; - darkMode: DarkMode; watermark: boolean; animation?: AnimationSpec; valueFormat?: string; diff --git a/packages/svelte/vitest.config.ts b/packages/svelte/vitest.config.ts index ba48e8ac..7f33fbaa 100644 --- a/packages/svelte/vitest.config.ts +++ b/packages/svelte/vitest.config.ts @@ -6,7 +6,7 @@ import { defineConfig } from 'vitest/config'; const packages = path.resolve(__dirname, '..'); export default defineConfig({ - plugins: [svelte({ hot: false }), svelteTesting()], + plugins: [svelte({ configFile: false }), svelteTesting()], resolve: { alias: [ // Workspace packages -> source (so side-effect imports fire) diff --git a/packages/vanilla/src/__tests__/annotation-notes.test.ts b/packages/vanilla/src/__tests__/annotation-notes.test.ts new file mode 100644 index 00000000..fb548e0c --- /dev/null +++ b/packages/vanilla/src/__tests__/annotation-notes.test.ts @@ -0,0 +1,180 @@ +import type { ChartSpec } from '@opendata-ai/openchart-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createContainer } from '../__test-fixtures__/dom'; +import { barSpec } from '../__test-fixtures__/specs'; +import { exportSVG, exportSVGWithFonts } from '../export'; +import { createChart } from '../mount'; + +const spec: ChartSpec = { + ...barSpec, + animation: false, + responsive: { compactAnnotationLimit: 0 }, + annotations: [ + { + type: 'text', + id: 'first', + x: 10, + y: 'A', + text: 'A **finding**', + subtitle: 'Supporting **context**', + }, + { + type: 'text', + id: 'second', + x: 20, + y: 'B', + text: 'Another finding', + subtitle: 'Complete detail', + }, + ], +}; +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + document.body.replaceChildren(); +}); +function mount(nextSpec = spec) { + const container = createContainer(390, 400); + const onEdit = vi.fn(); + const chart = createChart(container, nextSpec, { autoResize: false, onEdit }); + cleanups.push(() => chart.destroy()); + const svg = container.querySelector('svg')!; + const note = (index: number) => + svg.querySelector(`[data-note-spec-index="${index}"]`)!; + const markers = (index: number) => [ + ...svg.querySelectorAll(`[data-note-marker-index="${index}"]`), + ]; + return { container, chart, svg, onEdit, note, markers }; +} +const press = (element: Element, key: string) => + element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); + +describe('source-linked annotation notes', () => { + it('renders source-ordered, accessible complete notes with rich text', () => { + const m = mount(); + expect( + m.svg.querySelector('[role="list"][aria-label="Annotation notes, in source order"]'), + ).not.toBeNull(); + expect( + [...m.svg.querySelectorAll('[data-note-spec-index]')].map((el) => + el.getAttribute('data-note-spec-index'), + ), + ).toEqual(['0', '1']); + expect(m.svg.getAttribute('role')).toBe('group'); + expect(m.svg.getAttribute('aria-label')).toBeTruthy(); + expect(m.note(0).getAttribute('role')).toBe('listitem'); + expect(m.note(0).getAttribute('tabindex')).toBe('0'); + expect(m.note(0).getAttribute('aria-label')).toBe('1. A finding. Supporting context'); + expect(m.note(0).textContent).not.toContain('**'); + expect( + [...m.note(0).querySelectorAll('[font-weight="700"]')].map((el) => el.textContent).join(' '), + ).toContain('finding'); + expect(m.markers(0)).toHaveLength(1); + expect(m.markers(0)[0].getAttribute('role')).toBe('button'); + expect(m.markers(0)[0].getAttribute('tabindex')).toBe('0'); + const number = m.markers(0)[0].querySelector('.oc-annotation-footnote-number')!; + expect(number.getAttribute('text-anchor')).toBe('middle'); + expect(number.getAttribute('dominant-baseline')).toBe('central'); + }); + + it('focus and click emphasize only the source-linked note and marker; blur and Escape clear them', () => { + const m = mount(); + m.note(0).focus(); + expect([...m.svg.querySelectorAll('.oc-note-active')]).toEqual( + expect.arrayContaining([m.note(0), m.markers(0)[0]]), + ); + expect(m.note(1).classList.contains('oc-note-active')).toBe(false); + m.note(0).blur(); + expect(m.svg.querySelector('.oc-note-active')).toBeNull(); + m.note(1).dispatchEvent(new MouseEvent('click', { bubbles: true })); + expect(m.note(1).classList.contains('oc-note-active')).toBe(true); + expect(m.markers(1)[0].classList.contains('oc-note-active')).toBe(true); + press(m.note(1), 'Escape'); + expect(m.svg.querySelector('.oc-note-active')).toBeNull(); + expect(m.onEdit).not.toHaveBeenCalled(); + }); + + it.each(['Enter', ' '])('%s moves focus between a marker and its note without editing', (key) => { + const m = mount(); + const marker = m.markers(0)[0]; + marker.dispatchEvent(new MouseEvent('click', { bubbles: true })); + marker.focus(); + press(marker, key); + expect(document.activeElement).toBe(m.note(0)); + expect(m.container.querySelector('textarea, input, [contenteditable]')).toBeNull(); + press(m.note(0), key); + expect(document.activeElement).toBe(marker); + expect(m.onEdit).not.toHaveBeenCalled(); + }); + + it('resolves current note peers after a compatible resize reconciles SVG nodes', () => { + let width = 1280; + const container = document.createElement('div'); + document.body.appendChild(container); + container.getBoundingClientRect = () => ({ width, height: 400 }) as DOMRect; + const chart = createChart(container, spec, { autoResize: false }); + cleanups.push(() => chart.destroy()); + const originalSVG = container.querySelector('svg'); + expect(container.querySelector('[data-note-spec-index]')).toBeNull(); + width = 390; + chart.resize(); + expect(container.querySelector('svg')).toBe(originalSVG); + const note = container.querySelector('[data-note-spec-index="0"]')!; + const marker = container.querySelector('[data-note-marker-index="0"]')!; + note.focus(); + expect(marker.classList.contains('oc-note-active')).toBe(true); + press(note, 'Enter'); + expect(document.activeElement).toBe(marker); + press(marker, ' '); + expect(document.activeElement).toBe(note); + for (let i = 0; i < 3; i++) chart.update(spec); + const latestNote = container.querySelector('[data-note-spec-index="0"]')!; + const latestMarker = container.querySelector('[data-note-marker-index="0"]')!; + const focus = vi.spyOn(latestMarker, 'focus'); + press(latestNote, 'Enter'); + expect(focus).toHaveBeenCalledTimes(1); + chart.destroy(); + expect(container.querySelector('svg')).toBeNull(); + }); + + it('emphasizes every facet marker for one source and returns to the first instance', () => { + const faceted: ChartSpec = { + ...spec, + data: spec.data.flatMap((row) => [ + { ...row, panel: 'one' }, + { ...row, panel: 'two' }, + ]), + encoding: { ...spec.encoding, facet: { field: 'panel', type: 'nominal' } }, + }; + const m = mount(faceted); + expect(m.markers(0)).toHaveLength(2); + m.note(0).focus(); + expect(m.markers(0).every((marker) => marker.classList.contains('oc-note-active'))).toBe(true); + press(m.note(0), 'Enter'); + expect(document.activeElement).toBe(m.markers(0)[0]); + expect(m.onEdit).not.toHaveBeenCalled(); + }); + + it.each([ + 'svg', + 'embedded-svg', + ] as const)('excludes transient emphasis and editor controls from %s without clearing the live view', async (format) => { + const m = mount(); + m.note(0).focus(); + expect(m.svg.querySelector('.oc-note-active')).not.toBeNull(); + const source = + format === 'svg' ? exportSVG(m.svg) : await exportSVGWithFonts(m.svg, { embedFonts: false }); + const exported = new DOMParser().parseFromString(source, 'image/svg+xml'); + expect( + exported.querySelector( + '.oc-note-active, .oc-selection-overlay, .oc-anchor-handle, .oc-connector-handle', + ), + ).toBeNull(); + expect(exported.querySelectorAll('[data-note-spec-index]')).toHaveLength(2); + expect(exported.querySelector('[data-note-spec-index="0"]')?.getAttribute('aria-label')).toBe( + '1. A finding. Supporting context', + ); + expect(m.svg.querySelector('.oc-note-active')).not.toBeNull(); + expect(m.onEdit).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/vanilla/src/__tests__/annotations-render.test.ts b/packages/vanilla/src/__tests__/annotations-render.test.ts index f7cb6e7c..063b2fa9 100644 --- a/packages/vanilla/src/__tests__/annotations-render.test.ts +++ b/packages/vanilla/src/__tests__/annotations-render.test.ts @@ -66,7 +66,7 @@ describe('annotation connectors', () => { x: '2020-02-01', y: 30, text: 'Peak', - connector: 'curve', + connector: { type: 'curve', arrow: true }, offset: { dx: 60, dy: -50 }, }, ]); @@ -118,7 +118,7 @@ describe('annotation connectors', () => { x: '2020-02-01', y: 30, text: 'Peak', - connector: 'curve', + connector: { type: 'curve', arrow: true }, offset: { dx: 60, dy: -50 }, }, ]); diff --git a/packages/vanilla/src/__tests__/barlist-mount.test.ts b/packages/vanilla/src/__tests__/barlist-mount.test.ts index 34cc5988..14b77a28 100644 --- a/packages/vanilla/src/__tests__/barlist-mount.test.ts +++ b/packages/vanilla/src/__tests__/barlist-mount.test.ts @@ -84,10 +84,16 @@ describe('createBarList', () => { instance.destroy(); }); - it('valueFormat formats value labels', () => { + it('encoding.value.format formats value labels', () => { const instance = createBarList( container, - { ...basicSpec, valueFormat: '$,.0f' }, + { + ...basicSpec, + encoding: { + ...basicSpec.encoding, + value: { ...basicSpec.encoding.value, format: '$,.0f' }, + }, + }, { responsive: false }, ); diff --git a/packages/vanilla/src/__tests__/chrome-renderer.test.ts b/packages/vanilla/src/__tests__/chrome-renderer.test.ts index 700af43d..ee3239be 100644 --- a/packages/vanilla/src/__tests__/chrome-renderer.test.ts +++ b/packages/vanilla/src/__tests__/chrome-renderer.test.ts @@ -120,7 +120,7 @@ it('uses the engine note lines for rendering, footer spacing, and static export' let previousBottom = 0; for (const [i, note] of notes.entries()) { const resolved = layout.chrome.footnotes![i]; - expect([...note.querySelectorAll('tspan')].map((span) => span.textContent)).toEqual( + expect([...note.querySelectorAll(':scope > tspan')].map((span) => span.textContent)).toEqual( resolved.lines, ); expect(note.getAttribute('data-note-id')).toBe(resolved.id); diff --git a/packages/vanilla/src/__tests__/edit-events.test.ts b/packages/vanilla/src/__tests__/edit-events.test.ts index 73e36f56..33978dbd 100644 --- a/packages/vanilla/src/__tests__/edit-events.test.ts +++ b/packages/vanilla/src/__tests__/edit-events.test.ts @@ -75,7 +75,7 @@ const curvedAnnotatedSpec: ChartSpec = { y: 'A', text: 'Peak', offset: { dx: 40, dy: -50 }, - connector: 'curve', + connector: { type: 'curve', arrow: true }, }, ], }; diff --git a/packages/vanilla/src/__tests__/label-halo.test.ts b/packages/vanilla/src/__tests__/label-halo.test.ts index 3dc321b0..dec316c8 100644 --- a/packages/vanilla/src/__tests__/label-halo.test.ts +++ b/packages/vanilla/src/__tests__/label-halo.test.ts @@ -99,7 +99,7 @@ describe('mark label halo', () => { }); describe('annotation label halo', () => { - it('cuts an opaque halo behind a refline label under the default theme', () => { + it('cuts an opaque halo behind an explicitly anchored refline label', () => { const container = render({ mark: 'line', data: [ @@ -111,7 +111,7 @@ describe('annotation label halo', () => { x: { field: 'date', type: 'temporal' }, y: { field: 'value', type: 'quantitative' }, }, - annotations: [{ type: 'refline', y: 20, label: 'avg: 20' }], + annotations: [{ type: 'refline', y: 20, label: 'avg: 20', labelAnchor: 'top' }], }); const label = container.querySelector('.oc-annotation-label') as SVGElement | null; diff --git a/packages/vanilla/src/__tests__/note-band-compat.test.ts b/packages/vanilla/src/__tests__/note-band-compat.test.ts index 88d0ed98..a02c74ac 100644 --- a/packages/vanilla/src/__tests__/note-band-compat.test.ts +++ b/packages/vanilla/src/__tests__/note-band-compat.test.ts @@ -1,10 +1,10 @@ import { compileChart } from '@opendata-ai/openchart-engine'; import { expect, it, vi } from 'vitest'; import { createContainer } from '../__test-fixtures__/dom'; -import { footnoteBandHeight } from '../renderers/chrome'; +import * as chromeRenderer from '../renderers/chrome'; import { renderChartSVG } from '../svg-renderer'; -it('normal rendering does not call the deprecated adapter, which warns once when used', () => { +it('renders resolved notes without the removed footnoteBandHeight adapter', () => { const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); try { const layout = compileChart( @@ -27,11 +27,9 @@ it('normal rendering does not call the deprecated adapter, which warns once when expect( warn.mock.calls.some(([message]) => String(message).includes('footnoteBandHeight')), ).toBe(false); - expect(footnoteBandHeight(layout)).toBe(layout.chrome.noteBandHeight); - expect(footnoteBandHeight(layout)).toBe(layout.chrome.noteBandHeight); - expect( - warn.mock.calls.filter(([message]) => String(message).includes('footnoteBandHeight')), - ).toHaveLength(1); + expect(chromeRenderer).not.toHaveProperty('footnoteBandHeight'); + expect(layout.chrome.noteBandHeight).toBeGreaterThan(0); + expect(document.querySelector('.oc-footnotes')?.textContent).toContain('A note'); } finally { warn.mockRestore(); document.body.innerHTML = ''; diff --git a/packages/vanilla/src/__tests__/table-design.test.ts b/packages/vanilla/src/__tests__/table-design.test.ts index aeb3ada8..4770900c 100644 --- a/packages/vanilla/src/__tests__/table-design.test.ts +++ b/packages/vanilla/src/__tests__/table-design.test.ts @@ -67,11 +67,11 @@ describe('table density classes', () => { t.destroy(); }); - it('keeps the deprecated compact alias class alongside condensed', () => { + it('uses only the canonical condensed density class', () => { const t = createTable(container, makeSpec({ density: 'condensed' })); const wrapper = container.querySelector('.oc-table-wrapper')!; expect(wrapper.classList.contains('oc-table--condensed')).toBe(true); - expect(wrapper.classList.contains('oc-table--compact')).toBe(true); + expect(wrapper.classList.contains('oc-table--compact')).toBe(false); t.destroy(); }); diff --git a/packages/vanilla/src/__tests__/table-mount.test.ts b/packages/vanilla/src/__tests__/table-mount.test.ts index decccf95..b784dfce 100644 --- a/packages/vanilla/src/__tests__/table-mount.test.ts +++ b/packages/vanilla/src/__tests__/table-mount.test.ts @@ -490,12 +490,13 @@ describe('createTable', () => { table.destroy(); }); - it('compact mode applies oc-table--compact class', () => { - const spec = makeSpec({ compact: true }); + it('condensed density applies the canonical density class', () => { + const spec = makeSpec({ density: 'condensed' }); const table = createTable(container, spec); const wrapper = container.querySelector('.oc-table-wrapper'); - expect(wrapper?.classList.contains('oc-table--compact')).toBe(true); + expect(wrapper?.classList.contains('oc-table--condensed')).toBe(true); + expect(wrapper?.classList.contains('oc-table--compact')).toBe(false); table.destroy(); }); diff --git a/packages/vanilla/src/export.ts b/packages/vanilla/src/export.ts index 4e77a33f..276158fd 100644 --- a/packages/vanilla/src/export.ts +++ b/packages/vanilla/src/export.ts @@ -309,11 +309,22 @@ export function getSVGBackgroundColor(svgElement: SVGElement, fallback?: string) * @returns The SVG markup as a string. */ export function exportSVG(svgElement: SVGElement): string { - if (!svgElement.getAttribute('xmlns')) { - svgElement.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); + // Export a clean snapshot without changing the live chart's focus/selection. + // Every SVG and raster path serializes here, including font-embedded exports. + const clone = svgElement.cloneNode(true) as SVGElement; + for (const control of clone.querySelectorAll( + '.oc-selection-overlay, .oc-anchor-handle, .oc-connector-handle', + )) + control.remove(); + clone.classList.remove('oc-note-active'); + for (const element of clone.querySelectorAll('.oc-note-active')) { + element.classList.remove('oc-note-active'); + } + if (!clone.getAttribute('xmlns')) { + clone.setAttribute('xmlns', 'http://www.w3.org/2000/svg'); } const serializer = new XMLSerializer(); - return serializer.serializeToString(svgElement); + return serializer.serializeToString(clone); } /** diff --git a/packages/vanilla/src/interactions/annotation-notes.ts b/packages/vanilla/src/interactions/annotation-notes.ts new file mode 100644 index 00000000..821d535b --- /dev/null +++ b/packages/vanilla/src/interactions/annotation-notes.ts @@ -0,0 +1,55 @@ +const NOTE_SELECTOR = '[data-note-spec-index], [data-note-marker-index]'; + +/** Link source-identified notes and their facet markers without mutating the spec. */ +export function wireAnnotationNotes(svg: SVGSVGElement): void { + // A graphic's children are presentational to assistive technology. Keep the + // chart's name, but expose its interactive note list and marker buttons. + if (svg.querySelector(NOTE_SELECTOR) && svg.getAttribute('role') === 'img') { + svg.setAttribute('role', 'group'); + } + // Compatible updates reconcile descendants into this SVG. Delegate events + // and resolve peers at interaction time so retained listeners never point at + // detached notes or at the temporary SVG used to construct the next scene. + const linked = () => [...svg.querySelectorAll(NOTE_SELECTOR)]; + const key = (element: Element) => + element.getAttribute('data-note-spec-index') ?? element.getAttribute('data-note-marker-index'); + const target = (event: Event) => + event.target instanceof Element ? event.target.closest(NOTE_SELECTOR) : null; + const emphasize = (index: string | null) => { + for (const element of linked()) { + element.classList.toggle('oc-note-active', index !== null && key(element) === index); + } + }; + svg.addEventListener('focusin', (event) => { + const element = target(event); + if (element) emphasize(key(element)); + }); + svg.addEventListener('focusout', (event) => { + if (target(event)) emphasize(null); + }); + svg.addEventListener('click', (event) => { + const element = target(event); + if (element) emphasize(key(element)); + }); + svg.addEventListener('keydown', (event) => { + const element = target(event); + if (!element) return; + if (event.key === 'Escape') { + event.stopImmediatePropagation(); + emphasize(null); + return; + } + if (event.key !== 'Enter' && event.key !== ' ') return; + event.preventDefault(); + // Activation belongs to note navigation, even when its marker is selected. + event.stopImmediatePropagation(); + const isNote = element.hasAttribute('data-note-spec-index'); + const peer = linked().find( + (candidate) => + key(candidate) === key(element) && + candidate.hasAttribute('data-note-spec-index') !== isNote, + ); + peer?.focus(); + emphasize(key(element)); + }); +} diff --git a/packages/vanilla/src/renderers/annotations.ts b/packages/vanilla/src/renderers/annotations.ts index adfce582..f7af6bd8 100644 --- a/packages/vanilla/src/renderers/annotations.ts +++ b/packages/vanilla/src/renderers/annotations.ts @@ -184,6 +184,14 @@ function renderAnnotation( // Footnote marker: annotation was demoted by auto-thinning. Render a numbered // dot at the data point instead of the full label text. if (annotation.footnoteIndex != null && annotation.label) { + if (annotation.specIndex !== undefined) + g.setAttribute('data-note-marker-index', String(annotation.specIndex)); + g.setAttribute('tabindex', '0'); + g.setAttribute('role', 'button'); + g.setAttribute( + 'aria-label', + `Annotation ${annotation.footnoteIndex}: ${annotation.label.text.replaceAll('**', '')}. Activate to read the note.`, + ); // Prefer the data point. A suppressed connector (too short to be worth // drawing) still leaves a resolved dot there, so fall through to it before // giving up on the label position. @@ -222,6 +230,8 @@ function renderAnnotation( ...annotation.label.style, fontSize: 9, fontWeight: 600, + textAnchor: 'middle', + dominantBaseline: 'central', }); num.textContent = String(annotation.footnoteIndex); g.appendChild(num); diff --git a/packages/vanilla/src/renderers/chrome.ts b/packages/vanilla/src/renderers/chrome.ts index 13bafc0a..28ed8697 100644 --- a/packages/vanilla/src/renderers/chrome.ts +++ b/packages/vanilla/src/renderers/chrome.ts @@ -14,15 +14,8 @@ import { truncateToWidth, wrapText, } from '@opendata-ai/openchart-core'; -import { warnDeprecated } from '../deprecations'; import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom'; -/** @deprecated Read layout.chrome.noteBandHeight. */ -export function footnoteBandHeight(layout: ChartLayout): number { - warnDeprecated('footnoteBandHeight is deprecated; read layout.chrome.noteBandHeight.'); - return computeNoteBandHeight(layout.chrome, layout.theme); -} - export function renderChromeElement( parent: SVGElement, element: ResolvedChromeElement, @@ -168,6 +161,10 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void { textAnchor: 'start' as const, }; + const list = createSVGElement('g'); + list.setAttribute('role', 'list'); + list.setAttribute('aria-label', 'Annotation notes, in source order'); + g.appendChild(list); for (let i = 0; i < chrome.footnotes.length; i++) { const f = chrome.footnotes[i]; const y = @@ -177,20 +174,33 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void { noteLine * lineHeight; const el = createSVGElement('text'); el.setAttribute('class', 'oc-footnotes'); + el.setAttribute('role', 'listitem'); + el.setAttribute('tabindex', '0'); + el.setAttribute( + 'aria-label', + `${f.index}. ${f.text.replaceAll('**', '')}${f.subtitle ? `. ${f.subtitle.replaceAll('**', '')}` : ''}`, + ); if (f.specIndex !== undefined) el.setAttribute('data-note-spec-index', String(f.specIndex)); if (f.id !== undefined) el.setAttribute('data-note-id', f.id); setAttrs(el, { x: pad, y }); applyTextStyle(el, style); - if (f.lines) { - f.lines.forEach((line, index) => { - const span = createSVGElement('tspan'); - setAttrs(span, { x: pad, dy: index === 0 ? 0 : lineHeight }); - span.textContent = line; - el.appendChild(span); - }); - } else el.textContent = `${f.index}. ${f.text}`; + const lines = f.lines ?? [`${f.index}. ${f.text}`]; + lines.forEach((line, index) => { + const span = createSVGElement('tspan'); + setAttrs(span, { x: pad, dy: index === 0 ? 0 : lineHeight }); + const runs = f.spans?.[index]; + if (runs) { + for (const run of runs) { + const part = createSVGElement('tspan'); + if (run.bold) part.setAttribute('font-weight', '700'); + part.textContent = run.text; + span.appendChild(part); + } + } else span.textContent = line; + el.appendChild(span); + }); noteLine += f.lines?.length ?? 1; - g.appendChild(el); + list.appendChild(el); } } diff --git a/packages/vanilla/src/svg-renderer.ts b/packages/vanilla/src/svg-renderer.ts index a6a5d021..e8b5bf17 100644 --- a/packages/vanilla/src/svg-renderer.ts +++ b/packages/vanilla/src/svg-renderer.ts @@ -1,3 +1,4 @@ +import { wireAnnotationNotes } from './interactions/annotation-notes'; /** * SVG renderer: converts a ChartLayout into SVG DOM elements. * @@ -386,6 +387,7 @@ export function renderChartSVG( svg.style.position = 'relative'; } + wireAnnotationNotes(svg); container.appendChild(svg); return svg; } diff --git a/packages/vanilla/src/table-mount.ts b/packages/vanilla/src/table-mount.ts index 1cf91f5c..7b9bebe6 100644 --- a/packages/vanilla/src/table-mount.ts +++ b/packages/vanilla/src/table-mount.ts @@ -251,7 +251,7 @@ export function createTable( compact && currentSpec.mobileLayout === 'priority', ); - if (currentSpec.density !== undefined || currentSpec.compact === true) return; + if (currentSpec.density !== undefined) return; const columns = currentLayout?.columns.length ?? 0; const condensed = diff --git a/packages/vanilla/src/table-renderer.ts b/packages/vanilla/src/table-renderer.ts index 6d284d77..27329cd2 100644 --- a/packages/vanilla/src/table-renderer.ts +++ b/packages/vanilla/src/table-renderer.ts @@ -330,10 +330,6 @@ export function renderTable( // Apply class modifiers wrapper.classList.add(`oc-table--${layout.density}`); - if (layout.density === 'condensed') { - // Deprecated alias, kept one release for stylesheets targeting it. - wrapper.classList.add('oc-table--compact'); - } if (layout.striped) { wrapper.classList.add('oc-table--striped'); } From 1f0e2a010fd1aebf0506fa6768fbb0160e407acb Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 16:02:37 -0500 Subject: [PATCH 08/17] fix: preserve v9 themes and annotation behavior across updates --- CHANGELOG.md | 12 +- docs/codebase-map.md | 3 +- docs/theming-tokens.md | 4 +- llms-full.txt | 4 +- packages/core/src/styles/base.css | 2 + .../src/__tests__/migrate-spec-v8.test.ts | 24 ++ packages/engine/src/compile.ts | 28 +-- packages/engine/src/compile/layer.ts | 7 +- .../__tests__/v9-removed-fields.test.ts | 61 +++++ packages/engine/src/compiler/normalize.ts | 17 +- packages/engine/src/index.ts | 1 + packages/engine/src/migration/v8.ts | 16 +- .../src/__tests__/annotation-notes.test.ts | 17 ++ .../src/__tests__/export-canvas.test.ts | 35 ++- .../src/__tests__/export-mount-themes.test.ts | 230 ++++++++++++++++++ .../vanilla/src/__tests__/font-race.test.ts | 70 ++++++ .../src/__tests__/theme-style-block.test.ts | 10 +- packages/vanilla/src/barlist-mount.ts | 2 + packages/vanilla/src/dom-helpers.ts | 4 + .../src/interactions/annotation-notes.ts | 10 +- packages/vanilla/src/map-mount.ts | 2 + packages/vanilla/src/mount.ts | 62 +++-- packages/vanilla/src/renderers/annotations.ts | 5 +- packages/vanilla/src/renderers/chrome.ts | 2 +- packages/vanilla/src/sankey-mount.ts | 7 + packages/vanilla/src/theme-style-block.ts | 26 +- packages/vanilla/src/theme-tokens.ts | 15 ++ packages/vanilla/src/tilemap-mount.ts | 2 + 28 files changed, 596 insertions(+), 82 deletions(-) create mode 100644 packages/vanilla/src/__tests__/export-mount-themes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 0125ba71..2cb93c93 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -6,7 +6,9 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this ## [Unreleased] -### 9.0.0 — annotation and figure defaults +## [9.0.0] - 2026-10-17 + +### Annotation and figure defaults - Adopt 12px small/axis/source/byline/footer text and 16px/12px chrome spacing. Background stays transparent; default axis and eyebrow colors are unchanged. @@ -27,7 +29,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Retune default reference-rule and range ink; derive marker and plate fills from the resolved neutral surface while preserving authored colors. -### 9.0.0 — breaking API and authoring changes +### Breaking API and authoring changes - Make `onEdit(EditTransaction)` the sole durable editing callback. Remove `ElementEdit`, `onEditTransaction`, `onAnnotationEdit`, and `onTextEdit`; use @@ -52,7 +54,7 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this 80% agreement, and recognize numeric strings before dates. Explicit field types remain authoritative. -### 9.0.0 — themes and migration +### Themes and migration - Deep-merge spec/provider/host themes; leaf themes override parent themes and mount/compile options have highest precedence. Arrays and color pairs replace @@ -63,6 +65,10 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/), and this - Stamp only authored CSS theme overrides through one renderer helper. Host CSS can override omitted tokens; dark defaults include all registered tokens and SVG/raster exports retain live CSS token values. +- Keep layer font measurement and font-load updates aligned with the primary + leaf theme; keep hidden annotation notes out of keyboard navigation. +- Normalize breakpoint annotations and apply endpoint-label patches consistently + with root specs, including migrated legacy labels. - Extend `migrateSpecV8` with `{ styling: 'v9' }` for alias-only migration. Default migration materializes former v8 styling, including annotation colors/spacing, and reports repairs without mutating input. Layout pixel parity is not promised. diff --git a/docs/codebase-map.md b/docs/codebase-map.md index cbd1544f..745a1ed1 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -282,7 +282,7 @@ The vanilla adapter (`mount.ts`) takes the resulting `ChartLayout` and calls `re - **Default theme:** `DEFAULT_THEME` literal in `packages/core/src/theme/defaults.ts`. Single source of truth. - **User overrides:** public `mergeThemeConfig()` deep-merges authored configuration; arrays and TokenValues are atomic, null replaces. Layer leaf values beat parent values; mount options beat both. Framework providers merge into component props. `resolveTheme()` adds defaults and returns `ResolvedTheme`, retaining internal `_authored` metadata for CSS and dark adaptation. Public `Theme` is removed; `ThemeBase` remains internal. - **Dark mode:** `adaptTheme(resolved)` in `dark-mode.ts` swaps surface tokens, adapts categorical palette via binary-search on lightness to preserve contrast against the dark background. -- **Authored CSS tokens:** `packages/vanilla/src/theme-tokens.ts` → `stampThemeProperties()` is shared by chart/table/graph/map and specialized renderers. It stamps authored overrides only, restoring previous host inline values when an override disappears. `theme-style-block.ts` copies computed tokens into export clones. +- **Authored CSS tokens:** `packages/vanilla/src/theme-tokens.ts` → `stampThemeProperties()` is shared by chart/table/graph/map and specialized renderers. It stamps authored overrides only, restoring previous host inline values when an override disappears. `theme-style-block.ts` → `prepareSettledExportTheme()` preserves live computed tokens across chart, map, tilemap, Sankey, and barlist exports, or clears authored inline overrides when an explicit export theme wins. - **CSS tokens** (`tokens.css`, `dark.css`) are **generated** from `packages/core/src/styles/token-definitions.ts`. Run `bun run generate:tokens` after editing. CI enforces freshness via `check:generated`. - **Cascade layers:** all CSS is wrapped in `@layer oc.*` sub-layers declared in `index.css`: `oc.tokens, oc.base, oc.components, oc.animation, oc.reduced-motion`. Consumer un-layered CSS beats all openchart rules (v8 breaking change, see `docs/migrating-v8.md` section 14). @@ -433,6 +433,7 @@ colors retain their previous uses. | Shared bounded field inference | `core/src/helpers/spec-builders.ts`: public `inferFieldType`; normalization and chart helpers sample up to 50 values, with numeric strings before temporal matching. The engine duplicate is removed. | | V9 default and removal guards | `engine/src/annotations/__tests__/v9-defaults.test.ts`, `compiler/__tests__/v9-removed-fields.test.ts`; migration fixtures in `engine/src/__tests__/migrate-spec-v8.test.ts`. | | Connector crossing score | `engine/src/annotations/connector-crossings.ts`: internal `ConnectorCrossings`; geometry tests in `annotations/__tests__/connector-crossings.test.ts`. | +| Layer theme ownership | `engine/src/compile/layer.ts`: public `resolveLayerTheme` shares primary-leaf theme precedence with mount font measurement. | | Former line label implementation | `engine/src/charts/line/labels.ts` removed; endpoint columns use `engine/src/endpoint-labels/` and `vanilla/src/renderers/endpoint-labels.ts`. | The migration guide records remaining type/field removals. The built-in `editorial` diff --git a/docs/theming-tokens.md b/docs/theming-tokens.md index 92d1faaa..5b9c307d 100644 --- a/docs/theming-tokens.md +++ b/docs/theming-tokens.md @@ -48,8 +48,8 @@ const theme: ThemeConfig = { subtitle: { fontWeight: 400, lineHeight: 1.4 }, eyebrow: { color: '#e3120b', fontWeight: 700 }, - // Plain strings still work (color-only, backward-compatible) - source: '#666', + // Color-only overrides use the same object form. + source: { color: '#666' }, }, // New: series color assignment strategy diff --git a/llms-full.txt b/llms-full.txt index 87b43598..718752ad 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -4275,8 +4275,8 @@ const theme: ThemeConfig = { subtitle: { fontWeight: 400, lineHeight: 1.4 }, eyebrow: { color: '#e3120b', fontWeight: 700 }, - // Plain strings still work (color-only, backward-compatible) - source: '#666', + // Color-only overrides use the same object form. + source: { color: '#666' }, }, // New: series color assignment strategy diff --git a/packages/core/src/styles/base.css b/packages/core/src/styles/base.css index ed7dead9..6717984b 100644 --- a/packages/core/src/styles/base.css +++ b/packages/core/src/styles/base.css @@ -63,6 +63,8 @@ packages/vanilla/src/dom-helpers.ts for why. Keep the two in sync. */ .oc-sr-only { position: absolute; + top: 0; + left: 0; width: 1px; height: 1px; padding: 0; diff --git a/packages/engine/src/__tests__/migrate-spec-v8.test.ts b/packages/engine/src/__tests__/migrate-spec-v8.test.ts index 840ba33c..aff2021d 100644 --- a/packages/engine/src/__tests__/migrate-spec-v8.test.ts +++ b/packages/engine/src/__tests__/migrate-spec-v8.test.ts @@ -12,6 +12,30 @@ const chart = { encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, }; describe('migrateSpecV8 alias repairs', () => { + it('preserves hardcoded v8 reference typography independently of the small theme size', () => { + const source = { + ...chart, + theme: { fonts: { sizes: { small: 20 } } }, + annotations: [ + { type: 'refline', y: 0, label: 'Threshold' }, + { type: 'range', x1: 0, x2: 1, label: 'Interval' }, + { type: 'refline', y: 1, label: 'Authored', fontSize: 17 }, + ], + }; + const migrated = migrateSpecV8(source); + expect( + compileChart(migrated.spec, { width: 800, height: 600 }).annotations.map( + (a) => a.label?.style.fontSize, + ), + ).toEqual([11, 11, 17]); + expect(migrateSpecV8(migrated.spec).repairs).toEqual([]); + expect( + compileChart(migrateSpecV8(source, { styling: 'v9' }).spec, { + width: 800, + height: 600, + }).annotations.map((a) => a.label?.style.fontSize), + ).toEqual([20, 20, 17]); + }); it.each([ [ { type: 'table', compact: true }, diff --git a/packages/engine/src/compile.ts b/packages/engine/src/compile.ts index 328d9a45..c0611fa6 100644 --- a/packages/engine/src/compile.ts +++ b/packages/engine/src/compile.ts @@ -19,6 +19,7 @@ import type { AnimationSpec, ChartLayout, ChartSpec, + ChartSpecOverride, CompileOptions, CompileTableOptions, Encoding, @@ -88,6 +89,7 @@ import { collectContrastWarnings } from './compiler/a11y-warnings'; import { resolveAnimation } from './compiler/animation'; import { compile as compileSpec } from './compiler/index'; import { resolveMarkRenderMode } from './compiler/mark-render-mode'; +import { normalizeAnnotations } from './compiler/normalize'; import { buildSparklineAreaGradient, computeTrendFromData, @@ -343,22 +345,7 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char // Apply breakpoint-conditional overrides from the expanded spec const rawSpec = expandedSpec as Record; const overrides = rawSpec.overrides as - | Partial< - Record< - string, - { - chrome?: unknown; - labels?: unknown; - legend?: unknown; - annotations?: unknown; - animation?: unknown; - display?: unknown; - encoding?: unknown; - watermark?: unknown; - crosshair?: unknown; - } - > - > + | Partial> | undefined; // Build userExplicit descriptor BEFORE applying any overrides so we capture @@ -381,8 +368,7 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char chrome: hasChromeKeys(rawSpec.chrome) || hasChromeKeys(bpForExplicit?.chrome), legend: rawSpec.legend !== undefined || bpForExplicit?.legend !== undefined, endpointLabels: - rawSpec.endpointLabels !== undefined || - (bpForExplicit as Record | undefined)?.endpointLabels !== undefined, + rawSpec.endpointLabels !== undefined || bpForExplicit?.endpointLabels !== undefined, // Axis explicitness tracks "user opted IN to render the axis" — `axis: false` // is an explicit opt-OUT and must not flip these flags on. Sparkline mode // reads them to decide whether to reserve axis space; treating `false` as @@ -402,6 +388,8 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char if (overrides?.[breakpoint]) { const bp = overrides[breakpoint]!; + if (bp.endpointLabels !== undefined) + chartSpec = { ...chartSpec, endpointLabels: bp.endpointLabels }; if (bp.chrome) { chartSpec = { ...chartSpec, @@ -439,10 +427,12 @@ function compileChartInternal(spec: unknown, optionsInput: CompileOptions): Char }; } if (bp.annotations) { + const annotationWarnings: string[] = []; chartSpec = { ...chartSpec, - annotations: bp.annotations as NormalizedChartSpec['annotations'], + annotations: normalizeAnnotations(bp.annotations, annotationWarnings), }; + emitSpecWarnings(annotationWarnings, options.onWarn); // User explicitly provided annotations at this breakpoint — override the // responsive strategy so they render inline instead of being stripped. strategy = { ...strategy, annotationPosition: 'inline' }; diff --git a/packages/engine/src/compile/layer.ts b/packages/engine/src/compile/layer.ts index 2b9df1e8..e90a3418 100644 --- a/packages/engine/src/compile/layer.ts +++ b/packages/engine/src/compile/layer.ts @@ -1040,6 +1040,11 @@ function withSharedDomains(leaf: ChartSpec, shared: SharedDomains): ChartSpec { return { ...leaf, encoding } as unknown as ChartSpec; } +/** Authored theme ownership shared by layer compilation and host text measurement. */ +export function resolveLayerTheme(layerSpec: LayerSpec, primary = flattenLayers(layerSpec)[0]) { + return mergeThemeConfig(layerSpec.theme, primary?.theme); +} + export function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): ChartSpec { const allData = leaves.flatMap((leaf) => leaf.data); @@ -1055,7 +1060,7 @@ export function buildPrimarySpec(leaves: ChartSpec[], layerSpec: LayerSpec): Cha labels: layerSpec.labels ?? leaves[0].labels, legend: layerSpec.legend ?? leaves[0].legend, responsive: layerSpec.responsive ?? leaves[0].responsive, - theme: mergeThemeConfig(layerSpec.theme, leaves[0].theme), + theme: resolveLayerTheme(layerSpec, leaves[0]), watermark: layerSpec.watermark ?? leaves[0].watermark, hiddenSeries: layerSpec.hiddenSeries ?? leaves[0].hiddenSeries, endpointLabels: layerSpec.endpointLabels ?? leaves[0].endpointLabels, diff --git a/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts b/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts index a5a36e36..c5e3aefd 100644 --- a/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts +++ b/packages/engine/src/compiler/__tests__/v9-removed-fields.test.ts @@ -8,6 +8,67 @@ const base = { data: [{ x: 1, y: 2 }], encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, }; +it.each([ + 'line', + 'area', +])('migrates %s breakpoint patches without inventing a layer container', (mark) => { + const source = { + ...base, + mark, + encoding: { ...base.encoding, x: { field: 'x', type: 'ordinal' } }, + overrides: { full: { legend: { show: false } } }, + }; + for (const styling of ['v8', 'v9'] as const) { + const result = migrateSpecV8(source, { styling }); + expect(() => compileChart(result.spec, { width: 1280, height: 600 })).not.toThrow(); + expect(migrateSpecV8(result.spec, { styling }).repairs).toEqual([]); + } +}); + +it('applies boolean and object endpoint-label breakpoint overrides', () => { + const source = { + ...base, + mark: 'line', + data: [ + { x: 1, y: 2, s: 'A' }, + { x: 2, y: 3, s: 'A' }, + { x: 1, y: 4, s: 'B' }, + { x: 2, y: 5, s: 'B' }, + ], + encoding: { + ...base.encoding, + x: { field: 'x', type: 'ordinal' }, + color: { field: 's', type: 'nominal' }, + }, + endpointLabels: true, + }; + expect( + compileChart( + { ...source, overrides: { full: { endpointLabels: false } } }, + { width: 1280, height: 600 }, + ).endpointLabels?.entries ?? [], + ).toEqual([]); + const withLabel = compileChart( + { ...source, overrides: { full: { endpointLabels: { content: 'label' } } } }, + { width: 1280, height: 600 }, + ); + expect(withLabel.endpointLabels!.entries.map((entry) => entry.value)).toEqual(['', '']); +}); + +it('normalizes active breakpoint annotation defaults exactly as root annotations', () => { + const annotations = [ + { type: 'refline', y: 1.9, label: 'First threshold' }, + { type: 'refline', y: 1.91, label: 'Second threshold' }, + ]; + const root = compileChart({ ...base, annotations }, { width: 1280, height: 600 }); + const overridden = compileChart( + { ...base, overrides: { full: { annotations } } }, + { width: 1280, height: 600 }, + ); + expect(overridden.annotations.map((a) => a.bounds)).toEqual( + root.annotations.map((a) => a.bounds), + ); +}); it('rejects retired breakpoint annotations even when another breakpoint is active', () => { const spec = { ...base, diff --git a/packages/engine/src/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index 2d2e59bf..ac3b7755 100644 --- a/packages/engine/src/compiler/normalize.ts +++ b/packages/engine/src/compiler/normalize.ts @@ -122,15 +122,13 @@ function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: strin // --------------------------------------------------------------------------- /** Apply default styles to annotations that don't have them. */ -function normalizeAnnotations( +export function normalizeAnnotations( annotations: Annotation[] | undefined, warnings: string[], ): Annotation[] { if (!annotations || annotations.length === 0) return []; - // The spec-sugar pass rewrites the deprecated `type: 'rule'` annotation - // alias to `'refline'` (with a warning) before validation/normalization - // ever runs, so by this point every annotation is in canonical form. + // Retired aliases are rejected before normalization; migration accepts v8 input. return annotations.map((ann) => { switch (ann.type) { case 'text': @@ -142,18 +140,13 @@ function normalizeAnnotations( ) { warnings.push('Annotation connector.bend is ignored unless connector.type is curve.'); } - // Deliberately does NOT default fontSize/fontWeight. The annotation - // layer owns those (DEFAULT_ANNOTATION_FONT_SIZE, and the lede rule - // that promotes a subtitle-bearing primary line to bold). Stamping - // them here makes `annotation.fontWeight` always defined, so - // `resolveLedeFontWeight` can never fire and the size default can - // never apply — both features become dead code. + // The annotation layer owns typography so authored values remain + // distinguishable from theme/default choices. return { ...ann, opacity: ann.opacity ?? 1, }; - // Omitted ink resolves through theme tokens. Their 8.x defaults preserve - // the colors historically stamped here; explicit authored ink stays intact. + // Omitted ink resolves through theme tokens; authored ink stays intact. case 'range': return { ...ann, diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index ca0e8838..d1899534 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -22,6 +22,7 @@ export { compileTable, compileTileMap, } from './compile'; +export { resolveLayerTheme } from './compile/layer'; // Lazy edge-tooltip builder (graph edge hover content on demand). export { buildEdgeTooltip } from './graphs/compile-graph'; diff --git a/packages/engine/src/migration/v8.ts b/packages/engine/src/migration/v8.ts index 4d494e93..2fb3f99d 100644 --- a/packages/engine/src/migration/v8.ts +++ b/packages/engine/src/migration/v8.ts @@ -152,6 +152,8 @@ export function migrateSpecV8( if (annotation[key] === undefined) replace(annotation, key, value, at, 'Preserve the v8 annotation default.'); }; + if (annotation.type === 'refline' || annotation.type === 'range') + setDefault('fontSize', 11); if (annotation.type === 'text') { setDefault('maxWidth', null); if (annotation.subtitle) { @@ -262,16 +264,18 @@ export function migrateSpecV8( const endpoint = current.endpointLabels ?? inherited.endpointLabels; for (const [breakpoint, override] of Object.entries(node(current.overrides) ?? {})) { const overrideNode = node(override); - if (overrideNode) - visit(overrideNode, `${path}/overrides/${escapePointer(breakpoint)}`, { + if (overrideNode) { + const owner: Node = { ...inherited, ...current, data, encoding: effectiveEncoding, - // An override is a shallow patch, not another container to traverse. - overrides: undefined, - layer: undefined, - }); + }; + // Shape guards distinguish absence from an undefined layer key. + delete owner.overrides; + delete owner.layer; + visit(overrideNode, `${path}/overrides/${escapePointer(breakpoint)}`, owner); + } } if (Array.isArray(current.layer)) current.layer.forEach((child, index) => { diff --git a/packages/vanilla/src/__tests__/annotation-notes.test.ts b/packages/vanilla/src/__tests__/annotation-notes.test.ts index fb548e0c..4521e8b9 100644 --- a/packages/vanilla/src/__tests__/annotation-notes.test.ts +++ b/packages/vanilla/src/__tests__/annotation-notes.test.ts @@ -50,6 +50,23 @@ const press = (element: Element, key: string) => element.dispatchEvent(new KeyboardEvent('keydown', { key, bubbles: true, cancelable: true })); describe('source-linked annotation notes', () => { + it.each([ + false, + true, + ])('keeps hidden notes out of keyboard navigation across compatible updates (initially hidden=%s)', (hidden) => { + const m = mount({ ...spec, a11y: { hidden } }); + for (const nextHidden of [hidden, !hidden, hidden]) { + m.chart.update({ ...spec, a11y: { hidden: nextHidden } }); + const svg = m.container.querySelector('svg')!; + const note = svg.querySelector('[data-note-spec-index]')!; + const marker = svg.querySelector('[data-note-marker-index]')!; + expect(note.getAttribute('tabindex')).toBe(nextHidden ? null : '0'); + expect(marker.getAttribute('tabindex')).toBe(nextHidden ? null : '0'); + const event = new KeyboardEvent('keydown', { key: 'Enter', bubbles: true, cancelable: true }); + note.dispatchEvent(event); + expect(event.defaultPrevented).toBe(!nextHidden); + } + }); it('renders source-ordered, accessible complete notes with rich text', () => { const m = mount(); expect( diff --git a/packages/vanilla/src/__tests__/export-canvas.test.ts b/packages/vanilla/src/__tests__/export-canvas.test.ts index 9ceeddf5..c7053795 100644 --- a/packages/vanilla/src/__tests__/export-canvas.test.ts +++ b/packages/vanilla/src/__tests__/export-canvas.test.ts @@ -7,7 +7,7 @@ * chart must produce the same bytes as exporting the SVG-mode twin. */ -import type { ChartSpec } from '@opendata-ai/openchart-core'; +import { type ChartSpec, resolveTheme } from '@opendata-ai/openchart-core'; import { compileChart } from '@opendata-ai/openchart-engine'; import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; import { createContainer } from '../__test-fixtures__/dom'; @@ -67,6 +67,39 @@ const normalize = (s: string) => s.replace(/oc-(clip|grad|pattern)-\d+/g, 'oc-$1 // timeout here would read as a canvas bug rather than a busy runner. vi.setConfig({ testTimeout: 20_000 }); +it.each([ + 'svg', + 'canvas', +] as const)('preserves live host tokens in mounted %s exports', async (renderer) => { + const { container, chart } = mount(scatter(10), renderer); + const live = container.querySelector('svg.oc-chart') as SVGElement; + live.style.setProperty('--oc-text', '#123456'); + const plain = chart.export('svg'); + expect(plain).toContain('--oc-text: #123456'); + expect(plain).toContain('data-oc-theme'); + expect(await chart.export('svg-with-fonts', { embedFonts: false })).toContain( + '--oc-text: #123456', + ); + chart.destroy(); +}); + +it.each([ + 'svg', + 'canvas', +] as const)('explicit export theme wins over authored %s tokens', async (renderer) => { + const spec = { ...scatter(10), theme: { colors: { text: '#123456' } } }; + const { chart } = mount(spec, renderer); + const out = await chart.export('svg-with-fonts', { + embedFonts: false, + theme: resolveTheme({ colors: { text: '#abcdef' } }), + }); + const svg = new DOMParser().parseFromString(out, 'image/svg+xml').documentElement; + expect(svg.getAttribute('style') ?? '').not.toContain('--oc-text: #123456'); + expect(svg.querySelector('style[data-oc-theme]')?.textContent).toContain('--oc-text: #abcdef'); + expect(svg.querySelectorAll('style[data-oc-theme]')).toHaveLength(1); + chart.destroy(); +}); + describe('vector export (at or below the point cap)', () => { it('re-materializes the dots, gridlines and background the canvas took over', () => { const { container, chart } = mount(scatter(1000), 'canvas'); diff --git a/packages/vanilla/src/__tests__/export-mount-themes.test.ts b/packages/vanilla/src/__tests__/export-mount-themes.test.ts new file mode 100644 index 00000000..69d4dcf4 --- /dev/null +++ b/packages/vanilla/src/__tests__/export-mount-themes.test.ts @@ -0,0 +1,230 @@ +import { resolveTheme } from '@opendata-ai/openchart-core'; +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'; +import { createContainer } from '../__test-fixtures__/dom'; +import { createBarList } from '../barlist-mount'; +import { createGeoMap } from '../map-mount'; +import { createSankey } from '../sankey-mount'; +import { type CanvasStub, stubCanvas2D } from '../scatter-canvas/__tests__/canvas-stub'; +import { createTileMap } from '../tilemap-mount'; + +const rows = [ + { name: 'Alpha', value: 10 }, + { name: 'Beta', value: 20 }, +]; +const chrome = { title: 'Exported evidence' }; +const authoredTheme = { colors: { text: '#654321' } }; +const families = [ + { + name: 'barlist', + mount: (container: HTMLElement, themed = true) => + createBarList( + container, + { + type: 'barlist', + data: rows, + chrome, + theme: themed ? authoredTheme : undefined, + animation: false, + encoding: { + label: { field: 'name', type: 'nominal' }, + value: { field: 'value', type: 'quantitative' }, + }, + }, + { responsive: false }, + ), + }, + { + name: 'map', + mount: (container: HTMLElement, themed = true) => + createGeoMap( + container, + { + type: 'map', + data: rows, + chrome, + theme: themed ? authoredTheme : undefined, + animation: false, + geo: { + projection: 'mercator', + features: { + type: 'Topology', + objects: { + regions: { + type: 'GeometryCollection', + geometries: [ + { type: 'Polygon', id: 'Alpha', properties: { name: 'Alpha' }, arcs: [[0]] }, + ], + }, + }, + arcs: [ + [ + [0, 0], + [0, 1], + [1, 1], + [1, 0], + [0, 0], + ], + ], + }, + }, + encoding: { + key: { field: 'name', type: 'nominal' }, + color: { field: 'value', type: 'quantitative' }, + }, + }, + { responsive: false }, + ), + }, + { + name: 'sankey', + mount: (container: HTMLElement, themed = true) => + createSankey( + container, + { + type: 'sankey', + data: [{ from: 'Alpha', to: 'Beta', value: 10 }], + chrome, + theme: themed ? authoredTheme : undefined, + animation: false, + encoding: { + source: { field: 'from', type: 'nominal' }, + target: { field: 'to', type: 'nominal' }, + value: { field: 'value', type: 'quantitative' }, + }, + }, + { responsive: false }, + ), + }, + { + name: 'tilemap', + mount: (container: HTMLElement, themed = true) => + createTileMap( + container, + { + type: 'tilemap', + data: [{ state: 'CA', value: 10 }], + chrome, + theme: themed ? authoredTheme : undefined, + animation: false, + encoding: { + state: { field: 'state', type: 'nominal' }, + value: { field: 'value', type: 'quantitative' }, + }, + }, + { responsive: false }, + ), + }, +]; +const formats = ['svg', 'svg-with-fonts', 'png', 'jpg'] as const; +let canvas: CanvasStub; +let rasterSources: Blob[]; +const cleanup: Array<() => void> = []; + +beforeEach(() => { + canvas = stubCanvas2D(); + rasterSources = []; + // happy-dom has no image decoder or raster encoder. Keep the full export + // pipeline and inspect the real SVG blob handed to the browser decoder. + vi.spyOn(URL, 'createObjectURL').mockImplementation((blob) => { + rasterSources.push(blob as Blob); + return 'blob:export-test'; + }); + vi.spyOn(URL, 'revokeObjectURL').mockImplementation(() => {}); + vi.stubGlobal( + 'Image', + class { + onload?: () => void; + set src(_value: string) { + queueMicrotask(() => this.onload?.()); + } + }, + ); + vi.spyOn(HTMLCanvasElement.prototype, 'toBlob').mockImplementation((callback, type) => { + callback(new Blob(['rasterized'], { type })); + }); +}); +afterEach(() => { + for (const destroy of cleanup.splice(0)) destroy(); + canvas.restore(); + vi.restoreAllMocks(); + vi.unstubAllGlobals(); + document.body.innerHTML = ''; +}); + +/** Read the self-contained root token cascade, without the host stylesheet. */ +function exportedTextToken(svg: SVGElement): string { + const inline = svg.style.getPropertyValue('--oc-text'); + if (inline) return inline.trim(); + let value = ''; + for (const style of svg.querySelectorAll('style')) { + const sheet = new CSSStyleSheet(); + sheet.replaceSync(style.textContent ?? ''); + for (const rule of sheet.cssRules) { + if (rule instanceof CSSStyleRule && svg.matches(rule.selectorText)) { + value = rule.style.getPropertyValue('--oc-text').trim() || value; + } + } + } + return value; +} + +for (const family of families) { + describe(`${family.name} public exports`, () => { + it('preserves host stylesheet tokens when the spec leaves them unauthored', async () => { + const container = createContainer(); + container.classList.add('host-theme'); + const style = document.createElement('style'); + style.textContent = '.host-theme svg { --oc-text: #135790; }'; + document.head.appendChild(style); + cleanup.push(() => style.remove()); + const instance = family.mount(container, false); + cleanup.push(() => instance.destroy()); + expect(getComputedStyle(container.querySelector('svg')!).getPropertyValue('--oc-text')).toBe( + '#135790', + ); + const serialized = await instance.export('svg'); + const svg = new DOMParser().parseFromString(serialized as string, 'image/svg+xml') + .documentElement as unknown as SVGElement; + expect(exportedTextToken(svg)).toBe('#135790'); + }); + + it.each( + formats, + )('preserves live host tokens in %s without mutating the live SVG', async (format) => { + const container = createContainer(); + const instance = family.mount(container); + cleanup.push(() => instance.destroy()); + const live = container.querySelector('svg') as SVGElement; + live.style.setProperty('--oc-text', '#123456'); + const before = live.outerHTML; + const result = await instance.export(format, { embedFonts: false }); + const serialized = typeof result === 'string' ? result : await rasterSources.at(-1)!.text(); + const svg = new DOMParser().parseFromString(serialized, 'image/svg+xml') + .documentElement as unknown as SVGElement; + expect(exportedTextToken(svg)).toBe('#123456'); + expect(svg.querySelectorAll('style[data-oc-theme]')).toHaveLength(1); + expect(svg.querySelector('style')?.textContent).toContain('.oc-title'); + expect(live.outerHTML).toBe(before); + }); + + it.each( + formats, + )('lets an explicit export theme override authored and host tokens in %s', async (format) => { + const container = createContainer(); + const instance = family.mount(container); + cleanup.push(() => instance.destroy()); + const live = container.querySelector('svg') as SVGElement; + live.style.setProperty('--oc-text', '#123456'); + const result = await instance.export(format, { + embedFonts: false, + theme: resolveTheme({ colors: { text: '#abcdef' } }), + }); + const serialized = typeof result === 'string' ? result : await rasterSources.at(-1)!.text(); + const svg = new DOMParser().parseFromString(serialized, 'image/svg+xml') + .documentElement as unknown as SVGElement; + expect(exportedTextToken(svg)).toBe('#abcdef'); + expect(svg.querySelectorAll('style[data-oc-theme]')).toHaveLength(1); + expect(live.style.getPropertyValue('--oc-text')).toBe('#123456'); + }); + }); +} diff --git a/packages/vanilla/src/__tests__/font-race.test.ts b/packages/vanilla/src/__tests__/font-race.test.ts index 90ec8806..be3b5746 100644 --- a/packages/vanilla/src/__tests__/font-race.test.ts +++ b/packages/vanilla/src/__tests__/font-race.test.ts @@ -107,6 +107,55 @@ describe('createChart font-load recompile', () => { document.body.innerHTML = ''; }); + it.each([ + false, + true, + ])('measures the effective first leaf font through updates (nested=%s)', (nested) => { + installFonts(makeFontFaceSet(true)); + const ctx = stubCanvasContext(); + const spec = (family: string) => ({ + theme: { fonts: { family: 'ParentFont' } }, + layer: nested + ? [{ layer: [{ ...lineSpec, theme: { fonts: { family } } }] }] + : [{ ...lineSpec, theme: { fonts: { family } } }], + }); + for (const hostFamily of [undefined, 'HostFont']) { + const chart = createChart(container, spec('LeafFont'), { + theme: hostFamily ? { fonts: { family: hostFamily } } : undefined, + }); + expect(chart.layout.theme.fonts.family).toBe(hostFamily ?? 'LeafFont'); + expect(ctx.font).toContain(hostFamily ?? 'LeafFont'); + chart.update(spec('NextLeafFont')); + expect(chart.layout.theme.fonts.family).toBe(hostFamily ?? 'NextLeafFont'); + expect(ctx.font).toContain(hostFamily ?? 'NextLeafFont'); + chart.destroy(); + } + }); + + it.each([ + undefined, + 'HostFont', + ])('restores host font measurement after removing a leaf theme (%s)', (hostFont) => { + const fonts = makeFontFaceSet(false); + const check = vi.fn(() => false); + fonts.check = check; + installFonts(fonts); + const ctx = stubCanvasContext(); + const defaults = document.createElement('style'); + defaults.textContent = '.oc-root { --oc-font-family: Inter, sans-serif; }'; + document.body.appendChild(defaults); + if (hostFont) container.style.setProperty('--oc-font-family', hostFont); + const chart = createChart(container, { + layer: [{ ...lineSpec, animation: false, theme: { fonts: { family: 'LeafFont' } } }], + }); + chart.update({ layer: [{ ...lineSpec, animation: false }] }); + expect(ctx.font).toContain(hostFont ?? 'Inter'); + expect(ctx.font).not.toContain('LeafFont'); + expect(check).toHaveBeenLastCalledWith(`12px "${hostFont ?? 'Inter'}"`); + expect(container.style.getPropertyValue('--oc-font-family')).toBe(hostFont ?? ''); + chart.destroy(); + }); + it('marks pending then ready and re-renders once when fonts resolve late', async () => { const fonts = makeFontFaceSet(false); installFonts(fonts); @@ -129,6 +178,27 @@ describe('createChart font-load recompile', () => { chart.destroy(); }); + it('watches the updated leaf font and ignores the superseded font reload', async () => { + const fonts = makeFontFaceSet(false); + const check = vi.fn(() => false); + fonts.check = check; + installFonts(fonts); + stubCanvasContext(); + const spec = (family: string) => ({ + layer: [{ ...lineSpec, animation: false, theme: { fonts: { family } } }], + }); + const chart = createChart(container, spec('FirstLeaf')); + chart.update(spec('SecondLeaf')); + expect(check).toHaveBeenLastCalledWith('12px "SecondLeaf"'); + const before = Number(container.dataset.ocRenderGen); + fonts.resolveReady(); + await fonts.ready; + await Promise.resolve(); + expect(container.dataset.ocFontsState).toBe('ready'); + expect(Number(container.dataset.ocRenderGen)).toBe(before + 1); + chart.destroy(); + }); + it('marks ready immediately and does not re-render when fonts already loaded', async () => { const fonts = makeFontFaceSet(true); installFonts(fonts); diff --git a/packages/vanilla/src/__tests__/theme-style-block.test.ts b/packages/vanilla/src/__tests__/theme-style-block.test.ts index f7733572..c6f9782a 100644 --- a/packages/vanilla/src/__tests__/theme-style-block.test.ts +++ b/packages/vanilla/src/__tests__/theme-style-block.test.ts @@ -26,7 +26,7 @@ describe('buildThemeStyleBlock', () => { // The custom properties the rules reference must be defined on svg.oc-chart, // else the var() lookups resolve to nothing once detached from the page. - expect(css).toMatch(/svg\.oc-chart\s*\{[^}]*--oc-text:/); + expect(css).toMatch(/svg\.oc-chart[^{}]*\{[^}]*--oc-text:/); expect(css).toMatch(/--oc-positive:/); expect(css).toMatch(/--oc-negative:/); }); @@ -60,6 +60,14 @@ describe('injectThemeStyleBlock', () => { expect(svg.querySelector('defs')).not.toBeNull(); }); + it('replaces its existing block when preparation repeats with a new theme', () => { + const svg = makeSvg(); + injectThemeStyleBlock(svg, resolveTheme()); + injectThemeStyleBlock(svg, resolveTheme({ colors: { text: '#abcdef' } })); + expect(svg.querySelectorAll('style[data-oc-theme]')).toHaveLength(1); + expect(svg.querySelector('style')?.textContent).toContain('--oc-text: #abcdef'); + }); + it('is a no-op when theme is undefined', () => { const svg = makeSvg(); injectThemeStyleBlock(svg, undefined); diff --git a/packages/vanilla/src/barlist-mount.ts b/packages/vanilla/src/barlist-mount.ts index 42da96fe..162ef489 100644 --- a/packages/vanilla/src/barlist-mount.ts +++ b/packages/vanilla/src/barlist-mount.ts @@ -33,6 +33,7 @@ import { import { createMeasureText, resolveFontFamily, scheduleFontReload } from './measure-text'; import { observeResize } from './resize-observer'; import { resolveDarkMode } from './resolve-dark-mode'; +import { prepareSettledExportTheme } from './theme-style-block'; import { createTooltipManager, type TooltipManager } from './tooltip'; // --------------------------------------------------------------------------- @@ -317,6 +318,7 @@ export function createBarList( // Export settled geometry, independent of transient hover and animation. const terminalSvg = renderBarListSVG(currentLayout, { animate: false }); const resolvedOptions = { theme: currentLayout.theme, ...options_ }; + prepareSettledExportTheme(svgElement, terminalSvg, resolvedOptions.theme, !!options_?.theme); switch (format) { case 'svg': diff --git a/packages/vanilla/src/dom-helpers.ts b/packages/vanilla/src/dom-helpers.ts index 6a7fedf0..93779158 100644 --- a/packages/vanilla/src/dom-helpers.ts +++ b/packages/vanilla/src/dom-helpers.ts @@ -12,6 +12,10 @@ */ export function applySrOnlyStyles(el: HTMLElement): void { el.style.position = 'absolute'; + // A grown SVG may exceed an exact-height host. Do not let this hidden box + // use its static position after the SVG and add stray page scroll space. + el.style.top = '0'; + el.style.left = '0'; el.style.width = '1px'; el.style.height = '1px'; el.style.padding = '0'; diff --git a/packages/vanilla/src/interactions/annotation-notes.ts b/packages/vanilla/src/interactions/annotation-notes.ts index 821d535b..2b03e63a 100644 --- a/packages/vanilla/src/interactions/annotation-notes.ts +++ b/packages/vanilla/src/interactions/annotation-notes.ts @@ -4,7 +4,11 @@ const NOTE_SELECTOR = '[data-note-spec-index], [data-note-marker-index]'; export function wireAnnotationNotes(svg: SVGSVGElement): void { // A graphic's children are presentational to assistive technology. Keep the // chart's name, but expose its interactive note list and marker buttons. - if (svg.querySelector(NOTE_SELECTOR) && svg.getAttribute('role') === 'img') { + if ( + svg.getAttribute('aria-hidden') !== 'true' && + svg.querySelector(NOTE_SELECTOR) && + svg.getAttribute('role') === 'img' + ) { svg.setAttribute('role', 'group'); } // Compatible updates reconcile descendants into this SVG. Delegate events @@ -14,7 +18,9 @@ export function wireAnnotationNotes(svg: SVGSVGElement): void { const key = (element: Element) => element.getAttribute('data-note-spec-index') ?? element.getAttribute('data-note-marker-index'); const target = (event: Event) => - event.target instanceof Element ? event.target.closest(NOTE_SELECTOR) : null; + svg.getAttribute('aria-hidden') !== 'true' && event.target instanceof Element + ? event.target.closest(NOTE_SELECTOR) + : null; const emphasize = (index: string | null) => { for (const element of linked()) { element.classList.toggle('oc-note-active', index !== null && key(element) === index); diff --git a/packages/vanilla/src/map-mount.ts b/packages/vanilla/src/map-mount.ts index 87caeb56..ea2f4fb2 100644 --- a/packages/vanilla/src/map-mount.ts +++ b/packages/vanilla/src/map-mount.ts @@ -49,6 +49,7 @@ import type { Camera } from './story/camera-math'; import { interpolateCamera } from './story/camera-math'; import type { Tween } from './story/tween'; import { createTween, easingFns, storyMotion } from './story/tween'; +import { prepareSettledExportTheme } from './theme-style-block'; import { createTooltipManager, type TooltipManager } from './tooltip'; /** @@ -848,6 +849,7 @@ export function createGeoMap( } applyFocusDim(currentFocusIds, terminalSvg); const resolvedOptions = { theme: currentLayout.theme, ...options_ }; + prepareSettledExportTheme(svgElement, terminalSvg, resolvedOptions.theme, !!options_?.theme); switch (format) { case 'svg': diff --git a/packages/vanilla/src/mount.ts b/packages/vanilla/src/mount.ts index b9e3067c..88f7a3ad 100644 --- a/packages/vanilla/src/mount.ts +++ b/packages/vanilla/src/mount.ts @@ -38,6 +38,7 @@ import { compileLayer, facetMinHeight, legendGap, + resolveLayerTheme, } from '@opendata-ai/openchart-engine'; import { cancelAnimations, computeAnimationDuration, setupAnimationCleanup } from './animation'; import { @@ -86,7 +87,8 @@ import { createScatterCanvasLayer, type ScatterCanvasLayer } from './scatter-can import { createSeriesSearch, type SeriesSearchController } from './series-search'; import { renderChartSVG } from './svg-renderer'; import { createTextEditOverlay, createTextEditOverlayAtPosition } from './text-edit-overlay'; -import { stampThemeProperties } from './theme-tokens'; +import { prepareSettledExportTheme } from './theme-style-block'; +import { readWithoutThemeStamp, stampThemeProperties } from './theme-tokens'; import { createTooltipManager, type TooltipManager } from './tooltip'; import { canTransition, runTransition, type TransitionHandle } from './transition'; import { createYouDrawIt, type YouDrawItController } from './you-draw-it'; @@ -339,21 +341,20 @@ export function createChart( // before we build the text measurer. container.classList.add('oc-root'); - // Resolve the effective font family the way compile() will. compile merges - // { ...spec.theme, ...options.theme }, so options.theme wins over the - // spec-level theme; fall back to the container's computed font. Measuring - // against a different font than compile renders (e.g. a spec that sets - // theme.fonts.family) desyncs layout metrics and the font-reload watcher. + // Use the compiler's primary leaf ownership before the host override so + // measurement and font readiness agree with the rendered layer theme. function resolveEffectiveFont(candidate = currentSpec): string { + const theme = isLayerSpec(candidate) ? resolveLayerTheme(candidate) : candidate.theme; return ( options?.theme?.fonts?.family ?? - candidate.theme?.fonts?.family ?? - resolveFontFamily(container) + theme?.fonts?.family ?? + readWithoutThemeStamp(container, '--oc-font-family', () => resolveFontFamily(container)) ); } let fontFamily = resolveEffectiveFont(); let measureText = createMeasureText(fontFamily); let renderGen = 0; + let fontWatchGeneration = 0; // --------------------------------------------------------------------------- // Compilation @@ -1394,7 +1395,8 @@ export function createChart( const entranceWasRunning = cleanupAnimations != null; const nextFont = resolveEffectiveFont(newSpec); - const nextMeasure = nextFont === fontFamily ? measureText : createMeasureText(nextFont); + const fontChanged = nextFont !== fontFamily; + const nextMeasure = fontChanged ? createMeasureText(nextFont) : measureText; const nextLayout = compile(newSpec, nextMeasure, layoutOnly); // Capture only after compilation succeeds, before render() cancels the // current motion. A rejected edit must not leave geometry for a later update. @@ -1421,6 +1423,7 @@ export function createChart( selectedElement = updateOpts.selectedElement ?? null; } render(canTransitionSpecShape(prevSpec, newSpec), nextLayout); + if (fontChanged) watchCurrentFont(); // After render, check if we can run a smooth transition instead of the // instant swap that render() already performed. @@ -1542,6 +1545,14 @@ export function createChart( // chrome fills (metrics, eyebrow dot, legend) survive serialization away from // the page stylesheet. An explicit options.theme still wins. const theme = 'theme' in currentLayout ? currentLayout.theme : undefined; + // Re-rendering preserves settled geometry, but the detached scene has no + // host cascade. Carry the live tokens across unless export overrides them. + prepareSettledExportTheme( + svgElement, + svg, + exportOptions?.theme ?? theme, + !!exportOptions?.theme, + ); const withTheme = (o: T | undefined): T => ({ theme, ...(o ?? {}) }) as T; @@ -1660,22 +1671,23 @@ export function createChart( // Initial render render(); - // Recompile once after webfonts load. On real devices the primary font - // (e.g. Inter via display=swap) often swaps in after first paint, changing - // text metrics; without a re-measure, titles wrap wrong and labels collide. - // Desktop Chrome has the font cached, so it renders 'ready' immediately. - const pending = scheduleFontReload( - fontFamily, - () => !destroyed, - () => { - // Mark the reload owed, then trigger a recompile. render() flips the - // attribute to 'ready' once it actually recompiles — including the - // pendingResize replay after the entrance animation finishes. - fontsReloadPending = true; - resize(); - }, - ); - container.dataset.ocFontsState = pending ? 'pending' : 'ready'; + // Track the effective font after initial mount and spec updates. A delayed + // promise for a superseded font must not recompile the current chart. + function watchCurrentFont(): void { + const generation = ++fontWatchGeneration; + const pending = scheduleFontReload( + fontFamily, + () => !destroyed && generation === fontWatchGeneration, + () => { + // render() marks ready only after the new metrics actually compile, + // including deferred resize work during an entrance animation. + fontsReloadPending = true; + resize(); + }, + ); + container.dataset.ocFontsState = pending ? 'pending' : 'ready'; + } + watchCurrentFont(); // Set up responsive resize if (options?.autoResize !== false) { diff --git a/packages/vanilla/src/renderers/annotations.ts b/packages/vanilla/src/renderers/annotations.ts index f7af6bd8..cdebdd3b 100644 --- a/packages/vanilla/src/renderers/annotations.ts +++ b/packages/vanilla/src/renderers/annotations.ts @@ -125,6 +125,7 @@ function renderAnnotation( annotation: ResolvedAnnotation, index: number, bgColor?: string, + hidden = false, ): void { const g = createSVGElement('g'); g.setAttribute('class', `oc-annotation oc-annotation-${annotation.type}`); @@ -186,7 +187,7 @@ function renderAnnotation( if (annotation.footnoteIndex != null && annotation.label) { if (annotation.specIndex !== undefined) g.setAttribute('data-note-marker-index', String(annotation.specIndex)); - g.setAttribute('tabindex', '0'); + if (!hidden) g.setAttribute('tabindex', '0'); g.setAttribute('role', 'button'); g.setAttribute( 'aria-label', @@ -401,7 +402,7 @@ export function renderAnnotations(parent: SVGElement, layout: ChartLayout): void // defaults to 'transparent', which made every halo a no-op on default themes. const bgColor = resolvedSurface(layout.theme); for (let i = 0; i < layout.annotations.length; i++) { - renderAnnotation(g, layout.annotations[i], i, bgColor); + renderAnnotation(g, layout.annotations[i], i, bgColor, layout.a11y.hidden); } } diff --git a/packages/vanilla/src/renderers/chrome.ts b/packages/vanilla/src/renderers/chrome.ts index 28ed8697..1e06ed41 100644 --- a/packages/vanilla/src/renderers/chrome.ts +++ b/packages/vanilla/src/renderers/chrome.ts @@ -175,7 +175,7 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void { const el = createSVGElement('text'); el.setAttribute('class', 'oc-footnotes'); el.setAttribute('role', 'listitem'); - el.setAttribute('tabindex', '0'); + if (!layout.a11y.hidden) el.setAttribute('tabindex', '0'); el.setAttribute( 'aria-label', `${f.index}. ${f.text.replaceAll('**', '')}${f.subtitle ? `. ${f.subtitle.replaceAll('**', '')}` : ''}`, diff --git a/packages/vanilla/src/sankey-mount.ts b/packages/vanilla/src/sankey-mount.ts index 8c570dec..aaca23f8 100644 --- a/packages/vanilla/src/sankey-mount.ts +++ b/packages/vanilla/src/sankey-mount.ts @@ -34,6 +34,7 @@ import { createMeasureText, resolveFontFamily, scheduleFontReload } from './meas import { observeResize } from './resize-observer'; import { resolveDarkMode } from './resolve-dark-mode'; import { renderSankeySVG } from './sankey-renderer'; +import { prepareSettledExportTheme } from './theme-style-block'; import { createTooltipManager, type TooltipManager } from './tooltip'; // --------------------------------------------------------------------------- @@ -562,6 +563,12 @@ export function createSankey( // Export settled geometry, independent of transient hover and animation. const terminalSvg = renderSankeySVG(currentLayout); const resolvedOptions = { theme: currentLayout.theme, ...exportOptions }; + prepareSettledExportTheme( + svgElement, + terminalSvg, + resolvedOptions.theme, + !!exportOptions?.theme, + ); switch (format) { case 'svg': diff --git a/packages/vanilla/src/theme-style-block.ts b/packages/vanilla/src/theme-style-block.ts index 9a6c2661..da543958 100644 --- a/packages/vanilla/src/theme-style-block.ts +++ b/packages/vanilla/src/theme-style-block.ts @@ -21,7 +21,7 @@ import { SVG_NS } from './renderers/svg-dom'; /** * Build the theme `