diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aeb1232..36c6795b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,6 +1,58 @@ # Changelog -## Unreleased — 8.10 +All notable changes to this project will be documented in this file. + +The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). + +## [Unreleased] + +## [8.9.1] - 2026-10-17 + +### Annotation layout and theme options + +- Add opt-in rich callout wrapping (`maxWidth`), `subtitleFontSize`, and compact + callout limits. Omitted fields retain existing output; null disables limits. +- Add reference-label placement at line ends, automatic contained placement, and + reserved outside lanes with wrapping and connectors, including facets and + independent-axis layers. +- Add seven annotation theme color tokens with light/dark authoring support; + explicit annotation ink remains authoritative. Rule/range tokens preserve the + existing compiled colors in both modes. +- Support facet `chromeLayout: 'grow'` and report engine-added height so auto-height + mounts and export count chrome, notes and facet growth once. +- Deprecate both `footnoteBandHeight` helpers in favor of resolved + `layout.chrome.noteBandHeight`, preserving their 8.x compatibility signatures. +- Materialize omitted 8.x defaults in `migrateSpecV8` while preserving authored + theme, responsive and layer ownership. Warn about inapplicable mark options + before shorthand expansion, with exact authored paths and repair guidance. + +### Annotation geometry and gestures + +- Add optional connector `bend` in `[-1, 1]` and engine-resolved curve control + points. Omitted/zero bend retains the existing curve; other connector types + warn and ignore bend. +- Export frozen annotation editing contexts, move/bend previews, and inverse + label placement from the engine. +- Keep authored zero offsets pinned so a committed placement is not nudged on release. + +- Convert the first label drag to an explicit anchor/offset and `responsive: false` + atomically, keeping the data point fixed. Remove placement-dependent right + margin reservation so the plot does not shift when the drag commits. +- Reserve note space from source annotation text, including subtitles and pinned + callouts, so dragging cannot resize the plot or figure. Auto-thinned charts may + retain unused note space. Expose the reserved height as `chrome.noteBandHeight` + so rendering, footer placement and export agree. Exhausted fixed-height budgets + report an actionable `layout.note-capacity` diagnostic. +- Add Alt+arrow (1px) and Alt+Shift+arrow (10px) annotation nudges, batched into + one transaction. Escape, pointer/touch cancellation, width changes and document + replacement cancel active drags without emitting edits. +- Animate label and data-anchor positions independently, rebuilding connectors + from engine geometry while preserving annotation enter fades. +- Make drop-line label and connector offsets round-trip through edit previews. +- Place right-side legends after endpoint labels in the gutter already reserved + for them, preventing column overlap in regular and layered charts. + +### Editing transactions and compatibility - Add atomic `EditTransaction` operations, pure `applyEditTransaction` with undo, optional revisions, and `onEditTransaction` in vanilla and all three wrappers. @@ -15,15 +67,6 @@ legacy APIs. Existing 8.x callbacks and optional-field defaults remain supported. - Centralize shallow theme merging and remove unused internal compatibility paths. - -All notable changes to this project will be documented in this file. - -The format is based on [Keep a Changelog](https://keepachangelog.com/), and this project adheres to [Semantic Versioning](https://semver.org/). - -## [Unreleased] - -## [8.9.1] - 2026-10-16 - ### Fixed - **maps:** automatically sized maps grow around wrapped headings and source notes, preserving room for geography on phones. Explicitly sized hosts keep their height budget. Map headings, legends and sources now share the same responsive gutter. diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 6aedfc46..2f636aef 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -56,13 +56,14 @@ | Annotations: top-level compile | `packages/engine/src/annotations/compute.ts` → `computeAnnotations()` | | Annotations: text resolution (anchor → offset → connector) | `packages/engine/src/annotations/resolve-text.ts` | | Annotations: refline / range | `packages/engine/src/annotations/resolve-refline.ts`, `resolve-range.ts` | -| Annotations: geometry (text bounds, anchor offsets, ray-box connector exit, connector rebuild, arrowhead points) | `packages/engine/src/annotations/geometry.ts` (`connectorExit`, `refreshConnector`, `computeArrowheadPoints`). No `computeConnectorOrigin` — that's the pre-v8 name. | +| Annotations: geometry (text bounds, anchor offsets, ray-box connector exit, connector rebuild, arrowhead points) | `packages/engine/src/annotations/geometry.ts` (`connectorExit`, `refreshConnector`, `automaticControlPoint`, `computeArrowheadPoints`). No `computeConnectorOrigin` — that's the pre-v8 name. | | Annotations: `**bold**` span parsing | `packages/engine/src/annotations/rich-text.ts` → `parseAnnotationSpans()`, `RichSpan`, `BOLD_SPAN_FONT_WEIGHT` | | Annotations: block repositioning (label + subtitle + bounds + connector move as one; auto-placement's `textAnchor` stamp) | `packages/engine/src/annotations/move.ts` → `moveAnnotationTo()`, `moveAnnotationBy()`, `annotationBlockBounds()` | | 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()` (internal). `AnnotationFootnote.lines` carries engine-wrapped display lines to rendering/export. | +| 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: 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` | | Legend layout (entries, wrapping, positioning, `entryPositions`) | `packages/engine/src/legend/compute.ts`, `wrap.ts`; internal `measureCategoricalLegendContent()` reuses the same measurement for merged layer entries. | @@ -259,7 +260,7 @@ The vanilla adapter (`mount.ts`) takes the resulting `ChartLayout` and calls `re - **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. - **`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 bottom-margin reserve loop mirrors the single-chart path, `frozenChartArea` guard included. +- **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). ## Chrome system @@ -365,12 +366,12 @@ These are easy mistakes — written down so the next agent doesn't search for th The browser authoring application lives in the separate [OpenStudio repository](https://github.com/tryopendata/openstudio). Its UI, imports, persistence and deployment are not part of this codebase. -## Track A 8.10 additions +## Track A phase 1 additions (released in 8.9.1) | 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/vanilla/src/interactions/edit-transactions.ts`: `annotationSource`, `editToTransaction`; mount stamps source paths, renderer stamps facet instances; selection and click events preserve both identities | +| 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 | | 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` | @@ -381,3 +382,38 @@ require a `LayoutPlan`. Their old positional/plan-less fallbacks are gone. Graph scheduler, tween and spatial-index callers import from `motion/` and `spatial-index.ts`; the `graph/` re-export shims are gone. Map fill transitions accept `MapTransitionSnapshot` only. + +## Track A phase 2 additions (released in 8.9.1) + +| Concern | Source / exports | +| --- | --- | +| Frozen compiled annotation editing geometry | `packages/engine/src/annotations/edit-context.ts`: `createAnnotationEditContext`, `previewAnnotationMove`, `previewConnectorBend`, `previewConnectorOffset`, `inverseLabelPlacement`; uses actual compiled annotation objects with private measurement metadata | +| 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 | +| 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, +using its existing gutter reservation. Independent-layer assembly adds only the +remaining right-axis/merged-column offset; `compile-legend-gutter.test.ts` covers +regular, shared-layer and dual-axis column separation. + + +## Track A phase 3 annotation layout opt-ins (released in 8.9.1) + +| 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 | +| 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 | +| 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 | + +The seven annotation colors are resolved theme fields in `core/src/types/theme.ts`, +registered with dark pairs in `theme/resolve.ts` and `theme/dark-mode.ts`, and mirrored +by `styles/token-definitions.ts` and generated CSS. Existing generic annotation +colors retain their previous uses. diff --git a/docs/migrating-v8.md b/docs/migrating-v8.md index 175a098d..53ff6f68 100644 --- a/docs/migrating-v8.md +++ b/docs/migrating-v8.md @@ -296,7 +296,7 @@ import { MarkType, MARK_TYPES, MARK_ENCODING_RULES } from '@opendata-ai/openchar nudge per-series value labels by pixel offset. The collision system now handles label positioning automatically. -**Runtime behavior (8.10):** The property still works and emits a deprecation +**Runtime behavior (8.9.1):** The property still works and emits a deprecation warning once per process. Prefer `endpointLabels`. `migrateSpecV8` replaces legacy end-of-line labels with `{ content: 'label', showMarker: false }` and reports each dropped series offset. Labels can move to the right-edge column; @@ -1217,7 +1217,7 @@ warning at compile time with the exact fix. Items with no runtime warning: to `mark: { type: 'range', style: 'bar' }`. -## 8.10: atomic edits and compatibility aliases +## 8.9.1: atomic edits and compatibility aliases Use the additive `onEditTransaction` callback to persist edits without a switch for each legacy edit variant. `onEdit` keeps its existing payload in 8.x. @@ -1255,7 +1255,7 @@ both are supplied. The spec's `responsive` keeps controlling chart layout. Run `migrateSpecV8(spec)` from `@opendata-ai/openchart-engine` to obtain a new spec and a repair list (`path`, `from`, `to`, `reason`). It is pure and idempotent. -The 8.10 migration changes aliases only: +The migration rewrites these aliases and also materializes omitted defaults as described below: | Deprecated form | Replacement | | --- | --- | @@ -1280,7 +1280,7 @@ Executable compatibility paths warn once. Type-only aliases and primitive constant imports cannot detect usage at runtime; their JSDoc marks the migration. Tilemap `valueFormat` stays supported. The `oc-table--compact` compatibility class also stays in 8.x; host styles should target `oc-table--condensed` ahead -of its eventual removal. The `footnoteBandHeight` API is unchanged in 8.10. +of its eventual removal. The `footnoteBandHeight` helpers retain their compatibility signatures but are deprecated; see below. Notes now retain subtitles as `text. subtitle` and wrap within the figure. The engine reserves every wrapped line above the source and footer. Existing single-line @@ -1294,5 +1294,109 @@ right-hand axis; each series retains its own value formatting. Legacy-label migration currently targets multi-series line/area charts. A line with one color category can still draw a legacy series label, even though the suppression truth table describes single-series labels as hidden. Endpoint -labels also suppress that case, so 8.10 preserves its existing rendering. This +labels also suppress that case, so 8.9.1 preserves its existing rendering. This needs a separate decision before removing the legacy renderer. + +### Annotation geometry and editing + +The first label drag commits `anchor`, `offset`, and `responsive: false` together. +Use `onEditTransaction` to apply all three atomically. A dragged label stays inline +at compact widths; its data point stays fixed. The compatibility `onEdit` annotation +payload also includes the optional `anchor` and `responsive` fields. The deprecated +`onAnnotationEdit(annotation, offset)` retains its original callback shape and cannot +represent this complete placement change. + +In edit mode, Alt+arrow nudges the selected text annotation by 1px; Alt+Shift+arrow +uses 10px. Plain arrows still cycle selection. Repeated nudges form one transaction. +Escape, pointer/touch cancellation, a width change, or document replacement cancels +an active drag without emitting an edit. A height-only resize waits for the gesture. + +Custom editors can import `createAnnotationEditContext`, `previewAnnotationMove`, +`previewConnectorBend`, `previewConnectorOffset`, and `inverseLabelPlacement` from +engine. Create the context from the actual compiled layout and matching spec, use +canonical annotation refs (including `facetInstance` for facets), and recreate it +after commit or resize. It retains the compiler's private measurement context, so +JSON-deserialized or hand-built layouts cannot provide editing contexts. The +renderer still supports hand-built curve layouts through its control-point fallback. + +### Stable annotation note space + +Charts with automatic annotation thinning reserve note capacity from their source +text and subtitles before layout. Pinning or moving a callout can change the +visible notes without moving the plot, footer, watermark, or SVG edge. The band +can contain unused space. Pinned source callouts contribute because a fresh +compile cannot distinguish an authored pin from a drag result. Facets count each +source once. Charts with automatic thinning disabled retain their existing behavior. + +`layout.chrome.noteBandHeight` carries this reserve to renderers and export. It is +optional for compatibility with older or hand-built layouts; the existing +`footnoteBandHeight` helpers remain available, with deprecation warnings. + +With an explicit `subtract` height, enough annotation text can consume the entire +plot budget, even when those callouts are pinned. The figure keeps its requested +height; the existing chrome fallback can hide footer content and the plot can +have zero height. `onDiagnostic` reports `layout.note-capacity` as a non-throwing +error in this case. Increase the height, explicitly choose `grow`, or reduce the +annotation text/count. The compiler does not silently change sizing modes. + +### Annotation layout opt-ins + +Existing specs retain their 8.x rendering. Add these fields only where you want +new behavior: + +- `TextAnnotation.maxWidth` wraps measured rich text, including long words and + authored line breaks. Omission or `null` keeps unlimited width. Display lines + live in `label.lines`; `label.text` remains the authored text used for identity + and notes. `subtitleFontSize` overrides the existing rounded 85% subtitle size. +- `responsive.compactAnnotationLimit` limits eligible inline callouts below a + 400px **figure** width. Priority wins, then source order, before coverage and + collision thinning. Omission or `null` is unlimited; `0` sends all eligible + callouts to notes. Callouts with `responsive: false` remain inline and do not + count. Existing hide-all behavior with disabled responsiveness/auto-thinning + or fixed-size specs remains unchanged, as does the behavior below 200px tall. +- `RefLineAnnotation.labelPlacement` accepts `start`, `end`, `auto`, or `outside`. + Start/end mean left/right for horizontal rules and top/bottom for vertical + rules. `labelAnchor` still chooses the side. Auto tries the ends, then slides + along the rule to avoid obstacles using the full label bounds. An explicit + `labelOffset` pins the authored base position and reports overflow rather than + moving it. Outside labels wrap in a reserved right gutter or top lane; facets + reserve lanes inside each panel. Horizontal outside leaders stay in the gutter + during layout and animation. Dual-axis charts retain separate axis, endpoint, legend, + and reference columns. If a lane cannot fit its text, increase the figure size + or shorten the labels. Omitted placement retains the legacy anchor behavior. + +Annotation ink has seven theme tokens: `annotationLabel`, `annotationMuted`, +`annotationLeader`, `annotationRule`, `annotationRangeFill`, +`annotationMarkerFill`, and `annotationPlate`. Set them under `theme.colors`, +using either a color or a `{ light, dark }` pair. Explicit annotation colors win; +arrow connectors keep the label's ink. Rule and range defaults preserve the +previous compiled colors (`#666666` and `#000000`, respectively, in both modes). +Existing `annotationText` and +`annotationFill` token uses stay unchanged. + +Facet charts now support `chromeLayout: 'grow'`: chrome and the note band extend +the figure, preserving its plot budget. The outer assembly owns that space; +`layout.dimensions.height` is the final height for rendering and export. +`layout.chrome.grownHeight` reports added engine height so auto-height mounts +count it once. In layered specs, author `chromeLayout` on the first leaf (or use +the compile option); `LayerSpec` has no root-level `chromeLayout` field. +Default/subtract sizing still takes chrome and notes out of the requested height. + +Both `footnoteBandHeight` helpers are deprecated and warn once when called. +Read `layout.chrome.noteBandHeight` for the resolved reservation. Core retains +its numeric `(lineCount, theme)` signature for compatibility; it also accepts +resolved chrome metadata. `computeNoteBandHeight` supplies nondeprecated core +arithmetic for compiler code and hand-built layouts lacking that metadata. +Normal rendering and all framework wrappers use the resolved value. + +`migrateSpecV8` now also materializes omitted 8.x defaults: unlimited callout +width/compact count, lede weight, subtitle size, curve arrows, legacy reference +anchors, subtract sizing, and legacy editorial fonts/chrome sizes. It preserves +authored values, font precedence, theme presets, responsive opt-outs, and layer +ownership. It remains pure and idempotent; inspect `repairs` for every change. +The previously documented legacy series-label rewrite remains a visual migration. + +Mark options with no meaning for the authored mark now produce advisory warnings +with exact source paths and a suggested repair. Warnings are collected before +histogram/density shorthand expansion so discarded options remain visible. +Validation and the 8.x mark types remain compatible; these warnings are not errors. diff --git a/docs/spec-reference.md b/docs/spec-reference.md index b9ff256a..afd2117f 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -546,6 +546,8 @@ 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. | | `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. | @@ -562,6 +564,10 @@ A callout label positioned at a data coordinate. #### Text: line breaks, bold spans, and the lede rule +Curve connectors accept optional `bend: -1..1`; omitted or zero preserves the +existing automatic curve. Other connector types ignore `bend` with a warning. +An authored `offset` object pins placement even when both values are zero. + `\n` starts a new line. Each line renders as its own ``. Multi-line blocks are left-aligned by default, or right-aligned when `anchor: 'left'` puts the block to the left of the point, so the block's facing edge lines up with the leader. `**bold**` marks an inline bold span in `text` and in `subtitle`. Emphasis belongs on the key phrase, not the whole block: @@ -636,6 +642,7 @@ 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). | | `fontWeight` | `number` | `400` | Font weight override for the label. | | `fill` | `string` | `undefined` | Fill color. | @@ -1912,3 +1919,17 @@ Measured channels accept finite numbers and nonblank numeric strings, including Maps, tile maps, bar lists and Sankey support arrows for inspection and Enter/Space for their existing click callbacks, including when `tooltip: false`. Escape dismisses inspection; Tab exits naturally. A keyboard-focused map mark uses arrows for mark navigation; the map root retains its separate zoom/pan shortcuts. Tables restore keyed cell/header focus across updates and keep native mobile detail disclosures operable. 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) + +`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 +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). diff --git a/docs/theming-tokens.md b/docs/theming-tokens.md index cf81b850..25538cdc 100644 --- a/docs/theming-tokens.md +++ b/docs/theming-tokens.md @@ -207,3 +207,15 @@ as `chrome.*.color` and needs no dark-mode branch. `computeChrome` reserves - **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. No action required. If you import `tokens.css`/`dark.css`, keep doing so. The JS-stamped properties take precedence via inline style specificity. + + +### Annotation tokens (8.9.1) + +`theme.colors` accepts `annotationLabel`, `annotationMuted`, `annotationLeader`, +`annotationRule`, `annotationRangeFill`, `annotationMarkerFill`, and +`annotationPlate`, each as a color or `{ light, dark }` pair. Their CSS equivalents +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. diff --git a/e2e/annotation-invariants/curves.spec.ts b/e2e/annotation-invariants/curves.spec.ts new file mode 100644 index 00000000..55359742 --- /dev/null +++ b/e2e/annotation-invariants/curves.spec.ts @@ -0,0 +1,86 @@ +import type { ChartLayout } from '@opendata-ai/openchart-core'; +import { expect, test } from '@playwright/test'; +import { CONNECTOR_STANDOFF } from '../../packages/engine/src/annotations/constants'; + +for (const width of [320, 390, 768, 1280]) + for (const colorScheme of ['light', 'dark'] as const) { + test(`${width} ${colorScheme}: bend and arrow matrix stays within the figure`, async ({ + page, + }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ colorScheme }); + await page.goto('/?story=features--annotation-curves&mode=preview'); + const charts = page.getByTestId('annotation-curve'); + await expect(charts).toHaveCount(10); + for (const chart of await charts.all()) { + await expect(chart.locator('.oc-annotation-label')).toBeVisible(); + await chart.dispatchEvent('fixture-layout'); + const layout: ChartLayout = JSON.parse((await chart.getAttribute('data-layout'))!); + const annotation = layout.annotations[0]; + const bounds = annotation.bounds!; + const connector = annotation.label!.connector!; + // Existing geometry uses a 2px inflated box, then advances along the ray. + // Standoff is not the shortest Euclidean distance to the unpadded box. + const cx = bounds.x + bounds.width / 2; + const cy = bounds.y + bounds.height / 2; + const dx = connector.endpoint!.x - cx; + const dy = connector.endpoint!.y - cy; + const rayExit = Math.min( + (bounds.width / 2 + 2) / Math.abs(dx), + (bounds.height / 2 + 2) / Math.abs(dy), + ); + const standoff = Math.hypot( + connector.from.x - (cx + dx * rayExit), + connector.from.y - (cy + dy * rayExit), + ); + expect(Math.abs(standoff - CONNECTOR_STANDOFF)).toBeLessThanOrEqual(1); + const gapToPoint = Math.hypot( + connector.to.x - connector.endpoint!.x, + connector.to.y - connector.endpoint!.y, + ); + const markerRadius = annotation.dot + ? annotation.dot.radius + annotation.dot.strokeWidth / 2 + : 0; + const expectedGap = connector.arrow + ? Math.max(markerRadius + 2, 2) + : Math.max(markerRadius + 3, 4); + expect(gapToPoint).toBeCloseTo(expectedGap, 7); + expect(bounds.x).toBeGreaterThanOrEqual(-1); + expect(bounds.y).toBeGreaterThanOrEqual(-1); + expect(bounds.x + bounds.width).toBeLessThanOrEqual(layout.dimensions.width + 1); + expect(bounds.y + bounds.height).toBeLessThanOrEqual(layout.dimensions.height + 1); + const geometry = await chart.evaluate((host) => { + const svg = host.querySelector('svg')!; + return { + height: svg.getBoundingClientRect().height, + path: host.querySelector('path.oc-annotation-connector')?.getAttribute('d'), + inline: host.querySelectorAll('.oc-annotation-label').length, + notes: host.querySelectorAll('.oc-annotation-footnote-marker').length, + }; + }); + expect(geometry.height).toBeCloseTo(layout.dimensions.height, 1); + expect(geometry.path).toContain(' Q '); + expect(geometry.path).not.toMatch(/NaN|Infinity/); + // One eligible callout per matrix panel; no possible pairwise overlap. + expect(geometry.inline + geometry.notes).toBe(1); + await chart.dispatchEvent('fixture-export'); + expect( + await chart.evaluate((host) => { + const exported = new DOMParser().parseFromString(host.dataset.export!, 'image/svg+xml'); + return [ + '.oc-annotation-connector', + '.oc-annotation-arrowhead', + '.oc-annotation-dot', + ].every((selector) => { + const attrs = (el: Element | null) => + el && [...el.attributes].map((a) => [a.name, a.value]).sort(); + return ( + JSON.stringify(attrs(host.querySelector(selector))) === + JSON.stringify(attrs(exported.querySelector(selector))) + ); + }); + }), + ).toBe(true); + } + }); + } diff --git a/e2e/annotation-invariants/gestures.spec.ts b/e2e/annotation-invariants/gestures.spec.ts new file mode 100644 index 00000000..6330d851 --- /dev/null +++ b/e2e/annotation-invariants/gestures.spec.ts @@ -0,0 +1,123 @@ +import { expect, test } from '@playwright/test'; + +const story = '/?story=features--annotation-gestures&mode=preview'; +for (const width of [320, 390, 768, 1280]) + for (const colorScheme of ['light', 'dark'] as const) { + test(`${width} ${colorScheme}: first drag, geometry and export`, async ({ page }) => { + await page.setViewportSize({ width, height: 900 }); + await page.emulateMedia({ colorScheme }); + await page.goto(story); + const host = page.getByTestId('annotation-gesture-host'); + const label = host.locator('.oc-annotation-label'); + await expect(label).toBeVisible(); + // WebKit briefly reports a text box at the origin before its first SVG paint. + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + }); + await expect + .poll(() => label.evaluate((el) => el.getBoundingClientRect().right)) + .toBeGreaterThan(100); + const marker = host.locator('.oc-annotation-dot'); + const anchorBefore = await marker.evaluate((e) => [ + e.getAttribute('cx'), + e.getAttribute('cy'), + ]); + const box = await label.evaluate((el) => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + await page.mouse.move(box.x + box.width / 2, box.y + box.height / 2); + await page.mouse.down(); + await page.mouse.move(box.x + box.width / 2 - 35, box.y + box.height / 2 + 25, { steps: 4 }); + await page.evaluate(() => new Promise(requestAnimationFrame)); + const preview = await host + .locator('.oc-annotation-text') + .evaluate((group) => ({ + x: Number(group.querySelector('.oc-annotation-label')!.getAttribute('x')), + y: Number(group.querySelector('.oc-annotation-label')!.getAttribute('y')), + path: group.querySelector('path.oc-annotation-connector')?.getAttribute('d'), + })); + await page.mouse.up(); + await expect(page.getByTestId('edit-count')).toHaveText('1'); + const committed = await host + .locator('.oc-annotation-text') + .evaluate((group) => ({ + x: Number(group.querySelector('.oc-annotation-label')!.getAttribute('x')), + y: Number(group.querySelector('.oc-annotation-label')!.getAttribute('y')), + path: group.querySelector('path.oc-annotation-connector')?.getAttribute('d'), + })); + const previewNumbers = preview.path!.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g)!.map(Number); + const committedNumbers = committed.path!.match(/-?\d+(?:\.\d+)?(?:e[+-]?\d+)?/g)!.map(Number); + expect(committedNumbers).toHaveLength(previewNumbers.length); + committedNumbers.forEach((value, index) => + expect(value).toBeCloseTo(previewNumbers[index], 7), + ); + expect(committed.x).toBeCloseTo(preview.x, 7); + expect(committed.y).toBeCloseTo(preview.y, 7); + expect(await marker.evaluate((e) => [e.getAttribute('cx'), e.getAttribute('cy')])).toEqual( + anchorBefore, + ); + await host.dispatchEvent('fixture-export'); + const exported = await host.getAttribute('data-export'); + expect(exported).toContain(committed.path!); + const parity = await host.evaluate((node) => { + const exported = new DOMParser().parseFromString(node.dataset.export!, 'image/svg+xml'); + return ['.oc-annotation-connector', '.oc-annotation-arrowhead', '.oc-annotation-dot'].every( + (selector) => { + const attrs = (el: Element | null) => + el && [...el.attributes].map((a) => [a.name, a.value]).sort(); + return ( + JSON.stringify(attrs(node.querySelector(selector))) === + JSON.stringify(attrs(exported.querySelector(selector))) + ); + }, + ); + }); + expect(parity).toBe(true); + const contained = await label.evaluate((el) => { + const a = el.getBoundingClientRect(); + const b = el.closest('svg')!.getBoundingClientRect(); + return ( + a.left >= b.left - 1 && + a.right <= b.right + 1 && + a.top >= b.top - 1 && + a.bottom <= b.bottom + 1 + ); + }); + expect(contained).toBe(true); + }); + } +for (const cancellation of ['Escape', 'pointercancel', 'touchcancel', 'replacement', 'width']) { + test(`${cancellation} emits nothing`, async ({ page }) => { + await page.setViewportSize({ width: 768, height: 900 }); + await page.goto(story); + const host = page.getByTestId('annotation-gesture-host'); + const label = host.locator('.oc-annotation-label'); + await expect(label).toBeVisible(); + // WebKit briefly reports a text box at the origin before its first SVG paint. + await page.evaluate(async () => { + await document.fonts.ready; + await new Promise(requestAnimationFrame); + }); + await expect + .poll(() => label.evaluate((el) => el.getBoundingClientRect().right)) + .toBeGreaterThan(100); + const box = await label.evaluate((el) => { + const { x, y, width, height } = el.getBoundingClientRect(); + return { x, y, width, height }; + }); + await page.mouse.move(box.x + 5, box.y + 5); + await page.mouse.down(); + await page.mouse.move(box.x - 25, box.y + 25, { steps: 3 }); + if (cancellation === 'Escape') await page.keyboard.press('Escape'); + else if (cancellation === 'replacement') await host.dispatchEvent('fixture-replace'); + else if (cancellation === 'width') { + await page.setViewportSize({ width: 700, height: 900 }); + await expect.poll(() => host.locator('svg').getAttribute('viewBox')).not.toContain('768'); + await page.waitForTimeout(100); + } else await page.evaluate((type) => document.dispatchEvent(new Event(type)), cancellation); + await page.mouse.up(); + await expect(page.getByTestId('edit-count')).toHaveText('0'); + }); +} diff --git a/e2e/annotation-invariants/layout.spec.ts b/e2e/annotation-invariants/layout.spec.ts new file mode 100644 index 00000000..cfb023c9 --- /dev/null +++ b/e2e/annotation-invariants/layout.spec.ts @@ -0,0 +1,99 @@ +import type { ChartLayout, ResolvedAnnotation } from '@opendata-ai/openchart-core'; +import { expect, test } 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); + } + 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/); + 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'))!); + 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 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); + } + 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); +} diff --git a/e2e/visual/__screenshots__/stories-mobile.spec.ts-snapshots/bar-vertical-mobile-visual-mobile-darwin.png b/e2e/visual/__screenshots__/stories-mobile.spec.ts-snapshots/bar-vertical-mobile-visual-mobile-darwin.png index 203297a4..e044fb38 100644 Binary files a/e2e/visual/__screenshots__/stories-mobile.spec.ts-snapshots/bar-vertical-mobile-visual-mobile-darwin.png and b/e2e/visual/__screenshots__/stories-mobile.spec.ts-snapshots/bar-vertical-mobile-visual-mobile-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-horizontal-gradient-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-horizontal-gradient-visual-darwin.png index 22381a3d..f470434e 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-horizontal-gradient-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-horizontal-gradient-visual-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-vertical-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-vertical-visual-darwin.png index ea7caaf1..3b821bd6 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-vertical-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/bar-vertical-visual-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/chart-with-annotations-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/chart-with-annotations-visual-darwin.png index e13a4660..0eadb23d 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/chart-with-annotations-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/chart-with-annotations-visual-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/dark-mode-line-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/dark-mode-line-visual-darwin.png index 2dc8188b..be9fef28 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/dark-mode-line-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/dark-mode-line-visual-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/legend-bottom-right-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/legend-bottom-right-visual-darwin.png index 23afae08..a882d969 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/legend-bottom-right-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/legend-bottom-right-visual-darwin.png differ diff --git a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/scrolly-step-payoff-visual-darwin.png b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/scrolly-step-payoff-visual-darwin.png index aeed59e9..d80809a6 100644 Binary files a/e2e/visual/__screenshots__/stories.spec.ts-snapshots/scrolly-step-payoff-visual-darwin.png and b/e2e/visual/__screenshots__/stories.spec.ts-snapshots/scrolly-step-payoff-visual-darwin.png differ diff --git a/e2e/visual/capture.ts b/e2e/visual/capture.ts index 84a11263..577006c0 100644 --- a/e2e/visual/capture.ts +++ b/e2e/visual/capture.ts @@ -86,5 +86,10 @@ export async function captureStory( // One layout tick after fonts finish to settle text measurements. await page.waitForTimeout(100); - await expect(chart).toHaveScreenshot(screenshotName); + await expect(chart).toHaveScreenshot(screenshotName, { + // React can reconcile away an early head style while mounting the story. + // Apply overlay hiding during the capture itself as well, so dev controls + // cannot intermittently cover the chart. Chart pixels stay untouched. + style: '.ladle-theme-picker, .oc-github-link { display: none !important; }', + }); } diff --git a/examples/src/gallery/features-annotations.demos.ts b/examples/src/gallery/features-annotations.demos.ts index 3bad7baa..7a711391 100644 --- a/examples/src/gallery/features-annotations.demos.ts +++ b/examples/src/gallery/features-annotations.demos.ts @@ -1,3 +1,4 @@ +import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; /** * Demo registry for the Annotations gallery page. * @@ -23,3 +24,204 @@ export const page: PageEntry = { { id: 'auto-thinning', title: 'Narrow-width demotion to numbered footnotes' }, ], }; + +/** Track A geometry fixtures. These do not join visual baselines until phase 5. */ +export const annotationGestureSpec: ChartSpec = { + mark: 'point', + animation: false, + responsive: { autoThin: false }, + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + chrome: { title: 'Move a callout', subtitle: 'Drag the label; its data point stays fixed.' }, + annotations: [ + { + type: 'text', + id: 'callout', + x: 10, + y: 5, + text: 'A heading', + subtitle: 'A wider second line', + connector: { type: 'curve', bend: 0 }, + dot: true, + responsive: false, + }, + ], +}; +export const annotationCurveSpecs: ChartSpec[] = [-1, -0.5, 0, 0.5, 1].flatMap((bend) => + [false, true].map((arrow) => ({ + ...annotationGestureSpec, + chrome: { title: `Bend ${bend}${arrow ? ' with arrow' : ''}` }, + annotations: [ + { + type: 'text', + id: `bend-${bend}-${arrow}`, + x: 9, + y: 2, + text: `Bend ${bend}`, + subtitle: arrow ? 'Arrow' : 'Marker', + anchor: 'left', + offset: { dx: -25, dy: -75 }, + connector: { type: 'curve', bend, arrow }, + responsive: false, + }, + ], + })), +); + +/** Shared crowded-callout layout for browser invariants and the preview benchmark. */ +export const annotationCrowdedSpec: ChartSpec = { + ...annotationGestureSpec, + responsive: { autoThin: true }, + chrome: { title: 'Crowded callouts' }, + annotations: Array.from({ length: 14 }, (_, index) => ({ + type: 'text', + id: `crowded-${index}`, + x: index >= 12 ? 5 : 1 + (index % 4) * 2.5, + y: index >= 12 ? 5 : 2 + Math.floor(index / 4) * 3, + text: index >= 12 ? 'Kept inline' : `Callout ${index + 1}`, + subtitle: 'Context for the observation', + connector: 'curve', + priority: index, + ...(index >= 12 + ? { responsive: false, anchor: 'left' as const, offset: { dx: -20, dy: -30 } } + : {}), + })), +}; + +/** Optional layout behavior, kept out of screenshot baselines until phase 5. */ +export const annotationReferenceSpecs: ChartSpec[] = [ + { + ...annotationGestureSpec, + responsive: true, + chrome: { + title: 'Thresholds and events', + subtitle: 'Labels stay connected to their reference lines.', + }, + annotations: [ + { + type: 'refline', + id: 'threshold', + y: 6, + label: 'A longer threshold label, wrapped in the reserved right gutter', + labelPlacement: 'outside', + responsive: false, + }, + { + type: 'refline', + id: 'nearby', + y: 5.8, + label: 'A nearby threshold', + labelPlacement: 'outside', + responsive: false, + }, + { + type: 'refline', + id: 'event-one', + x: 3, + label: 'First event', + labelPlacement: 'outside', + responsive: false, + }, + { + type: 'refline', + id: 'event-two', + x: 7, + label: 'Another event', + labelPlacement: 'outside', + responsive: false, + }, + ], + }, + ...(['top', 'bottom', 'left', 'right', 'auto'] as const).flatMap((labelAnchor) => + [true, false].map((horizontal) => ({ + ...annotationGestureSpec, + chrome: { title: `${horizontal ? 'Horizontal' : 'Vertical'} · ${labelAnchor}` }, + annotations: (['start', 'end', 'auto'] as const).map((labelPlacement, i) => ({ + type: 'refline' as const, + id: `${labelAnchor}-${labelPlacement}`, + ...(horizontal ? { y: 2 + i * 3 } : { x: 2 + i * 3 }), + label: labelPlacement, + labelAnchor, + labelPlacement, + responsive: false as const, + })), + })), + ), +]; + +export const annotationNotesSpecs: ChartSpec[] = [undefined, 'subtract', 'grow'].flatMap( + (chromeLayout) => { + const base: ChartSpec = { + ...annotationGestureSpec, + chromeLayout: chromeLayout as ChartSpec['chromeLayout'], + chrome: { + title: `Notes · ${chromeLayout ?? 'default'}`, + source: 'Source: sample observations', + }, + responsive: { compactAnnotationLimit: 0 }, + annotations: [ + { + type: 'text', + id: 'note-a', + x: 4, + y: 4, + text: 'A **finding**', + subtitle: 'Full supporting context for the observation', + maxWidth: 90, + anchor: 'bottom', + }, + { + type: 'text', + id: 'note-b', + x: 4.8, + y: 4.8, + text: 'Another finding', + subtitle: 'Additional context, retained in notes', + maxWidth: 90, + anchor: 'bottom', + }, + ], + }; + return [ + base, + { + ...base, + data: base.data.map((row) => ({ ...row, group: 'Panel' })), + encoding: { ...base.encoding, facet: { field: 'group', type: 'nominal' as const } }, + }, + ]; + }, +); + +/** Exercises the independently reserved right axis, endpoint, legend and reference columns. */ +export const annotationReferenceDualAxisSpec: LayerSpec = { + layer: [1, 10].map( + (factor) => + ({ + mark: factor === 1 ? 'bar' : 'line', + data: (factor === 1 ? ['Count'] : ['North', 'South']).flatMap((s, i) => [ + { x: 'Jan', y: (i + 1) * factor, s }, + { x: 'Feb', y: (i + 5) * factor, s }, + ]), + encoding: { + x: { field: 'x', type: 'ordinal' }, + y: { + field: 'y', + type: 'quantitative', + axis: { title: factor === 1 ? 'Count' : 'Value' }, + }, + ...(factor === 1 ? {} : { color: { field: 's', type: 'nominal' as const } }), + }, + }) as ChartSpec, + ), + labels: { density: 'none' }, + animation: false, + chrome: { title: 'Separate columns' }, + resolve: { scale: { y: 'independent' } }, + legend: { show: false }, + endpointLabels: { content: 'label' }, + annotations: [{ type: 'refline', y: 4, label: 'Reference threshold', labelPlacement: 'outside' }], +}; diff --git a/examples/src/gallery/features-annotations.stories.tsx b/examples/src/gallery/features-annotations.stories.tsx index fbcc88fb..590b5c36 100644 --- a/examples/src/gallery/features-annotations.stories.tsx +++ b/examples/src/gallery/features-annotations.stories.tsx @@ -9,11 +9,21 @@ * lines, resize-stability, and auto-thinning. */ -import type { ChartSpec } from '@opendata-ai/openchart-core'; +import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; +import { applyEditTransaction } from '@opendata-ai/openchart-core'; import { Chart } from '@opendata-ai/openchart-react'; -import { useState } from 'react'; +import { createChart } from '@opendata-ai/openchart-vanilla'; +import { useEffect, useRef, useState } from 'react'; import { Demo, GalleryPage, Section } from '../components'; import { nvidiaStock, temperatureAnomaly, usInflation, usPayrolls } from '../data'; +import { + annotationCrowdedSpec, + annotationCurveSpecs, + annotationGestureSpec, + annotationNotesSpecs, + annotationReferenceDualAxisSpec, + annotationReferenceSpecs, +} from './features-annotations.demos'; const ACCENT = '#0e7490'; @@ -695,3 +705,213 @@ export const Annotations = () => ( ); + +/** Interactive geometry fixture, deliberately outside the visual baseline registry. */ +export function AnnotationGestures() { + const host = useRef(null); + const [count, setCount] = useState(0); + useEffect(() => { + if (!host.current) return; + let spec = structuredClone(annotationGestureSpec); + let revision = 0; + const chart = createChart(host.current, spec, { + editable: true, + revision, + onEditTransaction: (tx) => { + const result = applyEditTransaction(spec, tx); + if (!result.ok) throw new Error(result.error.reason); + spec = result.spec; + revision++; + setCount((count) => count + 1); + chart.update(spec, { revision }); + }, + }); + const replace = () => { + spec = structuredClone(spec); + chart.update(spec, { revision: ++revision }); + }; + const exportSVG = () => { + host.current!.dataset.export = chart.export('svg'); + }; + const expose = () => { + host.current!.dataset.layout = JSON.stringify(chart.layout); + }; + host.current.addEventListener('fixture-replace', replace); + host.current.addEventListener('fixture-export', exportSVG); + host.current.addEventListener('fixture-layout', expose); + return () => { + host.current?.removeEventListener('fixture-replace', replace); + host.current?.removeEventListener('fixture-export', exportSVG); + host.current?.removeEventListener('fixture-layout', expose); + chart.destroy(); + }; + }, []); + return ( +
+
+ {count} +
+ ); +} + +/** Shares the compiled snapshot with invariant tests, without exposing editor controls. */ +function AnnotationInvariantChart({ + spec, + height = 300, + testId = 'annotation-curve', + renderer, +}: { + spec: ChartSpec | LayerSpec; + height?: number | 'auto'; + testId?: string; + renderer?: 'svg' | 'canvas'; +}) { + const host = useRef(null); + useEffect(() => { + const node = host.current!; + const chart = createChart(node, spec, { renderer, darkMode: 'auto' }); + // A fixed plot budget can render a taller grow-mode figure. Keep the next + // example below the actual SVG while leaving the authored budget unchanged. + const spaceFigure = () => { + node.style.marginBottom = `${Math.max(0, chart.layout.dimensions.height - node.clientHeight)}px`; + }; + const observer = new ResizeObserver(spaceFigure); + const svg = node.querySelector('svg'); + if (svg) observer.observe(svg); + spaceFigure(); + const expose = () => { + node.dataset.layout = JSON.stringify(chart.layout); + }; + const exportSVG = () => { + node.dataset.export = chart.export('svg'); + }; + const exportPNG = async () => { + const bitmap = await createImageBitmap( + await chart.export('png', { dpi: 1, embedFonts: false }), + ); + node.dataset.png = JSON.stringify({ width: bitmap.width, height: bitmap.height }); + bitmap.close(); + }; + node.addEventListener('fixture-png', exportPNG); + node.addEventListener('fixture-layout', expose); + node.addEventListener('fixture-export', exportSVG); + return () => { + observer.disconnect(); + node.removeEventListener('fixture-png', exportPNG); + node.removeEventListener('fixture-layout', expose); + node.removeEventListener('fixture-export', exportSVG); + chart.destroy(); + }; + }, [spec, renderer]); + return
; +} +export function AnnotationCurves() { + return ( +
+ {annotationCurveSpecs.map((spec) => ( + + ))} +
+ ); +} + +export function AnnotationReferenceLabels() { + return ( +
+ + + annotationReferenceSpecs[0].data.map((row) => ({ ...row, group })), + ), + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + facet: { field: 'group', type: 'nominal', columns: 2 }, + }, + mark: 'point', + }} + height={750} + testId="annotation-layout" + /> + {annotationReferenceSpecs.map((spec) => ( + + ))} +
+ ); +} + +export function AnnotationCrowdedCallouts() { + return ( +
+ + a.type === 'text' ? { ...a, maxWidth: 130 } : a, + ), + }} + height={850} + testId="annotation-layout" + /> +
+ ); +} + +export function AnnotationNotesSizing() { + return ( +
+ {annotationNotesSpecs.map((spec) => ( + + ))} + {annotationNotesSpecs + .filter((_, i) => i % 2 === 0) + .map((spec) => ( + + ))} + + +
+ ); +} diff --git a/examples/src/gallery/features-edit-mode.stories.tsx b/examples/src/gallery/features-edit-mode.stories.tsx index b35e9dae..0b11e06b 100644 --- a/examples/src/gallery/features-edit-mode.stories.tsx +++ b/examples/src/gallery/features-edit-mode.stories.tsx @@ -465,7 +465,21 @@ function EditModeStudio() { (edit: ElementEdit) => { switch (edit.type) { case 'annotation': - // Also flows through onAnnotationEdit above; persisted there. + setState((prev) => ({ + ...prev, + annotations: prev.annotations?.map((annotation, index) => + edit.element.type === 'annotation' && + index === edit.element.index && + annotation.type === 'text' + ? { + ...annotation, + offset: edit.offset, + ...(edit.anchor !== undefined ? { anchor: edit.anchor } : {}), + ...(edit.responsive !== undefined ? { responsive: edit.responsive } : {}), + } + : annotation, + ), + })); pushLog( 'onEdit', `annotation[${'index' in edit.element ? edit.element.index : '?'}] "${oneLine(edit.annotation.text)}" → ${fmtOffset(edit.offset)}`, diff --git a/examples/src/testing/fixtures-lines.stories.tsx b/examples/src/testing/fixtures-lines.stories.tsx index f56d6d40..bcf77193 100644 --- a/examples/src/testing/fixtures-lines.stories.tsx +++ b/examples/src/testing/fixtures-lines.stories.tsx @@ -650,7 +650,8 @@ const lineSpec: ChartSpec = { text: 'TikTok overtakes Instagram\nin Q2 2024', fontSize: 11, anchor: 'left', - offset: { dx: 0, dy: 45 }, + // Keep the authored callout clear of ticks after reserving stable note capacity. + offset: { dx: 0, dy: 25 }, connector: true, background: true, }, diff --git a/llms-full.txt b/llms-full.txt index 64205055..068680d8 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1471,6 +1471,8 @@ 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. | | `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. | @@ -1487,6 +1489,10 @@ A callout label positioned at a data coordinate. #### Text: line breaks, bold spans, and the lede rule +Curve connectors accept optional `bend: -1..1`; omitted or zero preserves the +existing automatic curve. Other connector types ignore `bend` with a warning. +An authored `offset` object pins placement even when both values are zero. + `\n` starts a new line. Each line renders as its own ``. Multi-line blocks are left-aligned by default, or right-aligned when `anchor: 'left'` puts the block to the left of the point, so the block's facing edge lines up with the leader. `**bold**` marks an inline bold span in `text` and in `subtitle`. Emphasis belongs on the key phrase, not the whole block: @@ -1561,6 +1567,7 @@ 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). | | `fontWeight` | `number` | `400` | Font weight override for the label. | | `fill` | `string` | `undefined` | Fill color. | @@ -2838,6 +2845,20 @@ 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) + +`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 +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). + --- @@ -4448,3 +4469,15 @@ as `chrome.*.color` and needs no dark-mode branch. `computeChrome` reserves - **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. No action required. If you import `tokens.css`/`dark.css`, keep doing so. The JS-stamped properties take precedence via inline style specificity. + + +### Annotation tokens (8.9.1) + +`theme.colors` accepts `annotationLabel`, `annotationMuted`, `annotationLeader`, +`annotationRule`, `annotationRangeFill`, `annotationMarkerFill`, and +`annotationPlate`, each as a color or `{ light, dark }` pair. Their CSS equivalents +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. diff --git a/packages/core/schema/chart.schema.json b/packages/core/schema/chart.schema.json index 1d0e3519..944f3498 100644 --- a/packages/core/schema/chart.schema.json +++ b/packages/core/schema/chart.schema.json @@ -8122,6 +8122,17 @@ "type": "string", "description": "Optional muted second-tone text rendered below the primary `text`. Used for supporting context (e.g. methodology, source). Newlines in `text` still produce multi-line primary; subtitle is a separate block.\n\nSupports the same inline `**bold**` spans as `text`. When a subtitle is present and no `fontWeight` is set, the primary `text` resolves to weight 700 — the lede + context stack." }, + "subtitleFontSize": { + "type": "number", + "description": "Subtitle size in pixels. Default: round(fontSize * 0.85)." + }, + "maxWidth": { + "type": [ + "number", + "null" + ], + "description": "Optional display wrap width in pixels. Omitted or null keeps unbounded lines." + }, "dot": { "anyOf": [ { @@ -8254,6 +8265,10 @@ "$ref": "#/definitions/ConnectorType", "description": "Connector style." }, + "bend": { + "type": "number", + "description": "Curve displacement along the chord normal, from -1 to 1. Omitted or zero preserves the automatic curve. Ignored for other connector types." + }, "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." @@ -8429,6 +8444,16 @@ "$ref": "#/definitions/AnnotationOffset", "description": "Pixel offset for the reference line label." }, + "labelPlacement": { + "type": "string", + "enum": [ + "auto", + "start", + "end", + "outside" + ], + "description": "Opt-in screen-space label placement. Outside reserves a right gutter or top lane; omission preserves legacy labelAnchor behavior." + }, "labelAnchor": { "$ref": "#/definitions/AnnotationAnchor", "description": "Anchor direction for the reference line label." @@ -8602,6 +8627,13 @@ "ResponsiveConfig": { "type": "object", "properties": { + "compactAnnotationLimit": { + "type": [ + "number", + "null" + ], + "description": "Maximum eligible inline callouts below 400px container width; null or omitted is unlimited. Pinned annotations are exempt." + }, "preserveEnd": { "type": "boolean", "description": "Preserve the final temporal boundary before thinning interior ticks." @@ -8694,6 +8726,34 @@ "$ref": "#/definitions/TokenValue", "description": "Annotation text color. Accepts a TokenValue for light/dark pairs." }, + "annotationLabel": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation label color. Accepts a TokenValue light/dark pair." + }, + "annotationMuted": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation muted color. Accepts a TokenValue light/dark pair." + }, + "annotationLeader": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation leader color. Accepts a TokenValue light/dark pair." + }, + "annotationRule": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rule color. Accepts a TokenValue light/dark pair." + }, + "annotationRangeFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rangefill color. Accepts a TokenValue light/dark pair." + }, + "annotationMarkerFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation markerfill color. Accepts a TokenValue light/dark pair." + }, + "annotationPlate": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation plate color. Accepts a TokenValue light/dark pair." + }, "positive": { "$ref": "#/definitions/TokenValue", "description": "Semantic color for positive/up-trend values. Accepts a TokenValue for light/dark pairs." diff --git a/packages/core/schema/table.schema.json b/packages/core/schema/table.schema.json index f33edcfc..5ac3d22e 100644 --- a/packages/core/schema/table.schema.json +++ b/packages/core/schema/table.schema.json @@ -664,6 +664,34 @@ "$ref": "#/definitions/TokenValue", "description": "Annotation text color. Accepts a TokenValue for light/dark pairs." }, + "annotationLabel": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation label color. Accepts a TokenValue light/dark pair." + }, + "annotationMuted": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation muted color. Accepts a TokenValue light/dark pair." + }, + "annotationLeader": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation leader color. Accepts a TokenValue light/dark pair." + }, + "annotationRule": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rule color. Accepts a TokenValue light/dark pair." + }, + "annotationRangeFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rangefill color. Accepts a TokenValue light/dark pair." + }, + "annotationMarkerFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation markerfill color. Accepts a TokenValue light/dark pair." + }, + "annotationPlate": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation plate color. Accepts a TokenValue light/dark pair." + }, "positive": { "$ref": "#/definitions/TokenValue", "description": "Semantic color for positive/up-trend values. Accepts a TokenValue for light/dark pairs." diff --git a/packages/core/schema/vizspec.schema.json b/packages/core/schema/vizspec.schema.json index f01cd8d4..4cdabd4b 100644 --- a/packages/core/schema/vizspec.schema.json +++ b/packages/core/schema/vizspec.schema.json @@ -8151,6 +8151,17 @@ "type": "string", "description": "Optional muted second-tone text rendered below the primary `text`. Used for supporting context (e.g. methodology, source). Newlines in `text` still produce multi-line primary; subtitle is a separate block.\n\nSupports the same inline `**bold**` spans as `text`. When a subtitle is present and no `fontWeight` is set, the primary `text` resolves to weight 700 — the lede + context stack." }, + "subtitleFontSize": { + "type": "number", + "description": "Subtitle size in pixels. Default: round(fontSize * 0.85)." + }, + "maxWidth": { + "type": [ + "number", + "null" + ], + "description": "Optional display wrap width in pixels. Omitted or null keeps unbounded lines." + }, "dot": { "anyOf": [ { @@ -8283,6 +8294,10 @@ "$ref": "#/definitions/ConnectorType", "description": "Connector style." }, + "bend": { + "type": "number", + "description": "Curve displacement along the chord normal, from -1 to 1. Omitted or zero preserves the automatic curve. Ignored for other connector types." + }, "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." @@ -8458,6 +8473,16 @@ "$ref": "#/definitions/AnnotationOffset", "description": "Pixel offset for the reference line label." }, + "labelPlacement": { + "type": "string", + "enum": [ + "auto", + "start", + "end", + "outside" + ], + "description": "Opt-in screen-space label placement. Outside reserves a right gutter or top lane; omission preserves legacy labelAnchor behavior." + }, "labelAnchor": { "$ref": "#/definitions/AnnotationAnchor", "description": "Anchor direction for the reference line label." @@ -8631,6 +8656,13 @@ "ResponsiveConfig": { "type": "object", "properties": { + "compactAnnotationLimit": { + "type": [ + "number", + "null" + ], + "description": "Maximum eligible inline callouts below 400px container width; null or omitted is unlimited. Pinned annotations are exempt." + }, "preserveEnd": { "type": "boolean", "description": "Preserve the final temporal boundary before thinning interior ticks." @@ -8723,6 +8755,34 @@ "$ref": "#/definitions/TokenValue", "description": "Annotation text color. Accepts a TokenValue for light/dark pairs." }, + "annotationLabel": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation label color. Accepts a TokenValue light/dark pair." + }, + "annotationMuted": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation muted color. Accepts a TokenValue light/dark pair." + }, + "annotationLeader": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation leader color. Accepts a TokenValue light/dark pair." + }, + "annotationRule": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rule color. Accepts a TokenValue light/dark pair." + }, + "annotationRangeFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation rangefill color. Accepts a TokenValue light/dark pair." + }, + "annotationMarkerFill": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation markerfill color. Accepts a TokenValue light/dark pair." + }, + "annotationPlate": { + "$ref": "#/definitions/TokenValue", + "description": "Annotation plate color. Accepts a TokenValue light/dark pair." + }, "positive": { "$ref": "#/definitions/TokenValue", "description": "Semantic color for positive/up-trend values. Accepts a TokenValue for light/dark pairs." diff --git a/packages/core/src/helpers/annotation-source.ts b/packages/core/src/helpers/annotation-source.ts new file mode 100644 index 00000000..487acca1 --- /dev/null +++ b/packages/core/src/helpers/annotation-source.ts @@ -0,0 +1,31 @@ +import type { Annotation } from '../types/spec'; + +type Node = Record; +const object = (value: unknown): Node | undefined => + value !== null && typeof value === 'object' && !Array.isArray(value) + ? (value as Node) + : undefined; + +/** Matches layer compilation's parent-or-first-leaf annotation ownership. */ +export function annotationSource(spec: unknown): { annotations: Annotation[]; path: string } { + const root = object(spec); + if (Array.isArray(root?.annotations)) + return { annotations: root.annotations, path: '/annotations' }; + function firstLeaf( + node: Node | undefined, + path: string, + ): { annotations: Annotation[]; path: string } | undefined { + if (Array.isArray(node?.layer)) { + for (const [index, child] of node.layer.entries()) { + const result = firstLeaf(object(child), `${path}/layer/${index}`); + if (result) return result; + } + } else if (node && 'mark' in node) { + return { + annotations: Array.isArray(node.annotations) ? node.annotations : [], + path: `${path}/annotations`, + }; + } + } + return firstLeaf(root, '') ?? { annotations: [], path: '/annotations' }; +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 25324d17..66032c68 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -80,6 +80,7 @@ export { BRAND_RESERVE_WIDTH, COMPACT_WIDTH, computeChrome, + computeNoteBandHeight, estimateTextWidth, FOOTNOTE_LINE_HEIGHT, footnoteBandHeight, @@ -231,6 +232,7 @@ export { deepMergeSpec } from './helpers/deep-merge'; // CSS token definitions (single source of truth for tokens.css / dark.css) // --------------------------------------------------------------------------- +export { annotationSource } from './helpers/annotation-source'; export type { MotionPhase, MotionSample } from './motion/sample'; export { FLUID_SPRING, diff --git a/packages/core/src/layout/__tests__/note-band-compat.test.ts b/packages/core/src/layout/__tests__/note-band-compat.test.ts new file mode 100644 index 00000000..82bfdb1e --- /dev/null +++ b/packages/core/src/layout/__tests__/note-band-compat.test.ts @@ -0,0 +1,14 @@ +import { expect, it, vi } from 'vitest'; +import { resolveTheme } from '../../theme/resolve'; +import { footnoteBandHeight } from '../chrome'; + +it('warns once and retains the numeric API while preferring resolved band metadata', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + 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(); +}); diff --git a/packages/core/src/layout/chrome.ts b/packages/core/src/layout/chrome.ts index 485f1858..13774402 100644 --- a/packages/core/src/layout/chrome.ts +++ b/packages/core/src/layout/chrome.ts @@ -179,17 +179,37 @@ export const FOOTNOTE_LINE_HEIGHT = 1.3; /** Gap below the footnote list, separating it from the source row. */ const FOOTNOTE_BAND_GAP = 4; +/** Internal compiler arithmetic and compatibility fallback for hand-built layouts. */ +export function computeNoteBandHeight( + input: number | Pick, + theme: ResolvedTheme, +): number { + if (typeof input !== 'number' && input.noteBandHeight !== undefined) return input.noteBandHeight; + const count = + typeof input === 'number' + ? input + : (input.footnotes?.reduce((sum, note) => sum + (note.lines?.length ?? 1), 0) ?? 0) + + (input.seriesSummary?.reduce((sum, entry) => sum + entry.lines.length, 0) ?? 0); + return count <= 0 + ? 0 + : count * theme.fonts.sizes.small * FOOTNOTE_LINE_HEIGHT + FOOTNOTE_BAND_GAP; +} + +let warnedFootnoteBandHeight = false; /** - * Vertical space a footnote list of `count` lines occupies below the plot. - * - * Single source of truth, deliberately: the engine reserves this band in the - * bottom margin, the chrome renderer shifts the source row down by it, and the - * brand watermark shifts by it too. When those three disagree the footnotes - * land on top of the watermark, which is exactly the bug this centralizes away. + * @deprecated Read layout.chrome.noteBandHeight. The numeric signature remains compatible in 8.x. */ -export function footnoteBandHeight(count: number, theme: ResolvedTheme): number { - if (count <= 0) return 0; - return count * theme.fonts.sizes.small * FOOTNOTE_LINE_HEIGHT + FOOTNOTE_BAND_GAP; +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); } /** diff --git a/packages/core/src/layout/index.ts b/packages/core/src/layout/index.ts index cc0b7f0f..990266b4 100644 --- a/packages/core/src/layout/index.ts +++ b/packages/core/src/layout/index.ts @@ -2,7 +2,12 @@ * Layout module barrel export. */ -export { computeChrome, FOOTNOTE_LINE_HEIGHT, footnoteBandHeight } from './chrome'; +export { + computeChrome, + computeNoteBandHeight, + FOOTNOTE_LINE_HEIGHT, + footnoteBandHeight, +} from './chrome'; export { BRAND_FONT_SIZE, BRAND_MIN_WIDTH, 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 70033004..eb3b06ae 100644 --- a/packages/core/src/styles/__tests__/token-theme-parity.test.ts +++ b/packages/core/src/styles/__tests__/token-theme-parity.test.ts @@ -95,3 +95,26 @@ describe('CSS token / theme parity', () => { expect(dark.colors.neutral.surface).toBe(cssTokenDefault('--oc-bg', 'dark')); }); }); + +it('annotation tokens preserve both legacy modes and authored pairs', () => { + const names = [ + 'annotationLabel', + 'annotationMuted', + 'annotationLeader', + 'annotationRule', + 'annotationRangeFill', + 'annotationMarkerFill', + 'annotationPlate', + ] as const; + const colors = Object.fromEntries( + names.map((name) => [name, { light: '#123456', dark: '#abcdef' }]), + ); + const custom = resolveTheme({ colors }); + for (const name of names) { + const css = `--oc-${name.replace(/[A-Z]/g, (letter) => `-${letter.toLowerCase()}`)}`; + expect(cssTokenDefault(css, 'light')).toBe(light.colors[name]); + expect(cssTokenDefault(css, 'dark')).toBe(dark.colors[name]); + expect(custom.colors[name]).toBe('#123456'); + expect(adaptTheme(custom).colors[name]).toBe('#abcdef'); + } +}); diff --git a/packages/core/src/styles/token-definitions.ts b/packages/core/src/styles/token-definitions.ts index f2ac1132..28502b16 100644 --- a/packages/core/src/styles/token-definitions.ts +++ b/packages/core/src/styles/token-definitions.ts @@ -73,6 +73,13 @@ export const CSS_TOKENS: readonly CssTokenDef[] = [ { name: '--oc-source-size', light: '11px' }, { 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-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 37a4bca5..bba32d60 100644 --- a/packages/core/src/styles/tokens.css +++ b/packages/core/src/styles/tokens.css @@ -98,6 +98,13 @@ --oc-source-size: 11px; --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-marker-fill: #ffffff; + --oc-annotation-plate: #ffffff; --oc-eyebrow-size: 11px; --oc-eyebrow-weight: 500; --oc-eyebrow-tracking: 0.08em; diff --git a/packages/core/src/theme/dark-mode.ts b/packages/core/src/theme/dark-mode.ts index ef7210b9..f06b6f09 100644 --- a/packages/core/src/theme/dark-mode.ts +++ b/packages/core/src/theme/dark-mode.ts @@ -179,6 +179,27 @@ 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'), + annotationMarkerFill: + pairs?.['colors.annotationMarkerFill']?.dark ?? + overridden(theme.colors.annotationMarkerFill, light.annotationMarkerFill, '#0a0a0a'), + annotationPlate: + pairs?.['colors.annotationPlate']?.dark ?? + overridden(theme.colors.annotationPlate, light.annotationPlate, '#0a0a0a'), categorical, categoricalFill, hairline: darkHairline, diff --git a/packages/core/src/theme/defaults.ts b/packages/core/src/theme/defaults.ts index 2fab951e..8810ff17 100644 --- a/packages/core/src/theme/defaults.ts +++ b/packages/core/src/theme/defaults.ts @@ -39,6 +39,13 @@ export const DEFAULT_THEME: ThemeBase = { 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', }, diff --git a/packages/core/src/theme/resolve.ts b/packages/core/src/theme/resolve.ts index e6165bfa..cb9c1e54 100644 --- a/packages/core/src/theme/resolve.ts +++ b/packages/core/src/theme/resolve.ts @@ -12,6 +12,7 @@ import { isOpaqueColor } from '../colors/contrast'; import { deriveNeutralRamp } from '../colors/neutral'; +import { cssTokenDefault } from '../styles/token-definitions'; import type { ChromeThemeOverride, ThemeConfig } from '../types/spec'; import type { ChromeDefaults, ResolvedTheme, ThemeBase, TokenValue } from '../types/theme'; @@ -78,6 +79,20 @@ function themeConfigToPartial(config: ThemeConfig): Partial { colors.annotationFill = resolveTokenLight(config.colors.annotationFill); if (config.colors.annotationText) colors.annotationText = resolveTokenLight(config.colors.annotationText); + if (config.colors.annotationLabel) + colors.annotationLabel = resolveTokenLight(config.colors.annotationLabel); + if (config.colors.annotationMuted) + colors.annotationMuted = resolveTokenLight(config.colors.annotationMuted); + if (config.colors.annotationLeader) + colors.annotationLeader = resolveTokenLight(config.colors.annotationLeader); + if (config.colors.annotationRule) + colors.annotationRule = resolveTokenLight(config.colors.annotationRule); + if (config.colors.annotationRangeFill) + colors.annotationRangeFill = resolveTokenLight(config.colors.annotationRangeFill); + if (config.colors.annotationMarkerFill) + colors.annotationMarkerFill = resolveTokenLight(config.colors.annotationMarkerFill); + if (config.colors.annotationPlate) + colors.annotationPlate = resolveTokenLight(config.colors.annotationPlate); if (config.colors.positive) colors.positive = resolveTokenLight(config.colors.positive); if (config.colors.negative) colors.negative = resolveTokenLight(config.colors.negative); } @@ -169,6 +184,13 @@ function collectTokenPairs( 'axis', 'annotationFill', 'annotationText', + 'annotationLabel', + 'annotationMuted', + 'annotationLeader', + 'annotationRule', + 'annotationRangeFill', + 'annotationMarkerFill', + 'annotationPlate', 'positive', 'negative', ] as const; @@ -331,6 +353,24 @@ export function resolveTheme( const dark = isDarkBackground(merged.colors.background); if (dark) { merged = adaptChromeForDarkBg(merged, merged.colors.text); + // These defaults historically followed a dark canvas even without darkMode. + const authoredColors = + userTheme?.colors && !Array.isArray(userTheme.colors) ? userTheme.colors : {}; + for (const key of [ + 'annotationLabel', + 'annotationMuted', + 'annotationLeader', + 'annotationRule', + 'annotationRangeFill', + 'annotationMarkerFill', + 'annotationPlate', + ] as const) { + if (authoredColors[key] === undefined) + merged.colors[key] = cssTokenDefault( + `--oc-${key.replace(/[A-Z]/g, (c) => `-${c.toLowerCase()}`)}`, + 'dark', + ); + } } return { diff --git a/packages/core/src/types/events.ts b/packages/core/src/types/events.ts index b896e047..372f2c53 100644 --- a/packages/core/src/types/events.ts +++ b/packages/core/src/types/events.ts @@ -140,6 +140,10 @@ export type ElementEdit = 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'; diff --git a/packages/core/src/types/layout.ts b/packages/core/src/types/layout.ts index 6a2443a6..1cc7d551 100644 --- a/packages/core/src/types/layout.ts +++ b/packages/core/src/types/layout.ts @@ -130,6 +130,10 @@ export interface ResolvedChrome { brand?: ResolvedChromeElement; /** Annotation footnotes generated by auto-thinning. Rendered as a numbered list above the source line. */ footnotes?: AnnotationFootnote[]; + /** Reserved annotation-note and series-summary band, independent of the current demoted set. */ + noteBandHeight?: number; + /** Height added by the engine in grow mode; hosts must not add it again. */ + grownHeight?: number; /** Responsive series readout above source/methodology. */ seriesSummary?: Array<{ key: string; text: string; lines: string[]; color: string }>; /** Absolute y at which bottom-chrome relative offsets are anchored: area.y + area.height + x-axis extent. */ @@ -656,6 +660,8 @@ export type Mark = * The collision engine determines whether labels are visible or demoted to tooltip-only. */ export interface ResolvedLabel { + /** Engine-wrapped display lines; text retains the original identity string. */ + lines?: string[]; /** Label text content. */ text: string; /** Computed x position. */ @@ -672,12 +678,18 @@ export interface ResolvedLabel { connector?: { /** Connector start (at the label). */ from: Point; + /** Engine-resolved quadratic control point; absent in older hand-built layouts. */ + control?: Point; + /** Authored curve displacement, retained for geometry rebuilds. */ + bend?: number; /** Connector end (pulled back from the data point so the line doesn't touch it). */ to: Point; /** Actual data point the connector is calling out. Renderer uses this for the endpoint marker. */ endpoint?: Point; /** Author's from-end nudge, re-applied on every geometry rebuild (mirrors `endpoint` for `to`). */ fromOffset?: { dx: number; dy: number }; + /** Fixed label attachment for outside-reference gutter leaders; retained during animation. */ + labelAttachment?: 'left-center'; /** Connector line color. */ stroke: string; /** Connector style: straight line, curved arrow, or vertical drop-line through the data point. */ @@ -756,6 +768,7 @@ export interface ResolvedAnnotation { * label. Positioned `lineHeight * primaryLineCount + gap` below `label.y`. */ subtitle?: { + lines?: string[]; text: string; x: number; y: number; @@ -2257,11 +2270,10 @@ export interface CompileOptions { /** Extra pixels to reserve on the right margin for a secondary y-axis. Set by compileLayer when resolve.scale.y is 'independent'. */ rightAxisReserve?: number; /** - * Extra pixels to reserve below the plot for the auto-thinning footnote list. - * Chicken-and-egg: thinning needs the chart area to decide what to demote, but - * the footnotes it produces need space carved out of that same area. compileChart - * resolves it by compiling once to learn the footnote count, then recompiling with - * this set. Callers should not set it; it is internal to that second pass. + * Internal note-band reserve resolved from source annotation capacity and + * responsive series-summary lines. Subtract mode consumes the fixed figure + * budget; grow mode adds the reserve to the SVG height while keeping the + * original layout budget and padding. Callers should not set this field. */ footnoteReserve?: number; /** Recursion depth of the footnote-reserve convergence pass. Internal to compileChart. */ diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index fab01dc2..398afd01 100644 --- a/packages/core/src/types/spec.ts +++ b/packages/core/src/types/spec.ts @@ -1038,6 +1038,8 @@ export type ConnectorType = 'straight' | 'curve' | 'drop-line'; export interface ConnectorConfig { /** Connector style. */ 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. */ arrow?: boolean; } @@ -1102,6 +1104,10 @@ export interface TextAnnotation extends AnnotationBase { * 700 — the lede + context stack. */ subtitle?: string; + /** Subtitle size in pixels. Default: round(fontSize * 0.85). */ + subtitleFontSize?: number; + /** Optional display wrap width in pixels. Omitted or null keeps unbounded lines. */ + maxWidth?: number | null; /** * Open-ring marker drawn on the data point itself (not on the pulled-back * connector tip). @@ -1216,6 +1222,8 @@ 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. */ + labelPlacement?: 'auto' | 'start' | 'end' | 'outside'; /** Anchor direction for the reference line label. */ labelAnchor?: AnnotationAnchor; /** Label font size in pixels. Default: 11. */ @@ -1300,6 +1308,20 @@ export interface ThemeConfig { annotationFill?: TokenValue; /** Annotation text color. Accepts a TokenValue for light/dark pairs. */ annotationText?: TokenValue; + /** Annotation label color. Accepts a TokenValue light/dark pair. */ + annotationLabel?: TokenValue; + /** Annotation muted color. Accepts a TokenValue light/dark pair. */ + annotationMuted?: TokenValue; + /** Annotation leader color. Accepts a TokenValue light/dark pair. */ + annotationLeader?: TokenValue; + /** Annotation rule color. Accepts a TokenValue light/dark pair. */ + annotationRule?: TokenValue; + /** Annotation rangefill color. Accepts a TokenValue light/dark pair. */ + annotationRangeFill?: TokenValue; + /** Annotation markerfill color. Accepts a TokenValue light/dark pair. */ + annotationMarkerFill?: TokenValue; + /** Annotation plate color. Accepts a TokenValue light/dark pair. */ + annotationPlate?: TokenValue; /** Semantic color for positive/up-trend values. Accepts a TokenValue for light/dark pairs. */ positive?: TokenValue; /** Semantic color for negative/down-trend values. Accepts a TokenValue for light/dark pairs. */ @@ -1628,6 +1650,8 @@ 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. */ + compactAnnotationLimit?: number | null; /** Preserve the final temporal boundary before thinning interior ticks. */ preserveEnd?: boolean; /** Move direct series labels into a keyed list when the inline column cannot fit. */ diff --git a/packages/core/src/types/theme.ts b/packages/core/src/types/theme.ts index 62f980b0..ac288fc4 100644 --- a/packages/core/src/types/theme.ts +++ b/packages/core/src/types/theme.ts @@ -72,6 +72,20 @@ export interface ThemeColors { annotationFill: string; /** Annotation text color. */ annotationText: string; + /** Annotation label color. */ + annotationLabel: string; + /** Annotation muted color. */ + annotationMuted: string; + /** Annotation leader color. */ + annotationLeader: string; + /** Annotation rule color. */ + annotationRule: string; + /** Annotation rangefill color. */ + annotationRangeFill: string; + /** Annotation markerfill color. */ + annotationMarkerFill: string; + /** Annotation plate color. */ + annotationPlate: string; /** Semantic color for positive/up-trend values (e.g. sparkline trend coloring). */ positive: string; /** Semantic color for negative/down-trend values (e.g. sparkline trend coloring). */ 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 054ba8a7..fe080369 100644 --- a/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap +++ b/packages/engine/src/__tests__/__snapshots__/compile-snapshot.test.ts.snap @@ -408,6 +408,13 @@ exports[`compileChart snapshot (Step 7 oracle) > clipped-domain bar chart (data }, "colors": { "annotationFill": "rgba(0,0,0,0.04)", + "annotationLabel": "#333333", + "annotationLeader": "#8f8f8f", + "annotationMarkerFill": "#ffffff", + "annotationMuted": "#6b7280", + "annotationPlate": "#ffffff", + "annotationRangeFill": "#000000", + "annotationRule": "#666666", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", @@ -1511,6 +1518,13 @@ exports[`compileChart snapshot (Step 7 oracle) > legend-heavy multi-series line }, "colors": { "annotationFill": "rgba(0,0,0,0.04)", + "annotationLabel": "#333333", + "annotationLeader": "#8f8f8f", + "annotationMarkerFill": "#ffffff", + "annotationMuted": "#6b7280", + "annotationPlate": "#ffffff", + "annotationRangeFill": "#000000", + "annotationRule": "#666666", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", @@ -2226,6 +2240,13 @@ exports[`compileChart snapshot (Step 7 oracle) > watermarked column chart with g }, "colors": { "annotationFill": "rgba(0,0,0,0.04)", + "annotationLabel": "#333333", + "annotationLeader": "#8f8f8f", + "annotationMarkerFill": "#ffffff", + "annotationMuted": "#6b7280", + "annotationPlate": "#ffffff", + "annotationRangeFill": "#000000", + "annotationRule": "#666666", "annotationText": "#71717a", "axis": "#71717a", "background": "transparent", diff --git a/packages/engine/src/__tests__/compile-footnotes.test.ts b/packages/engine/src/__tests__/compile-footnotes.test.ts index c8a7c513..94c164d5 100644 --- a/packages/engine/src/__tests__/compile-footnotes.test.ts +++ b/packages/engine/src/__tests__/compile-footnotes.test.ts @@ -10,7 +10,7 @@ import type { ChartSpec } from '@opendata-ai/openchart-core'; import { describe, expect, it } from 'vitest'; -import { compileChart } from '../compile'; +import { compileChart, compileLayer } from '../compile'; /** * Six callouts in a narrow plot. At this width the labels are far wider than the @@ -78,9 +78,9 @@ describe('auto-thinning footnote reserve', () => { expect(pulledUpBy).toBeGreaterThanOrEqual(band - 1e-9); }); - it('leaves the plot alone when nothing demotes', () => { + it('keeps labels inline when nothing demotes', () => { const roomy = compileChart(crowdedSpec, { width: 900, height: 500 }); - // Wide enough that the labels fit; no footnotes, so no reserve. + // Wide enough that labels fit; the stable band remains available for later edits. expect(roomy.chrome.footnotes).toBeUndefined(); }); @@ -93,12 +93,163 @@ describe('auto-thinning footnote reserve', () => { expect(footnotes.length).toBeGreaterThan(0); expect(layout.area.height).toBeCloseTo(baseline.area.height, 6); const band = footnotes.length * layout.theme.fonts.sizes.small * 1.3 + 4; - expect(layout.dimensions.height - baseline.dimensions.height).toBeCloseTo(band, 6); + expect(layout.chrome.noteBandHeight).toBeGreaterThanOrEqual(band); + expect(layout.dimensions.height - baseline.dimensions.height).toBeCloseTo( + layout.chrome.noteBandHeight!, + 6, + ); const subtract = compileChart({ ...crowdedSpec, chromeLayout: 'subtract' }, options); expect(subtract.dimensions.height).toBe(options.height); }); + it.each([ + 'subtract', + 'grow', + ] as const)('reserves stable source capacity in %s, including pinned subtitles', (chromeLayout) => { + const spec: ChartSpec = { + ...crowdedSpec, + annotations: crowdedSpec.annotations!.map((annotation) => ({ + ...annotation, + subtitle: 'A longer explanatory subtitle that wraps at narrow widths', + })), + }; + const options = { width: 390, height: 600, chromeLayout }; + const automatic = compileChart(spec, options); + const pinned = compileChart( + { + ...spec, + annotations: spec.annotations!.map((annotation) => ({ ...annotation, responsive: false })), + }, + options, + ); + expect(automatic.chrome.footnotes?.length).toBeGreaterThan(0); + expect(pinned.chrome.footnotes).toBeUndefined(); + expect(automatic.chrome.noteBandHeight).toBeGreaterThan(0); + expect(pinned.chrome.noteBandHeight).toEqual(automatic.chrome.noteBandHeight); + expect(pinned.area).toEqual(automatic.area); + expect(pinned.dimensions).toEqual(automatic.dimensions); + expect(pinned.chrome.bottomAnchorY).toEqual(automatic.chrome.bottomAnchorY); + }); + + it.each([ + false, + true, + ])('preserves the original grow plot budget and padding with authored height=%s', (authoredHeight) => { + const spec: ChartSpec = { + ...crowdedSpec, + chromeLayout: 'grow', + ...(authoredHeight ? { height: 400 } : {}), + annotations: crowdedSpec.annotations!.map((annotation) => ({ + ...annotation, + responsive: false, + })), + }; + const options = { width: 600, height: authoredHeight ? 200 : 400 }; + const baseline = compileChart({ ...spec, annotations: [] }, options); + const reserved = compileChart(spec, options); + expect(reserved.chrome.noteBandHeight).toBeGreaterThan(0); + expect(reserved.area).toEqual(baseline.area); + expect(reserved.dimensions.height - baseline.dimensions.height).toBeCloseTo( + reserved.chrome.noteBandHeight!, + 8, + ); + }); + + it.each([ + ['subtract', false], + ['grow', false], + ['subtract', true], + ['grow', true], + ] as const)('reserves sparkline notes in %s with pinned=%s', (chromeLayout, pinned) => { + const spec: ChartSpec = { + ...crowdedSpec, + display: 'sparkline', + chromeLayout, + annotations: crowdedSpec.annotations!.map((annotation) => ({ + ...annotation, + x: '2021-01-01', + y: 22, + offset: { dx: 0, dy: 0 }, + ...(pinned ? { responsive: false } : {}), + })), + }; + const options = { width: 600, height: 400 }; + const baseline = compileChart({ ...spec, annotations: [] }, options); + const reserved = compileChart(spec, options); + const band = reserved.chrome.noteBandHeight!; + expect(band).toBeGreaterThan(0); + if (!pinned) expect(reserved.chrome.footnotes?.length).toBeGreaterThan(0); + expect(reserved.area.height).toBeCloseTo( + baseline.area.height - (chromeLayout === 'subtract' ? band : 0), + 8, + ); + expect(reserved.dimensions.height).toBeCloseTo( + baseline.dimensions.height + (chromeLayout === 'grow' ? band : 0), + 8, + ); + }); + + it('reports exhausted fixed subtract budgets instead of silently changing sizing mode', () => { + const spec: ChartSpec = { + ...crowdedSpec, + chromeLayout: 'subtract', + annotations: Array.from({ length: 10 }, (_, index) => ({ + type: 'text', + id: `pinned-${index}`, + x: '2021-01-01', + y: 22, + text: `Note ${index}`, + subtitle: + 'A detailed explanatory subtitle that takes additional lines at the narrow chart width.', + responsive: false, + })), + }; + const diagnostics: string[] = []; + const layout = compileChart(spec, { + width: 390, + height: 300, + onDiagnostic: (diagnostic) => diagnostics.push(diagnostic.code), + }); + expect(layout.dimensions.height).toBe(300); + expect(layout.area.height).toBe(0); + expect(diagnostics).toContain('layout.note-capacity'); + expect(diagnostics).not.toContain('layout.constrained'); + const layerDiagnostics: string[] = []; + const layered = compileLayer( + { layer: [spec, spec], annotations: spec.annotations }, + { + width: 390, + height: 300, + onDiagnostic: (diagnostic) => layerDiagnostics.push(diagnostic.code), + }, + ); + expect(layered.area.height).toBe(0); + expect(layerDiagnostics.filter((code) => code === 'layout.note-capacity')).toHaveLength(1); + const facetDiagnostics: string[] = []; + const faceted = compileChart( + { + ...spec, + data: [{ region: 'North', date: '2021-01-01', value: 22 }], + encoding: { ...spec.encoding, facet: { field: 'region', type: 'nominal' } }, + annotations: Array.from({ length: 100 }, (_, i) => ({ + ...spec.annotations![0], + id: `facet-${i}`, + })), + }, + { + width: 390, + height: 300, + onDiagnostic: (diagnostic) => facetDiagnostics.push(diagnostic.code), + }, + ); + expect(faceted.area.height).toBe(0); + expect(facetDiagnostics.filter((code) => code === 'layout.note-capacity')).toHaveLength(1); + + const grown = compileChart({ ...spec, chromeLayout: 'grow' }, { width: 390, height: 300 }); + expect(grown.area.height).toBeGreaterThan(80); + }); + it('converges instead of recursing forever', () => { // Reserving space shrinks the plot, which can demote one more label and grow // the band again. This must settle, not spin. @@ -163,6 +314,45 @@ 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 grown = compileChart( + onSpec ? { ...facetedSpec, chromeLayout: 'grow' } : facetedSpec, + onSpec ? options : { ...options, chromeLayout: 'grow' }, + ); + const growth = grown.chrome.grownHeight!; + expect(growth).toBeGreaterThan(0); + expect(grown.dimensions.height - baseline.dimensions.height).toBeCloseTo(growth); + expect(grown.area.height - baseline.area.height).toBeCloseTo(growth); + expect(grown.facet!.panels.length).toBe(baseline.facet!.panels.length); + expect(grown.chrome.noteBandHeight).toBe(baseline.chrome.noteBandHeight); + }); + + it('reserves each facet source once and keeps the grid fixed when notes disappear', () => { + const size = { width: 640, height: 600 }; + const before = compileChart(facetedSpec, size); + const after = compileChart( + { + ...facetedSpec, + annotations: facetedSpec.annotations!.map((annotation) => ({ + ...annotation, + responsive: false, + })), + }, + size, + ); + const single = compileChart({ ...facetedSpec, encoding: { ...crowdedSpec.encoding } }, size); + expect(before.chrome.footnotes?.length).toBeGreaterThan(0); + expect(after.chrome.footnotes).toBeUndefined(); + expect(before.chrome.noteBandHeight).toEqual(single.chrome.noteBandHeight); + expect(after.chrome.noteBandHeight).toEqual(before.chrome.noteBandHeight); + expect(after.facet!.panels.map((panel) => panel.area)).toEqual( + before.facet!.panels.map((panel) => panel.area), + ); + expect(after.dimensions).toEqual(before.dimensions); + }); + it('numbers a demoted annotation once, not once per panel', () => { const layout = compileChart(facetedSpec, { width: 640, height: 400 }); const footnotes = layout.chrome.footnotes ?? []; diff --git a/packages/engine/src/__tests__/compile-layer.test.ts b/packages/engine/src/__tests__/compile-layer.test.ts index 7c9e4a1a..d093b1ff 100644 --- a/packages/engine/src/__tests__/compile-layer.test.ts +++ b/packages/engine/src/__tests__/compile-layer.test.ts @@ -424,6 +424,45 @@ describe('compileLayer', () => { // Independent y-scales (dual-axis) // ------------------------------------------------------------------------- + it.each([ + 'subtract', + 'grow', + ] as const)('aligns independent axes with a stable note band and no endpoint column in %s', (chromeLayout) => { + const leaf: ChartSpec = { + mark: 'point', + endpointLabels: false, + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + }; + const layout = compileLayer( + { + layer: [leaf, leaf], + chromeLayout, + resolve: { scale: { y: 'independent' } }, + annotations: [ + { type: 'text', id: 'a', text: 'A', x: 3, y: 5, responsive: false }, + { type: 'text', id: 'b', text: 'B', x: 7, y: 5, responsive: false }, + ], + }, + { width: 600, height: 400 }, + ); + expect(layout.chrome.noteBandHeight).toBeGreaterThan(0); + expect(layout.endpointLabels).toBeUndefined(); + expect(layout.axes.y2!.ticks.map((tick) => tick.position)).toEqual( + layout.axes.y!.ticks.map((tick) => tick.position), + ); + const points = layout.marks.filter((mark) => mark.type === 'point'); + expect(points.slice(0, 2).map((point) => point.cy)).toEqual( + points.slice(2).map((point) => point.cy), + ); + }); + it('produces a y2 axis when resolve.scale.y is independent', () => { const spec: LayerSpec = { resolve: { scale: { y: 'independent' } }, diff --git a/packages/engine/src/__tests__/compile-legend-gutter.test.ts b/packages/engine/src/__tests__/compile-legend-gutter.test.ts new file mode 100644 index 00000000..7028ec25 --- /dev/null +++ b/packages/engine/src/__tests__/compile-legend-gutter.test.ts @@ -0,0 +1,51 @@ +import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; +import { expect, it } from 'vitest'; +import { compileChart, compileLayer } from '../compile'; + +const series: ChartSpec = { + mark: 'line', + data: ['North', 'South'].flatMap((region, index) => [ + { year: '2024', value: 10 + index * 5, region }, + { year: '2025', value: 20 + index * 5, region }, + ]), + encoding: { + x: { field: 'year', type: 'ordinal' }, + y: { field: 'value', type: 'quantitative' }, + color: { field: 'region', type: 'nominal' }, + }, + endpointLabels: true, +}; + +for (const position of ['right', 'bottom-right'] as const) { + it.each([ + 'chart', + 'shared', + 'independent', + ] as const)(`places ${position} legend in its reserved gutter for %s`, (kind) => { + const spec: ChartSpec = { + ...series, + legend: { show: true, position }, + annotations: [ + { type: 'text', id: 'a', x: '2024', y: 15, text: 'First note' }, + { type: 'text', id: 'b', x: '2025', y: 25, text: 'Second note' }, + ], + }; + const options = { width: 1000, height: 450 }; + const layered: LayerSpec = { + layer: [series, series], + legend: spec.legend, + annotations: spec.annotations, + ...(kind === 'independent' ? { resolve: { scale: { y: 'independent' as const } } } : {}), + }; + const layout = kind === 'chart' ? compileChart(spec, options) : compileLayer(layered, options); + const column = layout.endpointLabels!; + expect(column.entries.length).toBeGreaterThan(0); + const legend = layout.legend; + expect(legend.bounds.width).toBeGreaterThan(0); + expect(legend.bounds.x).toBeGreaterThanOrEqual(column.bounds.x + column.bounds.width + 7.99); + expect(legend.bounds.x + legend.bounds.width).toBeLessThanOrEqual(options.width); + if ('entryPositions' in legend) { + expect(legend.entryPositions!.every((entry) => entry.x >= legend.bounds.x)).toBe(true); + } + }); +} diff --git a/packages/engine/src/__tests__/dimensions.test.ts b/packages/engine/src/__tests__/dimensions.test.ts index 4542baa5..74db79b0 100644 --- a/packages/engine/src/__tests__/dimensions.test.ts +++ b/packages/engine/src/__tests__/dimensions.test.ts @@ -460,7 +460,7 @@ describe('computeDimensions', () => { expect(dimsSmall.margins.bottom).toBe(dimsNone.margins.bottom); }); - it('does not reserve annotation margin when strategy is tooltip-only', () => { + it('keeps plot margins independent of annotation placement and visibility', () => { const specWithAnnotations: NormalizedChartSpec = { ...baseSpec, annotations: [{ type: 'text', x: '2021-01-01', y: 20, text: 'Right-edge annotation' }], @@ -500,8 +500,8 @@ describe('computeDimensions', () => { lightTheme, ); - // Inline strategy should reserve extra right margin for the annotation - expect(dimsInline.margins.right).toBeGreaterThan(dimsNoAnnotations.margins.right); + // Inline callouts no longer change the data coordinate space + expect(dimsInline.margins.right).toBe(dimsNoAnnotations.margins.right); // Tooltip-only should NOT reserve extra margin (annotations are hidden) expect(dimsTooltipOnly.margins.right).toBe(dimsNoAnnotations.margins.right); }); diff --git a/packages/engine/src/__tests__/facet-grow-opt-in.test.ts b/packages/engine/src/__tests__/facet-grow-opt-in.test.ts new file mode 100644 index 00000000..d69a4e72 --- /dev/null +++ b/packages/engine/src/__tests__/facet-grow-opt-in.test.ts @@ -0,0 +1,71 @@ +import type { ChartSpec } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../compile'; + +const spec: ChartSpec = { + mark: 'point', + data: [ + { x: 0, y: 0, group: 'A' }, + { x: 10, y: 10, group: 'A' }, + { x: 0, y: 0, group: 'B' }, + { x: 10, y: 10, group: 'B' }, + ], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + facet: { field: 'group', type: 'nominal', columns: 2 }, + }, + chrome: { title: 'Two panels', source: 'Source' }, + annotations: [ + { type: 'text', id: 'one', x: 3, y: 3, text: 'First', subtitle: 'Detail' }, + { type: 'text', id: 'two', x: 6, y: 6, text: 'Second' }, + ], + responsive: { compactAnnotationLimit: 0 }, +}; +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 grow = compileChart({ ...spec, chromeLayout: 'grow' }, { width: 390, height: 650 }); + const added = + grow.chrome.topHeight + grow.chrome.bottomHeight + (grow.chrome.noteBandHeight ?? 0); + expect(grow.dimensions.height).toBeCloseTo(650 + added); + expect(grow.chrome.grownHeight).toBeCloseTo(added); + expect(grow.area.height - subtract.area.height).toBeCloseTo(added); + expect(grow.chrome.footnotes).toHaveLength(2); + expect( + grow.facet!.panels.every((panel) => panel.annotations.every((a) => a.footnoteIndex)), + ).toBe(true); + }); + it('keeps default/subtract identical and accepts the compile option', () => { + const opts = { width: 800, height: 650 }; + expect(compileChart(spec, opts)).toEqual( + compileChart({ ...spec, chromeLayout: 'subtract' }, opts), + ); + expect(compileChart(spec, { ...opts, chromeLayout: 'grow' })).toEqual( + compileChart({ ...spec, chromeLayout: 'grow' }, opts), + ); + }); +}); + +it('reports all engine-owned growth for short multi-row facets', () => { + const input: ChartSpec = { + ...spec, + chromeLayout: 'grow', + encoding: { ...spec.encoding, facet: { ...spec.encoding.facet!, columns: 1 } }, + }; + const result = compileChart(input, { width: 390, height: 200 }); + expect(result.chrome.grownHeight).toBeCloseTo(result.dimensions.height - 200); +}); + +it('grows an authored-height facet budget and reports actual added height', () => { + const input: ChartSpec = { + ...spec, + height: 200, + chromeLayout: 'grow', + encoding: { ...spec.encoding, facet: { ...spec.encoding.facet!, columns: 1 } }, + }; + const result = compileChart(input, { width: 390, height: 600 }); + const inferred = compileChart({ ...input, height: undefined }, { width: 390, height: 200 }); + expect(result.dimensions.height).toBe(inferred.dimensions.height); + expect(result.chrome.grownHeight).toBeCloseTo(result.dimensions.height - 200); +}); diff --git a/packages/engine/src/__tests__/migrate-spec-v8.test.ts b/packages/engine/src/__tests__/migrate-spec-v8.test.ts index 753f72cd..59ec70f2 100644 --- a/packages/engine/src/__tests__/migrate-spec-v8.test.ts +++ b/packages/engine/src/__tests__/migrate-spec-v8.test.ts @@ -45,7 +45,7 @@ describe('migrateSpecV8 alias repairs', () => { ])('rewrites %j, preserves input, records repairs, and is idempotent', (source, expected) => { const before = structuredClone(source); const result = migrateSpecV8(source); - expect(result.spec).toEqual(expected); + expect(result.spec).toEqual(migrateSpecV8(expected).spec); expect(source).toEqual(before); expect(result.repairs.length).toBeGreaterThan(0); expect(migrateSpecV8(result.spec)).toEqual({ spec: result.spec, repairs: [] }); @@ -53,7 +53,8 @@ describe('migrateSpecV8 alias repairs', () => { it('keeps record tilemap valueFormat', () => { const spec = { type: 'tilemap', data: { CA: 4 }, valueFormat: '.2f' }; - expect(migrateSpecV8(spec)).toEqual({ spec, repairs: [] }); + expect(migrateSpecV8(spec).spec).toMatchObject(spec); + expect(migrateSpecV8(migrateSpecV8(spec).spec).repairs).toEqual([]); }); it('rewrites nested layer aliases with root-relative repair paths', () => { @@ -117,7 +118,7 @@ describe('legacy label suppression migration', () => { migrateSpecV8({ ...spec, encoding: { ...spec.encoding, color: { field: 's', type: 'quantitative' } }, - }).repairs, + }).repairs.filter((repair) => repair.path === '/endpointLabels'), ).toEqual([]); }); it.each(['waffle', 'parliament'])('rewrites %s y while preserving authored theta', (mark) => { @@ -125,7 +126,7 @@ describe('legacy label suppression migration', () => { mark, encoding: { y: { field: 'old' }, theta: { field: 'v' }, radius: { field: 'unused' } }, }; - expect(migrateSpecV8(spec).spec).toEqual({ mark, encoding: { theta: { field: 'v' } } }); + expect(migrateSpecV8(spec).spec.encoding).toEqual({ theta: { field: 'v' } }); }); }); @@ -167,3 +168,101 @@ it('warns once for legacy chrome colors supplied through compile options', () => compileChart(chart, options); expect(warnings.filter((message) => message.includes('theme.chrome'))).toHaveLength(1); }); + +describe('migrateSpecV8 default materialization', () => { + it('pins v8 annotation defaults without replacing authored opt-ins', () => { + const source = { + ...chart, + annotations: [ + { type: 'text', x: 0, y: 0, text: 'Headline', subtitle: 'Detail', connector: 'curve' }, + { + type: 'text', + x: 1, + y: 1, + text: 'Custom', + subtitle: 'Detail', + fontSize: 20, + fontWeight: 400, + subtitleFontSize: 17, + maxWidth: 90, + connector: { type: 'curve', arrow: false }, + }, + { type: 'refline', y: 0, label: 'Horizontal' }, + { type: 'refline', x: 0, label: 'Vertical' }, + ], + }; + const { spec, repairs } = migrateSpecV8(source); + expect(spec.annotations[0]).toMatchObject({ + fontWeight: 700, + subtitleFontSize: 11, + maxWidth: null, + connector: { type: 'curve', arrow: true }, + }); + expect(spec.annotations[1]).toEqual(source.annotations[1]); + expect(spec.annotations[2]).toMatchObject({ labelAnchor: 'top' }); + expect(spec.annotations[3]).toMatchObject({ labelAnchor: 'left' }); + expect(spec).toMatchObject({ + responsive: { compactAnnotationLimit: null }, + chromeLayout: 'subtract', + }); + expect(repairs.map((repair) => repair.path)).toContain('/annotations/0/maxWidth'); + expect(migrateSpecV8(spec).spec).toMatchObject(spec); + expect(migrateSpecV8(migrateSpecV8(spec).spec).repairs).toEqual([]); + }); + it.each([ + false, + { autoThin: false }, + undefined, + ])('preserves opt-out semantics (%j)', (responsive) => { + const source = { ...chart, width: 390, height: 500, responsive }; + const migrated = migrateSpecV8(source).spec; + expect(compileChart(migrated, { width: 390, height: 500 }).annotations).toEqual( + compileChart(source, { width: 390, height: 500 }).annotations, + ); + 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', () => { + const source = { + theme: { colors: { categorical: ['green'] } }, + layer: [chart, { ...chart, theme: { fonts: { sizes: { small: 16 } } } }], + }; + const opts = { width: 640, height: 500 }; + expect(compileLayer(migrateSpecV8(source).spec, opts).marks).toEqual( + compileLayer(source, opts).marks, + ); +}); + +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' }], + }; + 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); +}); 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 ca4e67e9..bb071504 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 @@ -1,5 +1,5 @@ import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; -import { estimateTextWidth, footnoteBandHeight } from '@opendata-ai/openchart-core'; +import { computeNoteBandHeight, estimateTextWidth } from '@opendata-ai/openchart-core'; import { describe, expect, it } from 'vitest'; import { compileChart, compileLayer } from '../compile'; import { migrateSpecV8 } from '../migration/v8'; @@ -80,7 +80,7 @@ describe('wrapped annotation notes', () => { ).toBeLessThanOrEqual(options.width - 2 * layout.theme.spacing.padding); } const baseline = compileChart({ ...noteSpec, annotations: [] }, options); - const band = footnoteBandHeight( + const band = computeNoteBandHeight( notes.reduce((n, note) => n + note.lines!.length, 0), layout.theme, ); @@ -179,7 +179,7 @@ it.each([ const layout = compileLayer(spec, { width: 390, height: 450 }); const notes = layout.chrome.footnotes!; expect(notes.length).toBeGreaterThan(0); - const noteBand = footnoteBandHeight( + const noteBand = computeNoteBandHeight( notes.reduce((n, note) => n + (note.lines?.length ?? 1), 0), layout.theme, ); @@ -272,7 +272,7 @@ it('reserves wrapped notes once beneath a facet grid', () => { expect(notes.length).toBeGreaterThan(0); expect(notes.every((note) => (note.lines?.length ?? 0) > 1)).toBe(true); expect(new Set(notes.map((note) => note.id)).size).toBe(notes.length); - const reserve = footnoteBandHeight( + const reserve = computeNoteBandHeight( notes.reduce((sum, note) => sum + note.lines!.length, 0), layout.theme, ); diff --git a/packages/engine/src/annotations/__tests__/connector-control.test.ts b/packages/engine/src/annotations/__tests__/connector-control.test.ts new file mode 100644 index 00000000..31377f38 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/connector-control.test.ts @@ -0,0 +1,69 @@ +import type { ResolvedLabel } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { automaticControlPoint, refreshConnector } from '../geometry'; +import { isAutoPlacement } from '../placement'; + +const connector: NonNullable = { + from: { x: 10, y: 20 }, + to: { x: 200, y: 160 }, + endpoint: { x: 205, y: 165 }, + stroke: 'black', + style: 'curve', + arrow: true, +}; +const bounds = { x: 10, y: 10, width: 70, height: 20 }; + +describe('engine connector control', () => { + it('preserves the automatic formula, including missing-exit fallback', () => { + expect(automaticControlPoint({ x: 10, y: 20 }, { x: 110, y: 120 })).toEqual({ x: 30, y: 80 }); + expect(automaticControlPoint({ x: 10, y: 20 }, { x: 110, y: 70 })).toEqual({ x: 70, y: 30 }); + expect(automaticControlPoint({ x: 10, y: 20 }, { x: 110, y: 70 }, 'vertical')).toEqual({ + x: 30, + y: 50, + }); + }); + it('omitted and zero bend produce exactly the same control point', () => { + const plain = refreshConnector(connector, bounds, 0)!; + const zero = refreshConnector({ ...connector, bend: 0 }, bounds, 0)!; + expect(plain.control).toEqual(automaticControlPoint(plain.from, plain.to, plain.exit)); + expect(zero.control).toEqual(plain.control); + }); + it.each([-1, -0.5, 0.5, 1])('displaces by the directed chord normal for bend %s', (bend) => { + const base = refreshConnector(connector, bounds, 0)!; + const bent = refreshConnector({ ...connector, bend }, bounds, 0)!; + expect(bent.control!.x).toBeCloseTo(base.control!.x - bend * (base.from.y - base.to.y)); + expect(bent.control!.y).toBeCloseTo(base.control!.y + bend * (base.from.x - base.to.x)); + expect(bent.from).toEqual(base.from); + expect(bent.to).toEqual(base.to); + }); + it('recomputes control after a block move and preserves authored attachment offset', () => { + const original = refreshConnector( + { ...connector, fromOffset: { dx: 5, dy: -8 }, bend: 0.4 }, + bounds, + 4, + )!; + const next = refreshConnector(original, { ...bounds, x: 90 }, 4)!; + expect(next.control).not.toEqual(original.control); + expect(next.endpoint).toEqual(connector.endpoint); + expect(next.fromOffset).toEqual({ dx: 5, dy: -8 }); + }); +}); + +describe('manual placement predicate', () => { + it.each([undefined])('treats %j as automatic', (offset) => { + expect(isAutoPlacement({ offset })).toBe(true); + }); + it.each([ + {}, + { dx: 0 }, + { dx: 0, dy: 0 }, + { dx: 1 }, + { dy: -1 }, + ])('treats %j as manual', (offset) => { + expect(isAutoPlacement({ offset })).toBe(false); + }); + it('keeps explicit anchors and drop-lines manual even with zero offset', () => { + expect(isAutoPlacement({ anchor: 'left', offset: { dx: 0, dy: 0 } })).toBe(false); + expect(isAutoPlacement({ connector: 'drop-line' })).toBe(false); + }); +}); diff --git a/packages/engine/src/annotations/__tests__/edit-context.test.ts b/packages/engine/src/annotations/__tests__/edit-context.test.ts new file mode 100644 index 00000000..3a7162b2 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/edit-context.test.ts @@ -0,0 +1,312 @@ +import type { ChartSpec, TextAnnotation } from '@opendata-ai/openchart-core'; +import { elementRef } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { annotationCrowdedSpec } from '../../../../../examples/src/gallery/features-annotations.demos'; +import { compileChart, compileLayer } from '../../compile'; +import { validateSpec } from '../../compiler/validate'; +import { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, + previewConnectorBend, + previewConnectorOffset, +} from '../edit-context'; + +export const editSpec: ChartSpec = { + mark: 'point', + animation: false, + responsive: { autoThin: false }, + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + chrome: { title: 'An annotation edit' }, + annotations: [ + { + type: 'text', + id: 'callout', + x: 9.9, + y: 5, + text: 'A heading', + subtitle: 'A wider second line', + connector: 'curve', + }, + ], +}; +const options = { width: 600, height: 400 }; +const ref = elementRef.annotation(0, 'callout'); + +describe('resolved annotation editing', () => { + it.each([ + { dx: -10, dy: 10 }, + { dx: -999, dy: -999 }, + { dx: 999, dy: 999 }, + ])('first drag commits the preview including clamp: %j', (delta) => { + const layout = compileChart(editSpec, options); + expect(layout.annotations[0].label!.style.textAnchor).toBe('end'); + const before = structuredClone(layout.annotations); + const ctx = createAnnotationEditContext(layout, editSpec); + const preview = previewAnnotationMove(ctx, ref, delta); + const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); + expect(placement.anchor).not.toBe('auto'); + expect(placement.responsive).toBe(false); + const nextSpec = { ...editSpec, annotations: [{ ...editSpec.annotations![0], ...placement }] }; + const committed = compileChart(nextSpec, options).annotations[0]; + expect(committed.label!.x).toBeCloseTo(preview.label!.x, 8); + expect(committed.label!.y).toBeCloseTo(preview.label!.y, 8); + expect(committed.subtitle!.x).toBeCloseTo(preview.subtitle!.x, 8); + expect(committed.subtitle!.y).toBeCloseTo(preview.subtitle!.y, 8); + const round = (value: unknown) => + JSON.parse( + JSON.stringify(value, (_key, item) => + typeof item === 'number' ? Math.round(item * 1e8) / 1e8 : item, + ), + ); + expect(round(committed.label!.connector)).toEqual(round(preview.label!.connector)); + expect(committed.dot).toEqual(layout.annotations[0].dot); + expect(layout.annotations).toEqual(before); + }); + it.each([ + ['subtract', -20], + ['grow', -20], + ['subtract', 999], + ['grow', 999], + ] as const)('keeps a crowded note-bearing %s plot fixed on drag %s', (chromeLayout, dy) => { + const spec = structuredClone(annotationCrowdedSpec); + const size = { width: 390, height: 600, chromeLayout }; + const layout = compileChart(spec, size); + const ctx = createAnnotationEditContext(layout, spec); + const ref = elementRef.annotation(0, 'crowded-0'); + const preview = previewAnnotationMove(ctx, ref, { dx: -20, dy }); + const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); + const annotations = spec.annotations!.map((annotation, index) => + index === 0 ? { ...annotation, ...placement } : annotation, + ); + const committed = compileChart({ ...spec, annotations }, size); + const result = committed.annotations.find((annotation) => annotation.specIndex === 0)!; + expect(result.label!.y).toBeCloseTo(preview.label!.y, 8); + expect(committed.area).toEqual(layout.area); + expect(committed.dimensions).toEqual(layout.dimensions); + expect(committed.chrome.noteBandHeight).toEqual(layout.chrome.noteBandHeight); + }); + it('keeps the plot and attachment fixed when committing a callout at maximum x', () => { + const spec = { ...editSpec, annotations: [{ ...editSpec.annotations![0], x: 10 }] }; + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + const preview = previewAnnotationMove(ctx, ref, { dx: -60, dy: -30 }); + const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); + const next = compileChart( + { ...spec, annotations: [{ ...spec.annotations[0], ...placement }] }, + options, + ); + expect(next.area).toEqual(layout.area); + expect(next.annotations[0].label!.x).toBeCloseTo(preview.label!.x, 8); + expect(next.annotations[0].label!.connector?.endpoint).toEqual( + preview.label!.connector?.endpoint, + ); + }); + it('round-trips a moved drop-line including its subtitle and fixed marker', () => { + const spec: ChartSpec = { + ...editSpec, + annotations: [{ ...editSpec.annotations![0], connector: 'drop-line' }], + }; + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + const preview = previewAnnotationMove(ctx, ref, { dx: 20, dy: 20 }); + const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); + const next = compileChart( + { ...spec, annotations: [{ ...spec.annotations![0], ...placement }] }, + options, + ).annotations[0]; + expect(next.label!.x).toBeCloseTo(preview.label!.x, 8); + expect(next.label!.y).toBeCloseTo(preview.label!.y, 8); + expect(next.dot).toEqual(preview.dot); + expect(next.label!.connector).toEqual(preview.label!.connector); + }); + it('keeps zero-offset placement pinned against data obstacles', () => { + const pinned = { + ...editSpec, + annotations: [{ ...editSpec.annotations![0], anchor: 'left', offset: { dx: 0, dy: 0 } }], + } as ChartSpec; + const layout = compileChart(pinned, options); + const ctx = createAnnotationEditContext(layout, pinned); + const inverse = inverseLabelPlacement(ctx, ref, layout.annotations[0].bounds!); + expect(inverse.offset).toEqual({ dx: 0, dy: 0 }); + const next = compileChart( + { ...pinned, annotations: [{ ...pinned.annotations![0], ...inverse }] }, + options, + ); + expect(next.annotations).toEqual(layout.annotations); + }); + it('uses the first leaf despite ignored intermediate layer annotations', () => { + const spec = { + layer: [ + { + annotations: [{ type: 'text', id: 'ignored', x: 0, y: 0, text: 'Ignored' }], + layer: [editSpec], + }, + ], + }; + const layout = compileLayer(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + expect( + previewAnnotationMove( + ctx, + { ...ref, path: '/layer/0/layer/0/annotations/0' }, + { dx: 5, dy: 0 }, + ).id, + ).toBe('callout'); + expect(() => + previewAnnotationMove(ctx, { ...ref, path: '/layer/0/annotations/0' }, { dx: 5, dy: 0 }), + ).toThrow(); + }); + it('validates bend in root and nested layer annotations', () => { + const bad = { type: 'text', x: 0, y: 0, text: 'Bad', connector: { type: 'curve', bend: 3 } }; + const result = validateSpec({ + annotations: [bad], + layer: [{ layer: [{ ...editSpec, annotations: [bad] }] }], + }); + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.path)).toContain('annotations[0].connector.bend'); + expect(result.errors.map((error) => error.path)).toContain( + 'layer[0].layer[0].annotations[0].connector.bend', + ); + }); + it('preserves the existing null connector fallback without throwing a warning error', () => { + expect(() => + compileChart( + { + ...editSpec, + annotations: [{ ...editSpec.annotations![0], connector: null }], + } as unknown as ChartSpec, + options, + ), + ).not.toThrow(); + }); + it('validates bend on graph annotations too', () => { + const result = validateSpec({ + type: 'graph', + nodes: [{ id: 'a' }], + edges: [], + annotations: [ + { type: 'text', text: 'Bad', x: 0, y: 0, connector: { type: 'curve', bend: NaN } }, + ], + }); + expect(result.valid).toBe(false); + expect(result.errors.map((error) => error.path)).toContain('annotations[0].connector.bend'); + }); + it('freezes the original layout and source during a gesture', () => { + const spec = structuredClone(editSpec); + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + const original = previewAnnotationMove(ctx, ref, { dx: 0, dy: 0 }); + layout.annotations[0].label!.x += 100; + (spec.annotations![0] as TextAnnotation).text = 'mutated'; + expect(previewAnnotationMove(ctx, ref, { dx: 0, dy: 0 })).toEqual(original); + }); + it('bend preview matches compilation and leaves label and anchor fixed', () => { + const spec = { + ...editSpec, + annotations: [{ ...editSpec.annotations![0], anchor: 'left', offset: { dx: -20, dy: -20 } }], + } as ChartSpec; + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + const preview = previewConnectorBend(ctx, ref, 0.5); + const next = compileChart( + { + ...spec, + annotations: [{ ...spec.annotations![0], connector: { type: 'curve', bend: 0.5 } }], + }, + options, + ); + expect(preview).toEqual(next.annotations[0]); + expect(preview.label!.x).toBe(layout.annotations[0].label!.x); + expect(preview.dot).toEqual(layout.annotations[0].dot); + }); + it.each([ + 'straight', + 'curve', + 'drop-line', + ] as const)('%s endpoint previews round trip', (type) => { + const spec = { + ...editSpec, + annotations: [ + { + ...editSpec.annotations![0], + x: 5, + y: 5, + anchor: 'left', + offset: { dx: -40, dy: -40 }, + connector: { type }, + dot: true, + }, + ], + } as ChartSpec; + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + for (const end of ['from', 'to'] as const) { + const delta = { dx: 20, dy: 20 }; + const preview = previewConnectorOffset(ctx, ref, end, delta); + const next = compileChart( + { ...spec, annotations: [{ ...spec.annotations![0], connectorOffset: { [end]: delta } }] }, + options, + ); + expect(preview).toEqual(next.annotations[0]); + } + }); + it.each([-1.01, 1.01, NaN, Infinity, '0.5'])('rejects invalid bend %s', (bend) => { + const result = validateSpec({ + ...editSpec, + annotations: [{ ...editSpec.annotations![0], connector: { type: 'curve', bend } }], + }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.path.includes('bend'))).toBe(true); + }); + it.each(['straight', 'drop-line'])('warns and ignores bend for %s', (type) => { + const warnings: string[] = []; + const annotation = { ...editSpec.annotations![0], connector: { type, bend: 0.5 } }; + const bent = compileChart( + { ...editSpec, annotations: [annotation] }, + { ...options, onWarn: (warning) => warnings.push(warning) }, + ); + const plain = compileChart( + { ...editSpec, annotations: [{ ...annotation, connector: { type } }] }, + options, + ); + expect(bent.annotations).toEqual(plain.annotations); + expect(warnings.some((w) => w.includes('bend'))).toBe(true); + }); +}); + +it.each([ + 'straight', + 'curve', + 'drop-line', +] as const)('wrapped %s previews retain line geometry after release', (connector) => { + const spec: ChartSpec = { + ...editSpec, + annotations: [ + { + ...editSpec.annotations![0], + text: 'A **long heading** for wrapped geometry', + subtitle: 'A **supporting** line with more detail', + maxWidth: 90, + subtitleFontSize: 15, + connector, + }, + ], + }; + const layout = compileChart(spec, options); + const ctx = createAnnotationEditContext(layout, spec); + const preview = previewAnnotationMove(ctx, ref, { dx: -50, dy: 30 }); + const placement = inverseLabelPlacement(ctx, ref, preview.bounds!); + const committed = compileChart( + { ...spec, annotations: [{ ...spec.annotations![0], ...placement }] }, + options, + ).annotations[0]; + expect(committed.label!.lines).toEqual(preview.label!.lines); + expect(committed.subtitle!.lines).toEqual(preview.subtitle!.lines); + for (const key of ['x', 'y', 'width', 'height'] as const) + expect(committed.bounds![key]).toBeCloseTo(preview.bounds![key], 8); +}); diff --git a/packages/engine/src/annotations/__tests__/interpolate.test.ts b/packages/engine/src/annotations/__tests__/interpolate.test.ts new file mode 100644 index 00000000..6d5d2147 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/interpolate.test.ts @@ -0,0 +1,69 @@ +import type { ResolvedAnnotation } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { refreshConnector } from '../geometry'; +import { interpolateAnnotationGeometry } from '../interpolate'; + +const next: ResolvedAnnotation = { + type: 'text', + id: 'a', + bounds: { x: 100, y: 40, width: 80, height: 40 }, + label: { + text: 'Heading', + x: 100, + y: 53, + visible: true, + style: { fontSize: 13 }, + bounds: { x: 100, y: 40, width: 80, height: 17 }, + connector: { + from: { x: 100, y: 60 }, + to: { x: 300, y: 200 }, + endpoint: { x: 305, y: 205 }, + stroke: 'black', + style: 'curve', + arrow: true, + bend: 0.5, + }, + }, + subtitle: { text: 'Subtitle', x: 100, y: 73, style: { fontSize: 11 } }, + dot: { x: 305, y: 205, radius: 4, strokeWidth: 1, stroke: 'black', fill: 'white' }, +}; +next.label!.connector = refreshConnector(next.label!.connector!, next.bounds!, 4.5); +const previous = structuredClone(next); +previous.label!.x = 40; +previous.label!.y = 83; +previous.label!.connector!.endpoint = { x: 405, y: 105 }; +previous.dot!.x = 405; +previous.dot!.y = 105; + +describe('independent annotation animation geometry', () => { + it('interpolates label and data anchor independently, rebuilding control points', () => { + const sample = interpolateAnnotationGeometry(previous, next, 0.5); + expect(sample.label!.x).toBe(70); + expect(sample.label!.y).toBe(68); + expect(sample.dot).toMatchObject({ x: 355, y: 155 }); + expect(sample.label!.connector!.endpoint).toEqual({ x: 355, y: 155 }); + expect(sample.label!.connector).toEqual( + refreshConnector(sample.label!.connector!, sample.bounds!, 4.5, true), + ); + }); + it('ends at exact engine geometry without mutating either layout', () => { + const before = structuredClone(previous); + const after = structuredClone(next); + expect(interpolateAnnotationGeometry(previous, next, 1)).toEqual(next); + interpolateAnnotationGeometry(previous, next, 0.4); + expect(previous).toEqual(before); + expect(next).toEqual(after); + }); + it('suppresses a leader throughout when final geometry has none', () => { + const hidden = structuredClone(next); + delete hidden.label!.connector; + for (const t of [0, 0.25, 0.5, 0.75, 1]) + expect(interpolateAnnotationGeometry(previous, hidden, t).label!.connector).toBeUndefined(); + }); + it('does not flicker when a final visible leader becomes short mid-transition', () => { + const crossing = structuredClone(previous); + crossing.label!.connector!.endpoint = { x: 0, y: 0 }; + for (const t of [0, 0.25, 0.5, 0.75, 1]) + expect(interpolateAnnotationGeometry(crossing, next, t).label!.connector).toBeDefined(); + }); +}); diff --git a/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts b/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts new file mode 100644 index 00000000..5d02d980 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/layout-opt-ins.test.ts @@ -0,0 +1,259 @@ +import type { ChartSpec, TextAnnotation } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../../compile'; +import { validateSpec } from '../../compiler/validate'; +import { heuristicMeasure, measureRichLine } from '../geometry'; +import { wrapAnnotationText } from '../wrap-text'; + +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' } }, + animation: false, +}; +const annotation: TextAnnotation = { + type: 'text', + x: 5, + y: 5, + text: 'A **strong long phrase** and an extraordinarilylongword', + subtitle: 'A supporting line that also wraps', + responsive: false, +}; + +describe('annotation text layout opt-ins', () => { + it('wraps rich runs and long words while retaining explicit empty lines', () => { + const lines = wrapAnnotationText( + 'First\n\n**extraordinarilylongword** next', + 65, + { fontSize: 13, fontWeight: 400 }, + heuristicMeasure, + ); + expect(lines[1]).toBe(''); + expect(lines.length).toBeGreaterThan(4); + for (const line of lines) + expect( + measureRichLine(line, { fontSize: 13, fontWeight: 400 }, heuristicMeasure), + ).toBeLessThanOrEqual(65); + expect(lines.slice(2, -1).every((line) => line.startsWith('**') && line.endsWith('**'))).toBe( + true, + ); + }); + it.each([ + undefined, + 'drop-line', + ] as const)('preserves text identity and measures the wrapped block (%s)', (connector) => { + const layout = compileChart( + { ...base, annotations: [{ ...annotation, connector, maxWidth: 85, subtitleFontSize: 15 }] }, + { width: 600, height: 500 }, + ); + const a = layout.annotations[0]; + expect(a.label!.text).toBe(annotation.text); + expect(a.subtitle!.text).toBe(annotation.subtitle); + expect(a.label!.lines!.length).toBeGreaterThan(1); + expect(a.subtitle!.lines!.length).toBeGreaterThan(1); + expect(a.label!.bounds!.width).toBeLessThanOrEqual(85); + expect(a.subtitle!.style.fontSize).toBe(15); + expect(a.bounds!.width).toBeLessThanOrEqual(85); + }); + it('null and omission reproduce identical layout', () => { + 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)); + }); +}); + +describe('compact annotation limit', () => { + const annotations: TextAnnotation[] = [ + { type: 'text', id: 'late', x: 2, y: 2, text: 'Second', priority: 2 }, + { type: 'text', id: 'first', x: 8, y: 8, text: 'First', priority: 1 }, + { type: 'text', id: 'pinned', x: 5, y: 5, text: 'Pinned', responsive: false }, + ]; + it('zero demotes every eligible annotation, retaining pinned ones', () => { + const result = compileChart( + { ...base, annotations, responsive: { compactAnnotationLimit: 0 } }, + { width: 390, height: 700 }, + ); + expect(result.chrome.footnotes?.length).toBe(2); + expect(result.annotations.find((a) => a.id === 'pinned')?.footnoteIndex).toBeUndefined(); + }); + it('zero also demotes a single annotation', () => { + const result = compileChart( + { ...base, annotations: [annotations[0]], responsive: { compactAnnotationLimit: 0 } }, + { width: 390, height: 700 }, + ); + expect(result.chrome.footnotes?.length).toBe(1); + }); + it('uses outer width, preserving unlimited behavior at 400 and above', () => { + const compile = (width: number, limit?: number | null) => + compileChart( + { ...base, annotations, responsive: { compactAnnotationLimit: limit } }, + { width, height: 700 }, + ); + expect(compile(400, 0).annotations).toEqual(compile(400).annotations); + expect(compile(390, null).annotations).toEqual(compile(390).annotations); + }); + it('does not override D15 hide-all', () => { + const result = compileChart( + { ...base, annotations, responsive: { autoThin: false, compactAnnotationLimit: 0 } }, + { width: 390, height: 700 }, + ); + expect(result.annotations.map((a) => a.id)).toEqual(['pinned']); + expect(result.chrome.footnotes).toBeUndefined(); + }); +}); + +it('resolves authored annotation tokens and keeps explicit ink authoritative', () => { + const result = compileChart( + { + ...base, + theme: { + colors: { + annotationLabel: '#112233', + annotationMuted: '#223344', + annotationLeader: '#334455', + annotationRule: '#445566', + annotationRangeFill: '#556677', + annotationMarkerFill: '#667788', + annotationPlate: '#778899', + }, + }, + annotations: [ + { + ...annotation, + text: 'Callout', + anchor: 'left', + offset: { dx: -60, dy: -100 }, + connector: true, + background: true, + }, + { type: 'refline', y: 3, label: 'Rule' }, + { type: 'range', x1: 1, x2: 2, label: 'Range' }, + ], + }, + { width: 800, height: 700 }, + ); + const a = result.annotations[0]; + expect(a.label!.style.fill).toBe('#112233'); + expect(a.subtitle!.style.fill).toBe('#223344'); + expect(a.label!.connector!.stroke).toBe('#334455'); + expect(a.dot!.fill).toBe('#667788'); + expect(a.label!.background).toBe('#778899'); + expect(result.annotations[1].stroke).toBe('#445566'); + expect(result.annotations[2].fill).toBe('#556677'); + const explicit = compileChart( + { + ...base, + theme: { colors: { annotationLabel: '#112233' } }, + annotations: [ + { + ...annotation, + text: 'Arrow', + anchor: 'left', + offset: { dx: -60, dy: -100 }, + fill: '#aabbcc', + connector: 'curve', + }, + ], + }, + { width: 800, height: 700 }, + ); + expect(explicit.annotations[0].label!.connector!.stroke).toBe('#aabbcc'); +}); + +it.each([ + 'subtract', + 'grow', +] as const)('does not reserve a single-note band under 200px (%s)', (chromeLayout) => { + const spec = { ...base, chromeLayout, annotations: [{ ...annotation, responsive: undefined }] }; + const opts = { width: 390, height: 199 }; + const omitted = compileChart(spec, opts); + const limited = compileChart({ ...spec, responsive: { compactAnnotationLimit: 0 } }, opts); + expect(limited.area).toEqual(omitted.area); + expect(limited.chrome).toEqual(omitted.chrome); +}); + +it('keeps priority ties in source order before coverage and collision', () => { + const annotations: TextAnnotation[] = [ + { type: 'text', id: 'late', x: 2, y: 2, text: 'Later', priority: 2 }, + { type: 'text', id: 'first', x: 5, y: 5, text: 'First', priority: 1 }, + { type: 'text', id: 'tied', x: 8, y: 8, text: 'Tie', priority: 1 }, + ]; + const result = compileChart( + { ...base, annotations, responsive: { compactAnnotationLimit: 1 } }, + { width: 390, height: 800 }, + ); + expect(result.annotations.filter((a) => !a.footnoteIndex).map((a) => a.id)).toEqual(['first']); +}); +it('uses figure width for the limit when facet panels are compact', () => { + const spec: ChartSpec = { + ...base, + data: ['A', 'B'].flatMap((group) => base.data.map((row) => ({ ...row, group }))), + encoding: { ...base.encoding, facet: { field: 'group', type: 'nominal', columns: 2 } }, + annotations: [{ ...annotation, text: 'Short', subtitle: undefined, responsive: undefined }], + }; + const opts = { width: 640, height: 600 }; + expect(compileChart({ ...spec, responsive: { compactAnnotationLimit: 0 } }, opts).facet).toEqual( + compileChart(spec, opts).facet, + ); +}); + +it.each([ + -1, + 0, + Infinity, + NaN, + '90', +])('rejects invalid finite text widths and subtitle sizes: %s', (value) => { + for (const field of ['maxWidth', 'subtitleFontSize']) { + const result = validateSpec({ ...base, annotations: [{ ...annotation, [field]: value }] }); + expect(result.valid).toBe(false); + expect(result.errors.some((e) => e.path.endsWith(field))).toBe(true); + } +}); +it.each([ + -1, + 1.5, + Infinity, + NaN, + '1', +])('rejects invalid compact limits: %s', (compactAnnotationLimit) => { + const result = validateSpec({ ...base, responsive: { compactAnnotationLimit } }); + expect(result.valid).toBe(false); +}); + +it.each([ + false, + true, +])('preserves compiled 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' }, + ]; + const options = { width: 800, height: 700, darkMode }; + const legacyInk: ChartSpec['annotations'] = [ + { ...annotations[0], stroke: '#666666' }, + { ...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, + ); + const theme = { + colors: { + annotationRule: { light: '#445566', dark: '#aabbcc' }, + annotationRangeFill: { light: '#556677', dark: '#bbccdd' }, + }, + }; + const themed = compileChart({ ...base, annotations, theme }, options); + expect(themed.annotations[0].stroke).toBe(darkMode ? '#aabbcc' : '#445566'); + expect(themed.annotations[1].fill).toBe(darkMode ? '#bbccdd' : '#556677'); + const explicit = compileChart({ ...base, annotations: legacyInk, theme }, options); + expect(explicit.annotations[0].stroke).toBe('#666666'); + expect(explicit.annotations[1].fill).toBe('#000000'); +}); diff --git a/packages/engine/src/annotations/__tests__/notes.test.ts b/packages/engine/src/annotations/__tests__/notes.test.ts new file mode 100644 index 00000000..e0cf5bc8 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/notes.test.ts @@ -0,0 +1,33 @@ +import type { MeasureTextFn, TextAnnotation } from '@opendata-ai/openchart-core'; +import { resolveTheme } from '@opendata-ai/openchart-core'; +import { expect, it } from 'vitest'; +import { annotationNoteCapacity, annotationNoteLineCount, wrapAnnotationNotes } from '../notes'; + +it('bounds note wrapping across numbering changes with proportional digits', () => { + const theme = resolveTheme(); + const measureText: MeasureTextFn = (text) => ({ + width: [...text].reduce( + (width, character) => width + (character === '8' ? 15 : character === '1' ? 1 : 5), + 0, + ), + }); + const annotations: TextAnnotation[] = Array.from({ length: 12 }, (_, index) => ({ + type: 'text', + x: index, + y: index, + text: `Callout ${index}`, + subtitle: 'Some explanatory context that may wrap', + })); + for (const width of [100, 150, 390]) { + const capacity = annotationNoteCapacity(annotations, width, theme, measureText); + for (let rotation = 0; rotation < annotations.length; rotation++) { + const notes = annotations.map((annotation, index) => ({ + index: ((index + rotation) % annotations.length) + 1, + text: `${annotation.text}. ${annotation.subtitle}`, + })); + expect( + annotationNoteLineCount(wrapAnnotationNotes(notes, width, theme, measureText)), + ).toBeLessThanOrEqual(capacity); + } + } +}); diff --git a/packages/engine/src/annotations/__tests__/preview.bench.ts b/packages/engine/src/annotations/__tests__/preview.bench.ts new file mode 100644 index 00000000..fb94bb8e --- /dev/null +++ b/packages/engine/src/annotations/__tests__/preview.bench.ts @@ -0,0 +1,24 @@ +import { expect, test } from 'vitest'; +import { annotationCrowdedSpec } from '../../../../../examples/src/gallery/features-annotations.demos'; +import { compileChart } from '../../compile'; +import { createAnnotationEditContext, previewAnnotationMove } from '../edit-context'; + +const spec = annotationCrowdedSpec; +const layout = compileChart(spec, { width: 1280, height: 800 }); +const ctx = createAnnotationEditContext(layout, spec); +// Vitest 5 exposes benchmarks through the test context. +test('1,000 compiled annotation previews stay within one frame', async ({ bench }) => { + let i = 0; + const result = await bench('previewAnnotationMove', () => { + previewAnnotationMove( + ctx, + { type: 'annotation', index: 12, id: 'crowded-12' }, + { dx: Math.sin(i++) * 50, dy: Math.cos(i) * 30 }, + ); + }).run({ iterations: 1000, time: 0, warmup: false, retainSamples: true }); + const p95 = result.latency.samples![949]; + console.log( + JSON.stringify({ moves: result.latency.samplesCount, p50ms: result.latency.p50, p95ms: p95 }), + ); + expect(p95).toBeLessThan(16); +}); diff --git a/packages/engine/src/annotations/__tests__/reference-placement.test.ts b/packages/engine/src/annotations/__tests__/reference-placement.test.ts new file mode 100644 index 00000000..c3d606b9 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/reference-placement.test.ts @@ -0,0 +1,312 @@ +import type { AnnotationAnchor, ChartSpec, RefLineAnnotation } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../../compile'; + +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' } }, +}; +const anchors: AnnotationAnchor[] = ['top', 'bottom', 'left', 'right', 'auto']; +const compile = (annotations: RefLineAnnotation[], width = 640) => + compileChart({ ...base, annotations }, { width, height: 600, onWarn: () => {} }); +describe('authored reference label placement', () => { + it.each(anchors)('separates horizontal end from %s side', (labelAnchor) => { + for (const placement of ['start', 'end'] as const) { + const layout = compile([ + { type: 'refline', y: 5, label: 'Rule', labelAnchor, labelPlacement: placement }, + ]); + const a = layout.annotations[0]; + expect(a.label!.x).toBeCloseTo( + placement === 'start' ? layout.area.x + 4 : layout.area.x + layout.area.width - 4, + ); + expect(a.label!.y - a.line!.start.y).toBe(labelAnchor === 'bottom' ? 14 : -4); + expect(a.label!.style.textAnchor).toBe(placement === 'start' ? 'start' : 'end'); + } + }); + it.each(anchors)('separates vertical end from %s side', (labelAnchor) => { + for (const placement of ['start', 'end'] as const) { + const layout = compile([ + { type: 'refline', x: 5, label: 'Rule', labelAnchor, labelPlacement: placement }, + ]); + const a = layout.annotations[0]; + expect(a.label!.y).toBeCloseTo( + placement === 'start' ? layout.area.y + 14 : layout.area.y + layout.area.height - 4, + ); + expect(a.label!.x - a.line!.start.x).toBe(labelAnchor === 'right' ? -4 : 4); + } + }); + it('reserves and wraps outside labels with deterministic lanes', () => { + const plain = compile([]); + const annotations: RefLineAnnotation[] = [ + { + type: 'refline', + id: 'a', + y: 5, + label: 'A very long outside horizontal label that needs wrapping', + labelPlacement: 'outside', + }, + { + type: 'refline', + id: 'b', + y: 5.1, + label: 'A second label at almost the same value', + labelPlacement: 'outside', + }, + { + type: 'refline', + id: 'c', + x: 5, + label: 'An event above the plot', + labelPlacement: 'outside', + }, + ]; + const layout = compile(annotations); + expect(layout.area.width).toBeLessThan(plain.area.width); + expect(layout.area.y).toBeGreaterThan(plain.area.y); + const [a, b, c] = layout.annotations; + expect(a.bounds!.x).toBeGreaterThan(layout.area.x + layout.area.width); + expect(a.label!.lines!.length).toBeGreaterThan(1); + expect(b.bounds!.y).toBeGreaterThanOrEqual(a.bounds!.y + a.bounds!.height); + expect(c.bounds!.y + c.bounds!.height).toBeLessThan(layout.area.y); + for (const item of [a, b, c]) { + expect(item.bounds!.x).toBeGreaterThanOrEqual(0); + expect(item.bounds!.x + item.bounds!.width).toBeLessThanOrEqual(640); + expect(item.label!.connector).toBeDefined(); + } + }); + it('keeps authored offsets pinned and reports overflow', () => { + const warnings: string[] = []; + const result = compileChart( + { + ...base, + annotations: [ + { + type: 'refline', + y: 5, + label: 'Pinned', + labelPlacement: 'auto', + labelOffset: { dx: 2000 }, + }, + ], + }, + { width: 640, height: 600, onWarn: (w) => warnings.push(w) }, + ); + expect(result.annotations[0].label!.x).toBeGreaterThan(640); + expect(warnings.some((w) => w.includes('labelOffset') && w.includes('overflow'))).toBe(true); + }); +}); + +it('keeps crowded outside labels inside the figure even near the bottom rule', () => { + const result = compile( + [0.1, 0.2, 0.3, 0.4].map((y) => ({ + type: 'refline', + y, + label: 'A wrapped reference description near the lower edge', + labelPlacement: 'outside', + })), + 390, + ); + for (const a of result.annotations) + expect(a.bounds!.y + a.bounds!.height).toBeLessThanOrEqual(592); + for (let i = 1; i < result.annotations.length; i++) + expect(result.annotations[i].bounds!.y).toBeGreaterThanOrEqual( + result.annotations[i - 1].bounds!.y + result.annotations[i - 1].bounds!.height, + ); +}); + +it('auto avoids both occupied ends by sliding while retaining the authored side', async () => { + const { placeReferenceLabel } = await import('../reference-layout'); + const { heuristicMeasure } = await import('../geometry'); + const result = compile([ + { type: 'refline', y: 5, label: 'Rule', labelAnchor: 'bottom', labelPlacement: 'end' }, + ]); + const a = result.annotations[0]; + const originalY = a.label!.y; + placeReferenceLabel( + { type: 'refline', y: 5, labelPlacement: 'auto' }, + a, + result.area, + { x: 0, y: 0, width: 640, height: 600 }, + { top: 0, right: 0, labelWidth: 160 }, + {}, + heuristicMeasure, + result.theme.colors, + undefined, + [ + { kind: 'legend', x: 0, y: originalY - 20, width: 160, height: 40 }, + { kind: 'endpoint-label', x: 450, y: originalY - 20, width: 190, height: 40 }, + ], + ); + expect(a.label!.y).toBe(originalY); + expect(a.bounds!.x).toBeGreaterThanOrEqual(160); + expect(a.bounds!.x + a.bounds!.width).toBeLessThanOrEqual(450); + expect(a.label!.connector).toBeDefined(); +}); + +it('keeps outside references beyond dual-axis endpoints and the right legend', async () => { + const { compileLayer } = await import('../../compile'); + const leaf: ChartSpec = { + ...base, + mark: 'line', + data: ['North', 'South'].flatMap((s, i) => [ + { x: 0, y: i + 1, s }, + { x: 10, y: i + 5, s }, + ]), + encoding: { + ...base.encoding, + x: { field: 'x', type: 'ordinal' }, + color: { field: 's', type: 'nominal' }, + }, + }; + const result = compileLayer( + { + layer: [leaf, leaf], + resolve: { scale: { y: 'independent' } }, + legend: { position: 'right' }, + endpointLabels: { content: 'label' }, + annotations: [ + { type: 'refline', y: 6.5, label: 'Outside reference', labelPlacement: 'outside' }, + ], + }, + { width: 1000, height: 500 }, + ); + const box = result.annotations[0].bounds!; + expect(box.x).toBeGreaterThanOrEqual( + result.endpointLabels!.bounds.x + result.endpointLabels!.bounds.width, + ); + expect(box.x).toBeGreaterThanOrEqual(result.legend.bounds.x + result.legend.bounds.width); +}); + +it.each([ + 320, 390, 768, 1280, +])('keeps facet reference labels and leaders in their own cell at %s', (width) => { + for (const mode of ['shared', 'independent'] as const) { + const result = compileChart( + { + ...base, + chromeLayout: 'grow', + chrome: { title: 'Facet title', subtitle: 'Supporting subtitle' }, + data: ['A', 'B', 'C', 'D'].flatMap((group) => base.data.map((row) => ({ ...row, group }))), + encoding: { ...base.encoding, facet: { field: 'group', type: 'nominal', columns: 2 } }, + resolve: { scale: { y: mode } }, + annotations: [ + { type: 'refline', y: 6.5, label: 'Outside reference', labelPlacement: 'outside' }, + { type: 'refline', x: 5, label: 'Event', labelPlacement: 'outside' }, + ], + }, + { width, height: 400 }, + ); + expect(result.chrome.grownHeight).toBeCloseTo(result.dimensions.height - 400); + for (const panel of result.facet!.panels) { + for (const tick of panel.axes.x?.ticks ?? []) { + expect(tick.labelPosition ?? tick.position).toBeGreaterThanOrEqual(panel.area.x - 1); + expect(tick.labelPosition ?? tick.position).toBeLessThanOrEqual( + panel.area.x + panel.area.width, + ); + } + const [horizontal, vertical] = panel.annotations; + expect(horizontal.bounds!.x).toBeGreaterThan(panel.area.x + panel.area.width); + expect(vertical.bounds!.y + vertical.bounds!.height).toBeLessThan(panel.area.y); + expect(vertical.bounds!.y).toBeGreaterThan(panel.header.y); + for (const annotation of panel.annotations) { + const b = annotation.bounds!; + expect(b.x).toBeGreaterThanOrEqual(panel.area.x); + expect(b.y + b.height).toBeLessThanOrEqual(panel.area.y + panel.area.height); + for (const other of result.facet!.panels.filter((p) => p !== panel)) { + if (other.area.x > panel.area.x && other.area.y === panel.area.y) { + expect(b.x + b.width).toBeLessThan(other.area.x); + // Both endpoints stay on the owning side of the next panel. + expect(annotation.label!.connector!.from.x).toBeLessThan(other.area.x); + expect(annotation.label!.connector!.to.x).toBeLessThan(other.area.x); + } + if (other.area.y < panel.area.y) { + expect(b.y).toBeGreaterThan(other.area.y + other.area.height); + expect(annotation.label!.connector!.from.y).toBeGreaterThan( + other.area.y + other.area.height, + ); + expect(annotation.label!.connector!.to.y).toBeGreaterThan( + other.area.y + other.area.height, + ); + } + } + } + } + } +}); + +it('keeps the outside gutter above notes, footer and watermark', () => { + const result = compileChart( + { + ...base, + chrome: { title: 'References', source: 'Source', footer: 'Footer' }, + annotations: [0.1, 0.2, 0.3, 0.4].map((y) => ({ + type: 'refline', + y, + label: 'A wrapped reference description near the lower edge', + labelPlacement: 'outside', + })), + }, + { width: 390, height: 600 }, + ); + for (const a of result.annotations) { + expect(a.bounds!.y).toBeGreaterThanOrEqual(result.area.y); + expect(a.bounds!.y + a.bounds!.height).toBeLessThanOrEqual( + result.area.y + result.area.height + 0.001, + ); + } +}); + +it.each([ + 390, 1280, +])('keeps crowded reference leaders clear of neighboring labels at %s', async (width) => { + const { interpolateAnnotationGeometry } = await import('../interpolate'); + for (const reverse of [false, true]) + for (const y of [0.1, 5]) { + const annotations: RefLineAnnotation[] = [ + { + type: 'refline', + id: 'long', + y, + label: 'A longer threshold label, wrapped in the reserved right gutter', + labelPlacement: 'outside', + }, + { + type: 'refline', + id: 'short', + y: y + 0.2, + label: 'A nearby threshold', + labelPlacement: 'outside', + }, + ]; + if (reverse) annotations.reverse(); + const before = compile(annotations, width); + const after = compile( + annotations.map((a) => ({ ...a, y: Number(a.y) + 0.5 })), + width, + ); + for (const t of [0, 0.5, 1]) { + const items = after.annotations.map((a, i) => + interpolateAnnotationGeometry(before.annotations[i], a, t), + ); + for (const a of items) { + const connector = a.label!.connector!; + expect(connector).toBeDefined(); + for (const b of items) { + // A straight leader's convex hull must remain in the gutter corridor, + // including when a neighboring label is taller or a bottom sweep moves it. + expect(Math.max(connector.from.x, connector.to.x)).toBeLessThan(b.bounds!.x - 2); + } + expect( + Math.hypot( + connector.to.x - connector.endpoint!.x, + connector.to.y - connector.endpoint!.y, + ), + ).toBeCloseTo(4); + } + } + } +}); diff --git a/packages/engine/src/annotations/collisions.ts b/packages/engine/src/annotations/collisions.ts index 6aa23b3e..801492a2 100644 --- a/packages/engine/src/annotations/collisions.ts +++ b/packages/engine/src/annotations/collisions.ts @@ -54,29 +54,11 @@ export function generateNudgeCandidates( return candidates; } -/** - * Did the author hand-place this block? - * - * A hand-placed block goes exactly where it was put, and the automatic passes keep - * their hands off it. Without that, the obstacle pass overrides the author: on a line - * chart the polyline is one long obstacle, so `dy: -10` moved a block by ZERO pixels - * while `dy: -15` teleported it 56px. Same knob, no monotonicity -- unaimable. - * - * `isAutoPlacement` is the ONLY definition of "did the author place this". It already - * decides which resolve path an annotation takes, and a non-zero `offset` disqualifies - * the scored search there, so an offset ALWAYS means hand-placed -- with or without an - * `anchor`. A second, subtly different rule here (an earlier version of this function - * additionally demanded an `anchor`) does not create a second tier of annotation; it - * just disagrees with the function that actually routed the thing, and the disagreement - * shows up as an un-aimable offset on exactly the annotations the extra condition - * excluded. - * - * `offset: { dx: 0 }` is not an instruction, just the default spelled out, so it does - * not count -- matching `applyOffset`, which is likewise a no-op for it. +/** A present offset pins placement, even when both components are zero. + * Anchor-only annotations and drop-lines without offsets keep the existing nudge behavior. */ function hasExplicitOffset(annotation: TextAnnotation): boolean { - const moved = (annotation.offset?.dx ?? 0) !== 0 || (annotation.offset?.dy ?? 0) !== 0; - return moved && !isAutoPlacement(annotation); + return annotation.offset !== undefined && !isAutoPlacement(annotation); } /** diff --git a/packages/engine/src/annotations/compute.ts b/packages/engine/src/annotations/compute.ts index 71d55749..658e7882 100644 --- a/packages/engine/src/annotations/compute.ts +++ b/packages/engine/src/annotations/compute.ts @@ -1,3 +1,10 @@ +import { rememberAnnotationGeometry } from './edit-context'; +import { + placeReferenceLabel, + type ReferenceLanes, + type ReferenceReserve, + referenceLabelReserve, +} from './reference-layout'; /** * Annotation computation: converts spec-level annotations to pixel-positioned * ResolvedAnnotation objects using the resolved scales. @@ -41,6 +48,9 @@ import { export interface AnnotationContext { scales: ResolvedScales; chartArea: Rect; + referenceContainer?: Rect; + referenceLanes?: ReferenceLanes; + referenceReserve?: ReferenceReserve; strategy: LayoutStrategy; isDark: boolean; obstacles: PlacementObstacle[]; @@ -48,7 +58,9 @@ export interface AnnotationContext { measure: AnnotationMeasureTextFn; /** Theme font stack, so annotation text is measured and rendered in the same face. */ fontFamily?: string; + colors?: import('@opendata-ai/openchart-core').ThemeColors; debugPlacement?: boolean; + onWarn?: (message: string) => void; /** When true, annotations that overlap are demoted to footnote markers instead of being hidden. */ autoThin?: boolean; /** @@ -80,6 +92,9 @@ export function computeAnnotations( const svgRect: Rect = { x: 0, y: 0, width: ctx.svg.width, height: ctx.svg.height }; const annotations: ResolvedAnnotation[] = []; + const referenceReserve = + ctx.referenceReserve ?? referenceLabelReserve(spec.annotations, ctx.svg.width, ctx.measure); + const referenceLanes: ReferenceLanes = ctx.referenceLanes ?? {}; // Deferred auto-placement entries (resolved in Pass 2) const autoQueue: Array<{ @@ -106,6 +121,7 @@ export function computeAnnotations( ctx.isDark, ctx.measure, ctx.fontFamily, + ctx.colors, ); if (resolved && ctx.svg.width > 0 && ctx.svg.height > 0 && isAutoPlacement(annotation)) { const px = resolvePosition(annotation.x, ctx.scales.x); @@ -132,6 +148,7 @@ export function computeAnnotations( ctx.chartArea, ctx.isDark, ctx.fontFamily, + ctx.colors, ); break; case 'refline': @@ -142,6 +159,7 @@ export function computeAnnotations( ctx.isDark, ctx.measure, ctx.fontFamily, + ctx.colors, ); break; } @@ -157,7 +175,30 @@ export function computeAnnotations( ctx.measure, ); } - if (annotation.type === 'refline' && ctx.obstacles.length > 0) { + if (annotation.type === 'refline' && annotation.labelPlacement) + placeReferenceLabel( + annotation, + resolved, + ctx.chartArea, + ctx.referenceContainer ?? { + x: 8, + y: ctx.topBound ?? 8, + width: ctx.svg.width - 16, + height: ctx.svg.height - (ctx.topBound ?? 8) - 8, + }, + referenceReserve, + referenceLanes, + ctx.measure, + ctx.colors, + ctx.onWarn, + [ + ...ctx.obstacles, + ...annotations.flatMap((a) => + a.bounds ? [{ ...a.bounds, kind: 'annotation' as const }] : [], + ), + ], + ); + if (annotation.type === 'refline' && !annotation.labelPlacement && ctx.obstacles.length > 0) { nudgeRefLineLabel(resolved, annotation, ctx.obstacles, ctx.chartArea, ctx.measure); } // The resolved array is filtered (an out-of-domain annotation resolves to @@ -194,7 +235,7 @@ export function computeAnnotations( const subtitleStyle = annotation.subtitle ? { - fontSize: subtitleFontSize(labelStyle.fontSize), + fontSize: annotation.subtitleFontSize ?? subtitleFontSize(labelStyle.fontSize), fontWeight: SUBTITLE_FONT_WEIGHT, lineHeight: labelStyle.lineHeight, fontFamily: labelStyle.fontFamily, @@ -204,14 +245,14 @@ export function computeAnnotations( const result = findBestPlacement( anchorX, anchorY, - annotation.text, + resolved.label?.lines?.join('\n') ?? annotation.text, { fontSize: labelStyle.fontSize, fontWeight: Number(labelStyle.fontWeight) || 400, lineHeight: labelStyle.lineHeight, fontFamily: labelStyle.fontFamily, }, - annotation.subtitle, + resolved.subtitle?.lines?.join('\n') ?? annotation.subtitle, subtitleStyle, workingObstacles, ctx.chartArea, @@ -281,5 +322,9 @@ export function computeAnnotations( // Sort by zIndex (lower first, undefined treated as 0) annotations.sort((a, b) => (a.zIndex ?? 0) - (b.zIndex ?? 0)); + for (const resolved of annotations) { + const authored = spec.annotations[resolved.specIndex!]; + if (authored?.type === 'text') rememberAnnotationGeometry(resolved, authored, ctx); + } return annotations; } diff --git a/packages/engine/src/annotations/edit-context.ts b/packages/engine/src/annotations/edit-context.ts new file mode 100644 index 00000000..158a495d --- /dev/null +++ b/packages/engine/src/annotations/edit-context.ts @@ -0,0 +1,212 @@ +/** Frozen, engine-owned geometry for a single annotation editing gesture. */ +import type { + ChartLayout, + ElementRef, + Rect, + ResolvedAnnotation, + TextAnnotation, +} from '@opendata-ai/openchart-core'; +import { annotationSource } from '@opendata-ai/openchart-core'; +import { clampAnnotationsToBounds } from './collisions'; +import type { AnnotationContext } from './compute'; +import { refreshConnector } from './geometry'; +import { moveAnnotationBy } from './move'; +import { markerClearance, resolveTextAnnotation, textConnectorSeed } from './resolve-text'; + +// Keep measurement functions and scales out of serializable public layout objects. +// Each compiled annotation owns the exact inputs which produced its geometry. +const geometry = new WeakMap< + ResolvedAnnotation, + { context: AnnotationContext; authored: TextAnnotation } +>(); +export function rememberAnnotationGeometry( + annotation: ResolvedAnnotation, + authored: TextAnnotation, + context: AnnotationContext, +): void { + geometry.set(annotation, { context, authored }); +} + +interface Entry { + ref: Extract; + resolved: ResolvedAnnotation; + base: ResolvedAnnotation; + connectorSeed?: NonNullable['connector']; + anchor: 'left' | 'right'; + context: AnnotationContext; +} +/** Opaque gesture snapshot. Create a new context after a committed edit or resize. */ +export interface AnnotationEditContext { + readonly kind: 'annotation-edit-context'; +} + +const snapshots = new WeakMap(); + +/** Snapshot a compiled layout without asking callers to reconstruct scales or obstacles. */ +export function createAnnotationEditContext( + layout: ChartLayout, + spec: unknown, +): AnnotationEditContext { + const source = annotationSource(spec); + const entries: Entry[] = []; + const panels = layout.facet + ? layout.facet.panels.map((p) => ({ annotations: p.annotations, facet: p.key })) + : [{ annotations: layout.annotations, facet: undefined }]; + for (const panel of panels) { + for (const resolved of panel.annotations) { + if (resolved.type !== 'text' || !resolved.label || !resolved.bounds) continue; + const metadata = geometry.get(resolved); + if (!metadata || !source) continue; + const index = resolved.specIndex; + if (index === undefined) continue; + const authored = source.annotations[index] as TextAnnotation | undefined; + if (!authored || authored.type !== 'text' || authored.id !== resolved.id) continue; + const context = metadata.context; + const anchor = resolved.label.style.textAnchor === 'end' ? 'left' : 'right'; + const base = resolveTextAnnotation( + { ...metadata.authored, anchor, offset: undefined }, + context.scales, + context.chartArea, + context.isDark, + context.measure, + context.fontFamily, + context.colors, + ); + if (!base) continue; + entries.push({ + ref: { + type: 'annotation', + index, + id: authored.id, + path: `${source.path}/${index}`, + ...(panel.facet !== undefined ? { facetInstance: panel.facet } : {}), + }, + resolved: structuredClone(resolved), + base, + anchor, + connectorSeed: textConnectorSeed( + metadata.authored, + context.scales, + context.isDark, + context.colors, + ), + context: { + ...context, + chartArea: { ...context.chartArea }, + svg: { ...context.svg }, + obstacles: [], + }, + }); + } + } + const snapshot: AnnotationEditContext = Object.freeze({ kind: 'annotation-edit-context' }); + snapshots.set(snapshot, entries); + return snapshot; +} + +function entryFor(ctx: AnnotationEditContext, ref: ElementRef): Entry { + if (ref.type !== 'annotation') throw new Error('Expected an annotation reference'); + const matches = (snapshots.get(ctx) ?? []).filter( + (entry) => + entry.ref.index === ref.index && + (ref.id === undefined || entry.ref.id === ref.id) && + (ref.path === undefined || + ref.path === entry.ref.path || + ref.path.startsWith(`${entry.ref.path}/`)) && + (ref.facetInstance === undefined || ref.facetInstance === entry.ref.facetInstance), + ); + if (matches.length !== 1) + throw new Error('Annotation reference is missing or ambiguous in this edit context'); + return matches[0]; +} + +/** Convert the rendered block box to an explicit placement, preserving its alignment. */ +export function inverseLabelPlacement( + ctx: AnnotationEditContext, + ref: ElementRef, + renderedBox: Rect, +): Pick { + const entry = entryFor(ctx, ref); + const bounds = entry.base.bounds!; + return { + anchor: entry.anchor, + offset: { + dx: Math.round((renderedBox.x - bounds.x) * 1e10) / 1e10, + dy: Math.round((renderedBox.y - bounds.y) * 1e10) / 1e10, + }, + responsive: false, + }; +} + +/** Move only the label block; keep its data anchor and the surrounding layout frozen. */ +export function previewAnnotationMove( + ctx: AnnotationEditContext, + ref: ElementRef, + delta: { dx: number; dy: number }, +): ResolvedAnnotation { + const entry = entryFor(ctx, ref); + const annotation = structuredClone(entry.resolved); + const context = entry.context; + if (!annotation.label!.connector && entry.connectorSeed) + annotation.label!.connector = structuredClone(entry.connectorSeed); + moveAnnotationBy(annotation, annotation.label!, delta.dx, delta.dy, context.measure); + clampAnnotationsToBounds( + [annotation], + context.svg.width, + context.svg.height, + context.measure, + context.topBound, + ); + return annotation; +} + +/** Change a curve without moving its label, attachment, or marker. */ +export function previewConnectorBend( + ctx: AnnotationEditContext, + ref: ElementRef, + bend: number, +): ResolvedAnnotation { + if (!Number.isFinite(bend) || bend < -1 || bend > 1) + throw new RangeError('Connector bend must be between -1 and 1'); + const annotation = structuredClone(entryFor(ctx, ref).resolved); + const connector = annotation.label?.connector; + if (connector?.style === 'curve') + annotation.label!.connector = refreshConnector( + { ...connector, bend }, + annotation.bounds!, + markerClearance(annotation.dot), + ); + return annotation; +} + +/** Preview a connector endpoint nudge without parsing rendered path geometry. */ +export function previewConnectorOffset( + ctx: AnnotationEditContext, + ref: ElementRef, + end: 'from' | 'to', + delta: { dx: number; dy: number }, +): ResolvedAnnotation { + const entry = entryFor(ctx, ref); + const annotation = structuredClone(entry.resolved); + const connector = structuredClone(annotation.label?.connector ?? entry.connectorSeed); + if (!connector?.endpoint) return annotation; + if (end === 'from') + connector.fromOffset = { + dx: (connector.fromOffset?.dx ?? 0) + delta.dx, + dy: (connector.fromOffset?.dy ?? 0) + delta.dy, + }; + else { + connector.endpoint.x += delta.dx; + connector.endpoint.y += delta.dy; + if (annotation.dot) { + annotation.dot.x += delta.dx; + annotation.dot.y += delta.dy; + } + } + annotation.label!.connector = refreshConnector( + connector, + annotation.bounds!, + markerClearance(annotation.dot), + ); + return annotation; +} diff --git a/packages/engine/src/annotations/geometry.ts b/packages/engine/src/annotations/geometry.ts index 7d0a58bb..c9089a29 100644 --- a/packages/engine/src/annotations/geometry.ts +++ b/packages/engine/src/annotations/geometry.ts @@ -233,7 +233,7 @@ export function estimateLabelBounds( return computeTextBlockBounds( label.x, label.y, - label.text, + label.lines?.join('\n') ?? label.text, { fontSize, fontWeight, @@ -313,10 +313,27 @@ export function connectorPullbackGap(markerRadius: number, arrow: boolean): numb * inside the text block, or the resulting line is shorter than * `MIN_CONNECTOR_LENGTH`. */ +export function automaticControlPoint( + from: { x: number; y: number }, + to: { x: number; y: number }, + exit?: 'horizontal' | 'vertical', + bend = 0, +): { x: number; y: number } { + const dx = to.x - from.x; + const dy = to.y - from.y; + const vertical = exit ? exit === 'vertical' : Math.abs(dy) >= Math.abs(dx); + const x = from.x + dx * (vertical ? 0.2 : 0.6); + const y = from.y + dy * (vertical ? 0.6 : 0.2); + // len * normal = (dy, -dx), including a degenerate chord. Preserve exact + // floating-point output when bend is omitted or zero. + return bend === 0 ? { x, y } : { x: x + bend * dy, y: y - bend * dx }; +} + export function refreshConnector( connector: NonNullable, bounds: Rect, markerRadius: number, + keepVisible = false, ): ResolvedLabel['connector'] { // `endpoint` is the un-pulled-back data point, and the only valid basis for a // rebuild: pulling back from `to` (already pulled back) shrinks the line a @@ -340,13 +357,30 @@ export function refreshConnector( const gap = connectorPullbackGap(markerRadius, false); return { ...connector, - from: { x: endpoint.x, y: bounds.y - DROP_LINE_TOP_GAP }, + from: { + x: endpoint.x + (connector.fromOffset?.dx ?? 0), + y: bounds.y - DROP_LINE_TOP_GAP + (connector.fromOffset?.dy ?? 0), + }, to: { x: endpoint.x, y: endpoint.y - gap }, exit: 'vertical', }; } - const exit = connectorExit(bounds, endpoint.x, endpoint.y); + const exit = + (connector.labelAttachment === 'left-center' + ? { + x: bounds.x - BOX_INFLATE - CONNECTOR_STANDOFF, + y: bounds.y + bounds.height / 2, + exit: 'horizontal' as const, + } + : connectorExit(bounds, endpoint.x, endpoint.y)) ?? + (keepVisible + ? { + x: bounds.x + bounds.width / 2, + y: bounds.y + bounds.height / 2, + exit: connector.exit ?? ('horizontal' as const), + } + : null); if (!exit) return undefined; // Re-apply the author's from-end nudge BEFORE the pullback, so the pullback @@ -370,7 +404,16 @@ export function refreshConnector( // direction once its end is pulled back, and checking first would miss it. const lx = to.x - from.x; const ly = to.y - from.y; - if (!connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connector.arrow)) return undefined; + if (!keepVisible && !connectorIsDrawable(Math.sqrt(lx * lx + ly * ly), connector.arrow)) + return undefined; - return { ...connector, from, to, exit: exit.exit }; + return { + ...connector, + from, + to, + exit: exit.exit, + ...(connector.style === 'curve' + ? { control: automaticControlPoint(from, to, exit.exit, connector.bend) } + : {}), + }; } diff --git a/packages/engine/src/annotations/interpolate.ts b/packages/engine/src/annotations/interpolate.ts new file mode 100644 index 00000000..d7fb0494 --- /dev/null +++ b/packages/engine/src/annotations/interpolate.ts @@ -0,0 +1,59 @@ +import type { Point, ResolvedAnnotation } from '@opendata-ai/openchart-core'; +import { refreshConnector } from './geometry'; +import { markerClearance } from './resolve-text'; + +/** Sample label and attachment independently. Leader visibility is chosen by the final layout. */ +export function interpolateAnnotationGeometry( + before: ResolvedAnnotation, + after: ResolvedAnnotation, + t: number, +): ResolvedAnnotation { + if (t >= 1) return structuredClone(after); + const result = structuredClone(after); + const point = (a: Point, b: Point): Point => ({ + x: a.x + (b.x - a.x) * t, + y: a.y + (b.y - a.y) * t, + }); + if (before.label && result.label) { + const pos = point(before.label, result.label); + const dx = pos.x - result.label.x; + const dy = pos.y - result.label.y; + result.label.x = pos.x; + result.label.y = pos.y; + if (result.label.bounds) { + result.label.bounds.x += dx; + result.label.bounds.y += dy; + } + if (result.bounds) { + result.bounds.x += dx; + result.bounds.y += dy; + } + if (result.subtitle) { + result.subtitle.x += dx; + result.subtitle.y += dy; + } + } + if (before.dot && result.dot) Object.assign(result.dot, point(before.dot, result.dot)); + if (before.line && result.line) { + result.line.start = point(before.line.start, result.line.start); + result.line.end = point(before.line.end, result.line.end); + } + if (before.rect && result.rect) { + const rect = result.rect; + Object.assign(rect, point(before.rect, rect)); + rect.width = before.rect.width + (rect.width - before.rect.width) * t; + rect.height = before.rect.height + (rect.height - before.rect.height) * t; + } + const connector = result.label?.connector; + if (connector?.endpoint && result.bounds) { + const start = before.label?.connector?.endpoint ?? before.dot ?? connector.endpoint; + connector.endpoint = point(start, connector.endpoint); + result.label!.connector = refreshConnector( + connector, + result.bounds, + markerClearance(result.dot), + true, + ); + } + return result; +} diff --git a/packages/engine/src/annotations/move.ts b/packages/engine/src/annotations/move.ts index 427726b7..be1c8fb7 100644 --- a/packages/engine/src/annotations/move.ts +++ b/packages/engine/src/annotations/move.ts @@ -56,7 +56,7 @@ export function annotationBlockBounds( const subBounds = computeTextBlockBounds( sub.x, sub.y, - sub.text, + sub.lines?.join('\n') ?? sub.text, { // Every resolver stamps all three, so these fallbacks are unreachable today. // They still come from the constants: a hand-rolled `?? 10` here is a second diff --git a/packages/engine/src/annotations/notes.ts b/packages/engine/src/annotations/notes.ts index 977abed4..b3c9eaba 100644 --- a/packages/engine/src/annotations/notes.ts +++ b/packages/engine/src/annotations/notes.ts @@ -1,4 +1,9 @@ -import type { AnnotationFootnote, MeasureTextFn, ResolvedTheme } from '@opendata-ai/openchart-core'; +import type { + Annotation, + AnnotationFootnote, + MeasureTextFn, + ResolvedTheme, +} from '@opendata-ai/openchart-core'; import { estimateTextWidth, wrapText } from '@opendata-ai/openchart-core'; /** Resolve note lines once, so figure sizing, SVG rendering and export agree. */ @@ -35,3 +40,44 @@ export function wrapAnnotationNotes( export function annotationNoteLineCount(notes: AnnotationFootnote[]): number { return notes.reduce((count, note) => count + (note.lines?.length ?? 1), 0); } + +/** Source capacity stays fixed when a drag pins a label or changes which notes demote. */ +export function annotationNoteCapacity( + annotations: Annotation[], + width: number, + theme: ResolvedTheme, + measureText?: MeasureTextFn, + includeSingle = false, +): number { + if (annotations.length <= 1 && !includeSingle) return 0; + const texts = annotations.flatMap((annotation, specIndex) => + annotation.type === 'text' + ? [ + { + specIndex, + text: annotation.subtitle + ? `${annotation.text}. ${annotation.subtitle}` + : annotation.text, + }, + ] + : [], + ); + // Priority and placement determine the eventual numbering. Budget every source + // with the widest possible prefix, including proportional numeral fonts. + const size = theme.fonts.sizes.small; + const weight = theme.fonts.weights.normal; + const measure = (text: string) => + measureText?.(text, size, weight).width ?? estimateTextWidth(text, size, weight); + let widestIndex = 1; + for (let index = 2; index <= texts.length; index++) { + if (measure(`${index}. `) > measure(`${widestIndex}. `)) widestIndex = index; + } + return annotationNoteLineCount( + wrapAnnotationNotes( + texts.map((note) => ({ ...note, index: widestIndex })), + width, + theme, + measureText, + ), + ); +} diff --git a/packages/engine/src/annotations/placement.ts b/packages/engine/src/annotations/placement.ts index 7e914f14..d1105e0c 100644 --- a/packages/engine/src/annotations/placement.ts +++ b/packages/engine/src/annotations/placement.ts @@ -625,7 +625,7 @@ export function isAutoPlacement(annotation: { offset?: { dx?: number; dy?: number }; connector?: string | boolean | { type?: string }; }): boolean { - if (annotation.offset) return false; + if (annotation.offset !== undefined) return false; if (annotation.anchor && annotation.anchor !== 'auto') return false; if (annotation.connector === 'drop-line') return false; if ( diff --git a/packages/engine/src/annotations/reference-layout.ts b/packages/engine/src/annotations/reference-layout.ts new file mode 100644 index 00000000..2c6b2f7f --- /dev/null +++ b/packages/engine/src/annotations/reference-layout.ts @@ -0,0 +1,233 @@ +import type { + Annotation, + Rect, + RefLineAnnotation, + ResolvedAnnotation, + ThemeColors, +} from '@opendata-ai/openchart-core'; +import { overlapArea } from '@opendata-ai/openchart-core'; +import { type AnnotationMeasureTextFn, estimateLabelBounds, refreshConnector } from './geometry'; +import type { PlacementObstacle } from './placement'; +import { wrapAnnotationText } from './wrap-text'; + +export interface ReferenceReserve { + right: number; + top: number; + labelWidth: number; +} + +/** The reserve depends only on authored labels and container width, never placement results. */ +export function referenceLabelReserve( + annotations: Annotation[], + width: number, + measure: AnnotationMeasureTextFn, +): ReferenceReserve { + const outside = annotations.filter( + (a): a is RefLineAnnotation => + a.type === 'refline' && a.labelPlacement === 'outside' && !!a.label, + ); + const labelWidth = Math.max(24, Math.min(160, width * 0.25)); + const vertical = outside.filter((a) => a.y === undefined); + const top = vertical.reduce( + (height, a) => + height + + wrapAnnotationText( + a.label!, + labelWidth, + { fontSize: a.fontSize ?? 11, fontWeight: a.fontWeight ?? 400 }, + measure, + ).length * + (a.fontSize ?? 11) * + 1.3 + + 8, + 0, + ); + return { + right: outside.some((a) => a.y !== undefined) ? labelWidth + 32 : 0, + top: top ? top + 24 : 0, + labelWidth, + }; +} + +export interface ReferenceLanes { + rightLabels?: ResolvedAnnotation[]; + topBottom?: number; +} + +/** Opt-in placement leaves the legacy resolver and all un-authored paths untouched. */ +export function placeReferenceLabel( + annotation: RefLineAnnotation, + resolved: ResolvedAnnotation, + area: Rect, + container: Rect, + reserve: ReferenceReserve, + lanes: ReferenceLanes, + measure: AnnotationMeasureTextFn, + colors?: ThemeColors, + warn?: (message: string) => void, + obstacles: PlacementObstacle[] = [], +): void { + const label = resolved.label; + const line = resolved.line; + if (!label || !line || !annotation.labelPlacement) return; + const horizontal = annotation.y !== undefined; + const initial = { x: label.x, y: label.y }; + const bounds = () => estimateLabelBounds(label, measure); + const fits = (box: Rect) => + box.x >= container.x && + box.y >= container.y && + box.x + box.width <= container.x + container.width && + box.y + box.height <= container.y + container.height; + let target = horizontal ? line.end : line.start; + + if (annotation.labelOffset !== undefined) { + if (!fits(bounds())) + warn?.( + '[openchart] Reference labelOffset pins an overflowing label; remove labelOffset to allow automatic containment.', + ); + } else if (annotation.labelPlacement === 'outside') { + label.lines = wrapAnnotationText( + label.text, + reserve.labelWidth, + { fontSize: label.style.fontSize, fontWeight: label.style.fontWeight }, + measure, + ); + label.style = { ...label.style, textAnchor: 'start' }; + const size = bounds(); + if (horizontal) { + label.x = container.x + container.width - reserve.right + 24; + const previous = lanes.rightLabels?.at(-1)?.bounds; + const top = Math.max( + area.y, + line.end.y - size.height / 2, + previous ? previous.y + previous.height + 8 : area.y, + ); + label.y += top - bounds().y; + resolved.bounds = bounds(); + (lanes.rightLabels ??= []).push(resolved); + // Sweep the stack back from the bottom edge without changing source order. + // Connector endpoints remain on the corresponding rule after every move. + const bottom = Math.min(container.y + container.height, area.y + area.height); + let nextTop = bottom + 8; + for (const item of [...lanes.rightLabels].reverse()) { + const box = estimateLabelBounds(item.label!, measure); + const dy = Math.min(0, nextTop - 8 - box.y - box.height); + item.label!.y += dy; + item.bounds = item.label!.bounds = estimateLabelBounds(item.label!, measure); + nextTop = item.bounds.y; + if (item.label!.connector) + item.label!.connector = refreshConnector(item.label!.connector, item.bounds, 0); + } + if (nextTop < area.y) + warn?.( + '[openchart] Outside reference labels exceed their available lane; use shorter labels or a taller figure.', + ); + } else { + label.x = Math.min( + container.x + container.width - size.width, + Math.max(area.x, line.start.x - size.width / 2), + ); + const top = lanes.topBottom ?? area.y - reserve.top; + label.y += top - bounds().y; + lanes.topBottom = top + size.height + 8; + } + } else if (annotation.labelPlacement === 'auto') { + const original = { x: label.x, y: label.y, anchor: label.style.textAnchor }; + const candidates = horizontal + ? [ + { x: line.end.x - 4, y: label.y, anchor: 'end' as const }, + { x: line.start.x + 4, y: label.y, anchor: 'start' as const }, + ] + : [ + { x: label.x, y: line.start.y + 14, anchor: label.style.textAnchor }, + { x: label.x, y: line.end.y - 4, anchor: label.style.textAnchor }, + ]; + const relevant = obstacles.filter((o) => o.kind !== 'area-fill'); + const clear = (box: Rect) => fits(box) && relevant.every((o) => overlapArea(box, o) === 0); + let found = false; + for (const candidate of candidates) { + Object.assign(label, { x: candidate.x, y: candidate.y }); + label.style = { ...label.style, textAnchor: candidate.anchor }; + if (clear(bounds())) { + found = true; + break; + } + } + if (!found) { + Object.assign(label, { x: original.x, y: original.y }); + label.style = { ...label.style, textAnchor: original.anchor }; + const box = bounds(); + // Obstacle edges and containment boundaries are sufficient candidates; + // no pixel scan or side flip is needed. Bound the search for dense plots. + const shifts = horizontal + ? [ + container.x - box.x, + container.x + container.width - box.x - box.width, + ...relevant.flatMap((o) => [o.x - box.x - box.width - 4, o.x + o.width - box.x + 4]), + ] + : [ + container.y - box.y, + container.y + container.height - box.y - box.height, + ...relevant.flatMap((o) => [o.y - box.y - box.height - 4, o.y + o.height - box.y + 4]), + ]; + for (const shift of [...new Set(shifts)] + .sort((a, b) => Math.abs(a) - Math.abs(b)) + .slice(0, 64)) { + label.x = original.x + (horizontal ? shift : 0); + label.y = original.y + (horizontal ? 0 : shift); + if (clear(bounds())) { + found = true; + break; + } + } + if (!found) { + label.x = original.x; + label.y = original.y; + const shift = horizontal + ? Math.max( + container.x - box.x, + Math.min(0, container.x + container.width - box.x - box.width), + ) + : Math.max( + container.y - box.y, + Math.min(0, container.y + container.height - box.y - box.height), + ); + if (horizontal) label.x += shift; + else label.y += shift; + warn?.( + '[openchart] Reference label has no clear contained position; use labelPlacement: "outside" or shorter text.', + ); + } + } + } + label.background = colors?.annotationPlate; + label.bounds = bounds(); + resolved.bounds = label.bounds; + if ( + annotation.labelPlacement === 'outside' || + Math.hypot(label.x - initial.x, label.y - initial.y) > 8 + ) { + // A displaced label points to its rule, never a nearby observation. + if (annotation.labelPlacement !== 'outside') + target = horizontal ? { x: initial.x, y: line.start.y } : { x: line.start.x, y: initial.y }; + label.connector = refreshConnector( + { + from: { ...target }, + to: { ...target }, + endpoint: { ...target }, + stroke: resolved.stroke ?? '#666666', + style: 'straight', + arrow: false, + // The whole leader stays left of the right-gutter text, even when a + // tall neighbor displaces this label. Persist through sweep/tween rebuilds. + ...(annotation.labelPlacement === 'outside' && + horizontal && + annotation.labelOffset === undefined + ? { labelAttachment: 'left-center' as const } + : {}), + }, + label.bounds, + 0, + ); + } +} diff --git a/packages/engine/src/annotations/resolve-range.ts b/packages/engine/src/annotations/resolve-range.ts index a277644a..6a6cbba1 100644 --- a/packages/engine/src/annotations/resolve-range.ts +++ b/packages/engine/src/annotations/resolve-range.ts @@ -8,6 +8,7 @@ import type { Rect, ResolvedAnnotation, ResolvedLabel, + ThemeColors, } from '@opendata-ai/openchart-core'; import type { ResolvedScales } from '../layout/scales'; import { DEFAULT_RANGE_FILL, DEFAULT_RANGE_OPACITY } from './constants'; @@ -21,6 +22,7 @@ export function resolveRangeAnnotation( chartArea: Rect, isDark: boolean, fontFamily?: string, + colors?: ThemeColors, ): ResolvedAnnotation | null { let x = chartArea.x; let y = chartArea.y; @@ -78,7 +80,7 @@ export function resolveRangeAnnotation( const style = makeAnnotationLabelStyle( annotation.fontSize ?? 11, annotation.fontWeight ?? 500, - undefined, + colors?.annotationLabel, isDark, fontFamily, ); @@ -109,7 +111,7 @@ export function resolveRangeAnnotation( id: annotation.id, rect, label, - fill: annotation.fill ?? DEFAULT_RANGE_FILL, + fill: annotation.fill ?? colors?.annotationRangeFill ?? DEFAULT_RANGE_FILL, opacity: annotation.opacity ?? defaultOpacity, stroke: annotation.stroke, zIndex: annotation.zIndex, diff --git a/packages/engine/src/annotations/resolve-refline.ts b/packages/engine/src/annotations/resolve-refline.ts index b415d2a0..96cd55e0 100644 --- a/packages/engine/src/annotations/resolve-refline.ts +++ b/packages/engine/src/annotations/resolve-refline.ts @@ -9,6 +9,7 @@ import type { RefLineAnnotation, ResolvedAnnotation, ResolvedLabel, + ThemeColors, } from '@opendata-ai/openchart-core'; import type { ResolvedScales } from '../layout/scales'; import { DARK_REFLINE_STROKE, DEFAULT_REFLINE_DASH, LIGHT_REFLINE_STROKE } from './constants'; @@ -24,6 +25,7 @@ export function resolveRefLineAnnotation( isDark: boolean, measure: AnnotationMeasureTextFn = heuristicMeasure, fontFamily?: string, + colors?: ThemeColors, ): ResolvedAnnotation | null { let start: Point; let end: Point; @@ -123,9 +125,28 @@ export function resolveRefLineAnnotation( } } + if (annotation.labelPlacement) { + const atStart = + annotation.labelPlacement === 'start' || + (!isHorizontal && annotation.labelPlacement !== 'end'); + if (isHorizontal) { + labelX = atStart ? start.x : end.x; + labelY = start.y; + baseDx = atStart ? 4 : -4; + baseDy = anchor === 'bottom' ? 14 : -4; + textAnchor = atStart ? 'start' : 'end'; + } else { + labelX = start.x; + labelY = atStart ? start.y : end.y; + baseDx = anchor === 'right' ? -4 : 4; + baseDy = atStart ? 14 : -4; + textAnchor = anchor === 'right' ? 'end' : 'start'; + } + } const labelDelta = applyOffset({ dx: baseDx, dy: baseDy }, annotation.labelOffset); - const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE; + const defaultStroke = + colors?.annotationRule ?? (isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE); const style = makeAnnotationLabelStyle( annotation.fontSize ?? 11, annotation.fontWeight ?? 400, @@ -144,7 +165,8 @@ export function resolveRefLineAnnotation( }; } - const defaultStroke = isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE; + const defaultStroke = + colors?.annotationRule ?? (isDark ? DARK_REFLINE_STROKE : LIGHT_REFLINE_STROKE); const resolved: ResolvedAnnotation = { type: 'refline', diff --git a/packages/engine/src/annotations/resolve-text.ts b/packages/engine/src/annotations/resolve-text.ts index 8b6a2e9e..c2d34b07 100644 --- a/packages/engine/src/annotations/resolve-text.ts +++ b/packages/engine/src/annotations/resolve-text.ts @@ -11,6 +11,7 @@ import type { ResolvedLabel, TextAnnotation, TextStyle, + ThemeColors, } from '@opendata-ai/openchart-core'; import type { ResolvedScales } from '../layout/scales'; import { @@ -41,6 +42,7 @@ import { import { type AnnotationMeasureTextFn, applyOffset, + automaticControlPoint, computeAnchorOffset, computeTextBlockBounds, connectorExit, @@ -50,6 +52,7 @@ import { unionRects, } from './geometry'; import { resolvePosition } from './position'; +import { wrapAnnotationText } from './wrap-text'; /** The resolved endpoint marker on a text annotation. */ type ResolvedDot = NonNullable; @@ -61,9 +64,14 @@ type ResolvedDot = NonNullable; * * The marker inherits the same color, so leader and dot read as one system. */ -function defaultConnectorStroke(hasArrow: boolean, isDark: boolean, textInk: string): string { +function defaultConnectorStroke( + hasArrow: boolean, + isDark: boolean, + textInk: string, + colors?: ThemeColors, +): string { if (hasArrow) return textInk; - return isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE; + return colors?.annotationLeader ?? (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE); } /** @@ -82,6 +90,7 @@ function resolveDot( y: number, isDark: boolean, connectorStroke: string, + colors?: ThemeColors, ): ResolvedDot | undefined { const useDefault = dotSpec === undefined && hasConnector && !hasArrow; if (!dotSpec && !useDefault) return undefined; @@ -91,7 +100,8 @@ function resolveDot( x, y, radius: dotConfig.radius ?? DEFAULT_DOT_RADIUS, - fill: dotConfig.fill ?? (isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL), + fill: + dotConfig.fill ?? colors?.annotationMarkerFill ?? (isDark ? DARK_DOT_FILL : LIGHT_DOT_FILL), stroke: dotConfig.stroke ?? connectorStroke, strokeWidth: dotConfig.strokeWidth ?? DEFAULT_DOT_STROKE_WIDTH, }; @@ -108,13 +118,17 @@ export function markerClearance(dot: ResolvedDot | undefined): number { */ function resolveConnectorConfig( connector: TextAnnotation['connector'], -): { type: ConnectorType; arrow: boolean } | null { +): { type: ConnectorType; arrow: boolean; bend?: number } | null { if (connector === false) return null; 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'); - return { type: cfg.type, arrow }; + return { + type: cfg.type, + arrow, + ...(cfg.type === 'curve' && cfg.bend !== undefined ? { bend: cfg.bend } : {}), + }; } if (connector === 'curve') return { type: 'curve', arrow: true }; @@ -124,6 +138,47 @@ function resolveConnectorConfig( return { type: 'straight', arrow: false }; } +/** Retain a rebuildable connector even when its current leader is too short to draw. */ +export function textConnectorSeed( + annotation: TextAnnotation, + scales: ResolvedScales, + isDark: boolean, + colors?: ThemeColors, +): ResolvedLabel['connector'] { + const config = resolveConnectorConfig(annotation.connector); + const x = resolvePosition(annotation.x, scales.x); + const y = resolvePosition(annotation.y, scales.y); + if (!config || x === null || y === null) return undefined; + const endpoint = { + x: x + (annotation.connectorOffset?.to?.dx ?? 0), + y: y + (annotation.connectorOffset?.to?.dy ?? 0), + }; + return { + from: { ...endpoint }, + to: { ...endpoint }, + endpoint, + style: config.type, + arrow: config.arrow, + stroke: + annotation.stroke ?? + defaultConnectorStroke( + config.arrow, + isDark, + annotation.fill ?? colors?.annotationLabel ?? (isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL), + colors, + ), + ...(config.bend !== undefined ? { bend: config.bend } : {}), + ...(annotation.connectorOffset?.from + ? { + fromOffset: { + dx: annotation.connectorOffset.from.dx ?? 0, + dy: annotation.connectorOffset.from.dy ?? 0, + }, + } + : {}), + }; +} + /** * Resolve `background` to a concrete color. `true` picks the theme surface so * the plate follows light/dark; a string passes through as an explicit override. @@ -131,9 +186,11 @@ function resolveConnectorConfig( function resolveLabelBackground( background: string | true | undefined, isDark: boolean, + colors?: ThemeColors, ): string | undefined { if (background === undefined) return undefined; - if (background === true) return isDark ? DARK_LABEL_BACKGROUND : LIGHT_LABEL_BACKGROUND; + if (background === true) + return colors?.annotationPlate ?? (isDark ? DARK_LABEL_BACKGROUND : LIGHT_LABEL_BACKGROUND); return background; } @@ -167,6 +224,7 @@ export function resolveLedeFontWeight(annotation: TextAnnotation): number | unde return annotation.subtitle ? LEDE_FONT_WEIGHT : undefined; } +/** Resolve wrapped display geometry, retaining authored text as the identity. */ export function resolveTextAnnotation( annotation: TextAnnotation, scales: ResolvedScales, @@ -174,14 +232,72 @@ export function resolveTextAnnotation( isDark: boolean, measure: AnnotationMeasureTextFn = heuristicMeasure, fontFamily?: string, + colors?: ThemeColors, +): ResolvedAnnotation | null { + if (annotation.maxWidth == null) + return resolveTextAnnotationDisplay( + annotation, + scales, + chartArea, + isDark, + measure, + fontFamily, + colors, + ); + const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE; + const lines = wrapAnnotationText( + annotation.text, + annotation.maxWidth, + { fontSize, fontWeight: resolveLedeFontWeight(annotation) }, + measure, + ); + const subLines = annotation.subtitle + ? wrapAnnotationText( + annotation.subtitle, + annotation.maxWidth, + { + fontSize: annotation.subtitleFontSize ?? subtitleFontSize(fontSize), + fontWeight: SUBTITLE_FONT_WEIGHT, + }, + measure, + ) + : undefined; + const resolved = resolveTextAnnotationDisplay( + { ...annotation, text: lines.join('\n'), subtitle: subLines?.join('\n') }, + scales, + chartArea, + isDark, + measure, + fontFamily, + colors, + ); + if (resolved?.label) { + resolved.label.text = annotation.text; + resolved.label.lines = lines; + } + if (resolved?.subtitle) { + resolved.subtitle.text = annotation.subtitle!; + resolved.subtitle.lines = subLines; + } + return resolved; +} + +function resolveTextAnnotationDisplay( + annotation: TextAnnotation, + scales: ResolvedScales, + chartArea: Rect, + isDark: boolean, + measure: AnnotationMeasureTextFn = heuristicMeasure, + fontFamily?: string, + colors?: ThemeColors, ): ResolvedAnnotation | null { const px = resolvePosition(annotation.x, scales.x); const py = resolvePosition(annotation.y, scales.y); if (px === null || py === null) return null; - const defaultTextFill = isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL; - const labelBackground = resolveLabelBackground(annotation.background, isDark); + const defaultTextFill = colors?.annotationLabel ?? (isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL); + const labelBackground = resolveLabelBackground(annotation.background, isDark, colors); const labelStyle = makeAnnotationLabelStyle( annotation.fontSize, @@ -207,6 +323,7 @@ export function resolveTextAnnotation( labelBackground, isDark, measure, + colors, ); } @@ -247,7 +364,7 @@ export function resolveTextAnnotation( annotation.subtitle, { ...labelStyle, - fontSize: subtitleFontSize(fontSize), + fontSize: annotation.subtitleFontSize ?? subtitleFontSize(fontSize), fontWeight: SUBTITLE_FONT_WEIGHT, textAnchor: 'start', }, @@ -273,10 +390,11 @@ export function resolveTextAnnotation( let annotationBounds: Rect = labelBounds; if (annotation.subtitle) { const primaryLineCount = annotation.text.split('\n').length; - const mutedFill = isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL; + const mutedFill = + colors?.annotationMuted ?? (isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL); const subtitleStyle: TextStyle = { ...labelStyle, - fontSize: subtitleFontSize(fontSize), + fontSize: annotation.subtitleFontSize ?? subtitleFontSize(fontSize), fontWeight: SUBTITLE_FONT_WEIGHT, fill: mutedFill, }; @@ -307,7 +425,7 @@ export function resolveTextAnnotation( // The marker shares the color so it reads as part of the same gesture. const connectorStroke = annotation.stroke ?? - defaultConnectorStroke(connectorArrow, isDark, labelStyle.fill ?? defaultTextFill); + defaultConnectorStroke(connectorArrow, isDark, labelStyle.fill ?? defaultTextFill, colors); const dot = resolveDot( annotation.dot, showConnector, @@ -316,6 +434,7 @@ export function resolveTextAnnotation( endpoint.y, isDark, connectorStroke, + colors, ); // Connector origin: ray from the block center (label ∪ subtitle) to the data @@ -361,6 +480,12 @@ export function resolveTextAnnotation( style: connectorStyle, arrow: connectorArrow, exit: exit.exit, + ...(connectorStyle === 'curve' + ? { + control: automaticControlPoint(from, to, exit.exit, connectorConfig?.bend), + ...(connectorConfig?.bend !== undefined ? { bend: connectorConfig.bend } : {}), + } + : {}), }; } } @@ -406,12 +531,13 @@ function resolveDropLineAnnotation( labelBackground: string | undefined, isDark: boolean, measure: AnnotationMeasureTextFn = heuristicMeasure, + colors?: ThemeColors, ): ResolvedAnnotation { const fontSize = annotation.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE; const fontWeight = Number(labelStyle.fontWeight) || DEFAULT_ANNOTATION_FONT_WEIGHT; const lines = annotation.text.split('\n'); const subtitleLines = annotation.subtitle ? annotation.subtitle.split('\n') : []; - const subFontSize = subtitleFontSize(fontSize); + const subFontSize = annotation.subtitleFontSize ?? subtitleFontSize(fontSize); const estimatedWidth = Math.max( 0, ...lines.map((line) => measureRichLine(line, { fontSize, fontWeight }, measure)), @@ -440,7 +566,9 @@ function resolveDropLineAnnotation( side = fitsLeft || spaceLeft > spaceRight ? 'left' : 'right'; } - const labelX = side === 'left' ? px - DROP_LINE_LABEL_GAP : px + DROP_LINE_LABEL_GAP; + const labelX = + (side === 'left' ? px - DROP_LINE_LABEL_GAP : px + DROP_LINE_LABEL_GAP) + + (annotation.offset?.dx ?? 0); const textAnchor: 'start' | 'end' = side === 'left' ? 'end' : 'start'; // Drop the label box top a bit above the data point so the label and line @@ -457,7 +585,7 @@ function resolveDropLineAnnotation( // upward into chrome / metric-bar territory. const desiredLabelTopY = py - totalHeight - 12; const minLabelTopY = chartArea.y + 4; - const labelTopY = Math.max(desiredLabelTopY, minLabelTopY); + const labelTopY = Math.max(desiredLabelTopY, minLabelTopY) + (annotation.offset?.dy ?? 0); const labelBaselineY = labelTopY + fontSize; const resolvedStyle: TextStyle = { ...labelStyle, textAnchor }; @@ -466,12 +594,30 @@ function resolveDropLineAnnotation( // marker too — and the line stops short of it by the same rule as the other // connector styles. Quiet voice: gray hairline, marker in the same gray. const connectorStroke = - annotation.stroke ?? (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE); - const dot = resolveDot(annotation.dot, true, false, px, py, isDark, connectorStroke); + annotation.stroke ?? + colors?.annotationLeader ?? + (isDark ? DARK_CONNECTOR_STROKE : LIGHT_CONNECTOR_STROKE); + const endpoint = { + x: px + (annotation.connectorOffset?.to?.dx ?? 0), + y: py + (annotation.connectorOffset?.to?.dy ?? 0), + }; + const dot = resolveDot( + annotation.dot, + true, + false, + endpoint.x, + endpoint.y, + isDark, + connectorStroke, + colors, + ); const bottomGap = connectorPullbackGap(markerClearance(dot), false); - const from = { x: px, y: labelTopY - DROP_LINE_TOP_GAP }; - const to = { x: px, y: py - bottomGap }; + const from = { + x: endpoint.x + (annotation.connectorOffset?.from?.dx ?? 0), + y: labelTopY - DROP_LINE_TOP_GAP + (annotation.connectorOffset?.from?.dy ?? 0), + }; + const to = { x: endpoint.x, y: endpoint.y - bottomGap }; const labelBounds = computeTextBlockBounds( labelX, @@ -491,7 +637,7 @@ function resolveDropLineAnnotation( ...resolvedStyle, fontSize: subFontSize, fontWeight: SUBTITLE_FONT_WEIGHT, - fill: isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL, + fill: colors?.annotationMuted ?? (isDark ? DARK_MUTED_TEXT_FILL : LIGHT_MUTED_TEXT_FILL), }; // `lineHeight` here is already the pixel line-height, which is what the shared // formula takes. @@ -517,7 +663,15 @@ function resolveDropLineAnnotation( connector: { from, to, - endpoint: { x: px, y: py }, + endpoint, + ...(annotation.connectorOffset?.from + ? { + fromOffset: { + dx: annotation.connectorOffset.from.dx ?? 0, + dy: annotation.connectorOffset.from.dy ?? 0, + }, + } + : {}), stroke: connectorStroke, style: 'drop-line', arrow: false, diff --git a/packages/engine/src/annotations/thinning.ts b/packages/engine/src/annotations/thinning.ts index b727bf23..63739447 100644 --- a/packages/engine/src/annotations/thinning.ts +++ b/packages/engine/src/annotations/thinning.ts @@ -65,8 +65,9 @@ export function thinAnnotations( measure: AnnotationMeasureTextFn, plotArea?: Rect, containerBounds?: Rect, + compactLimit?: number | null, ): ThinningResult { - if (annotations.length <= 1) { + if (annotations.length <= 1 && compactLimit == null) { return { annotations, footnotes: [] }; } @@ -138,10 +139,11 @@ export function thinAnnotations( const containment = containerBounds ?? plotArea; let usedArea = 0; - for (const entry of candidates) { + for (const [rank, entry] of candidates.entries()) { + const overLimit = compactLimit != null && rank >= compactLimit; const bounds = entry.resolved.bounds ?? estimateLabelBounds(entry.resolved.label!, measure); - if (bounds.width <= 0 || bounds.height <= 0) { + if (!overLimit && (bounds.width <= 0 || bounds.height <= 0)) { placedBounds.push(bounds); continue; } @@ -159,7 +161,7 @@ export function thinAnnotations( const candidateArea = bounds.width * bounds.height; const overBudget = plotAreaSize > 0 && usedArea + candidateArea > coverageBudget; - if (overlaps || !fitsWithin(bounds, containment) || overBudget) { + if (overLimit || overlaps || !fitsWithin(bounds, containment) || overBudget) { const footnoteIndex = footnotes.length + 1; footnotes.push({ ...(entry.resolved.specIndex !== undefined ? { specIndex: entry.resolved.specIndex } : {}), diff --git a/packages/engine/src/annotations/wrap-text.ts b/packages/engine/src/annotations/wrap-text.ts new file mode 100644 index 00000000..1cad5c8f --- /dev/null +++ b/packages/engine/src/annotations/wrap-text.ts @@ -0,0 +1,73 @@ +import type { AnnotationMeasureTextFn } from './geometry'; +import { BOLD_SPAN_FONT_WEIGHT, parseAnnotationSpans } from './rich-text'; + +type Character = { text: string; bold: boolean }; + +/** Wrap display text without changing the authored string used for identity and notes. */ +export function wrapAnnotationText( + text: string, + maxWidth: number, + style: { fontSize: number; fontWeight?: number | string }, + measure: AnnotationMeasureTextFn, +): string[] { + const width = (chars: Character[]) => { + let total = 0; + let run = ''; + let bold = false; + const flush = () => { + total += measure(run, { + fontSize: style.fontSize, + fontWeight: bold ? BOLD_SPAN_FONT_WEIGHT : Number(style.fontWeight) || 400, + }); + }; + for (const char of chars) { + if (char.bold !== bold) { + flush(); + run = ''; + bold = char.bold; + } + run += char.text; + } + flush(); + return total; + }; + const serialize = (chars: Character[]) => { + let result = ''; + let bold = false; + for (const char of chars) { + if (char.bold !== bold) { + result += '**'; + bold = char.bold; + } + result += char.text; + } + return result + (bold ? '**' : ''); + }; + return text.split('\n').flatMap((paragraph) => { + const chars = parseAnnotationSpans(paragraph).flatMap((span) => + Array.from(span.text, (text) => ({ text, bold: span.bold })), + ); + const lines: string[] = []; + let pending: Character[] = []; + for (const char of chars) { + pending.push(char); + if (width(pending) <= maxWidth || pending.length === 1) continue; + let split = -1; + for (let i = pending.length - 1; i > 0; i--) + if (/\s/.test(pending[i].text)) { + split = i; + break; + } + if (split > 0) { + lines.push(serialize(pending.slice(0, split))); + pending = pending.slice(split + 1); + } else { + const last = pending.pop()!; + lines.push(serialize(pending)); + pending = [last]; + } + } + if (pending.length || !lines.length) lines.push(serialize(pending)); + return lines; + }); +} diff --git a/packages/engine/src/compile.ts b/packages/engine/src/compile.ts index 169ef71e..ddde00e3 100644 --- a/packages/engine/src/compile.ts +++ b/packages/engine/src/compile.ts @@ -1,5 +1,5 @@ import { mergeThemeConfig } from '@opendata-ai/openchart-core'; -import { diagnoseChart } from './design/diagnostics'; +import { diagnoseChart, noteCapacityDiagnostic } from './design/diagnostics'; /** * Main compile API: the public entry points for the engine. * @@ -37,7 +37,7 @@ import type { import { adaptTheme, computeLabelBounds, - footnoteBandHeight, + computeNoteBandHeight, generateAltText, generateDataTable, getBreakpoint, @@ -52,10 +52,17 @@ import { import { collectPinnedOverlapWarnings } from './annotations/collisions'; import { computeAnnotations } from './annotations/compute'; import { type AnnotationMeasureTextFn, heuristicMeasure } from './annotations/geometry'; -import { annotationNoteLineCount, wrapAnnotationNotes } from './annotations/notes'; +import { + annotationNoteCapacity, + annotationNoteLineCount, + wrapAnnotationNotes, +} from './annotations/notes'; import type { PlacementObstacle } from './annotations/placement'; +import { referenceLabelReserve } from './annotations/reference-layout'; import { thinAnnotations } from './annotations/thinning'; +import { collectMarkOptionWarnings } 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 // registry on module load. Tests that clear the registry can import // `registerBuiltinRenderers` from `./charts/builtin` to restore defaults. @@ -124,7 +131,11 @@ function applySpecSize(spec: unknown, options: CompileOptions): CompileOptions { return { ...options, width: typeof sized.width === 'number' ? sized.width : options.width, - height: typeof sized.height === 'number' ? sized.height : options.height, + height: + typeof sized.height === 'number' && + !(options?.facetHeightGrown && (sized.chromeLayout ?? options.chromeLayout) === 'grow') + ? sized.height + : options.height, }; } @@ -258,13 +269,19 @@ 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); + return compileChartInternal(spec, optionsInput); +} + +/** Internal passes retain authored warnings from the public entry point. */ +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 // validation + normalization, surfacing any deprecation warnings. const sugarWarnings: string[] = []; const expandedSpec = spec && typeof spec === 'object' && !Array.isArray(spec) - ? expandSpecSugar(spec as Record, sugarWarnings) + ? expandSpecSugar(spec as Record, sugarWarnings, false) : spec; // Top-level width/height are fixed-size overrides (VL alignment): the @@ -276,7 +293,9 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart // console channel as the sugar warnings. Emit both together in one call so // dedup spans the whole compile: a message produced by both a sugar pass and // a normalize pass prints once, not once per stage. - const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec); + const { spec: normalized, warnings: normalizeWarnings } = compileSpec(expandedSpec, { + markOptionWarnings: false, + }); emitSpecWarnings([...sugarWarnings, ...normalizeWarnings], options.onWarn); if ('type' in normalized && (normalized as unknown as Record).type === 'table') { @@ -579,6 +598,27 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart theme = adaptTheme(theme); } + // 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 + // pins from labels pinned by editing. Facets share this source-level budget. + const noteCapacity = chartSpec.autoThin + ? annotationNoteCapacity( + chartSpec.annotations, + options.width, + theme, + options.measureText, + options.width < 400 && options.height >= 200 && chartSpec.compactAnnotationLimit != null, + ) + : 0; + const stableNoteReserve = computeNoteBandHeight(noteCapacity, theme); + if (!options.frozenChartArea && stableNoteReserve > (options.footnoteReserve ?? 0)) { + return compileChartInternal(spec, { + ...optionsInput, + footnoteReserve: stableNoteReserve, + }); + } + // Sparkline mode: inject smart defaults that depend on the resolved theme // (trend-aware color, area gradient, default endpoint dot, pill cornerRadius // for bars). Each default backs off when the user has set the corresponding @@ -670,20 +710,23 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart const facetChannel = renderSpec.encoding.facet; if (facetChannel?.field) { - return compileFaceted( + const layout = compileFaceted( chartSpec, renderSpec, facetChannel, facetDirection, - options, + { ...options, onDiagnostic: undefined }, theme, strategy, watermark, resolvedAnimation, crosshair, spec, - optionsInput, + { ...optionsInput, onDiagnostic: undefined }, ); + const diagnostic = noteCapacityDiagnostic(layout); + if (diagnostic) options.onDiagnostic?.(diagnostic); + return layout; } // --------------------------------------------------------------------------- @@ -715,27 +758,6 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart // one so all layers share the primary layout's coordinate space. const chartArea = options.frozenChartArea ?? dims.chartArea; - // Place legend in its final position relative to the computed chart area. - // effectiveAxisGap lifts a top legend above the inline-tick overhang zone - // the margin stack reserved below it. - const finalLegend = placeLegend( - plan.legendContent, - chartArea, - options.width, - theme, - plan.xAxisExtent, - dims.effectiveAxisGap, - ); - - // Every legend on the chart. The color legend is primary (and stays in the - // singular `legend` slot); a size legend joins it when a quantitative `size` - // channel is encoded. Consumers that reserve space or hit-test must iterate - // this rather than read `legend` -- see ChartLayout.legends. - const sizeLegend = plan.sizeLegendContent - ? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend) - : null; - const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])]; - // Inline y-axis labels: inset the x-scale range start by the widest tick // label width so data marks start to the right of the labels. The plan // already measured the real tick labels. @@ -900,6 +922,32 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart plan.endpointLabelsDemoted, ); + // Place legend in its final position relative to the computed chart area. + // effectiveAxisGap lifts a top legend above the inline-tick overhang zone + // the margin stack reserved below it. + const finalLegend = placeLegend( + plan.legendContent, + // Dimensions reserve both columns; place the legend after the resolved + // endpoint column instead of drawing both at the plot's right edge. + (plan.legendContent.position === 'right' || plan.legendContent.position === 'bottom-right') && + endpointLabels.entries.length > 0 + ? { ...chartArea, width: chartArea.width + ENDPOINT_COLUMN_GAP + endpointLabels.bounds.width } + : chartArea, + options.width, + theme, + plan.xAxisExtent, + dims.effectiveAxisGap, + ); + + // Every legend on the chart. The color legend is primary (and stays in the + // singular `legend` slot); a size legend joins it when a quantitative `size` + // channel is encoded. Consumers that reserve space or hit-test must iterate + // this rather than read `legend` -- see ChartLayout.legends. + const sizeLegend = plan.sizeLegendContent + ? placeSizeLegend(plan.sizeLegendContent, chartArea, theme, finalLegend) + : null; + const allLegends: LegendLayout[] = [finalLegend, ...(sizeLegend ? [sizeLegend] : [])]; + // Compute annotations from spec, passing legend + mark + brand bounds as obstacles const obstacles: PlacementObstacle[] = []; if (finalLegend.bounds.width > 0) { @@ -950,6 +998,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart svg: { width: dims.total.width, height: dims.total.height }, measure: annotationMeasure, fontFamily: theme.fonts.family, + colors: theme.colors, + onWarn: (message) => emitSpecWarnings([message], options.onWarn), autoThin: chartSpec.autoThin, // Hand-placed blocks skip every nudge pass, so the clamp is the only lever // that keeps an author's offset from parking a callout on the title. @@ -974,13 +1024,14 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart // Pass the full spec annotations (not filtered to text-only) so index // alignment with the resolved array is preserved. let footnotes: import('@opendata-ai/openchart-core').AnnotationFootnote[] | undefined; - if (chartSpec.autoThin && annotations.length > 1) { + if (chartSpec.autoThin && (annotations.length > 1 || chartSpec.compactAnnotationLimit != null)) { const result = thinAnnotations( annotations, chartSpec.annotations, annotationMeasure, chartArea, { x: 0, y: 0, width: dims.total.width, height: dims.total.height }, + options.width < 400 && options.height >= 200 ? chartSpec.compactAnnotationLimit : undefined, ); annotations = result.annotations; if (result.footnotes.length > 0) { @@ -991,34 +1042,18 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart options.measureText, ); - // The footnote list renders below the plot, but nothing reserved space for - // it — thinning only just now discovered how many lines there are. Recompile - // with that band carved out of the bottom margin so the list can't overrun - // the source row and the brand watermark. - // - // Reserving shrinks the plot, which can push a further label out of bounds - // and grow the list by one, so re-reserve until the count settles. The - // count only ever grows (a smaller plot never keeps more labels inline) and - // is bounded by the annotation count, so this terminates; the explicit cap - // is a belt-and-braces guard against a measurement quirk oscillating. - // - // A frozen area short-circuits all of this. `compileLayer` pins every - // leaf to the primary layout's coordinate space, and `chartArea` above - // takes `frozenChartArea` over anything `computeDimensions` produces -- - // so a grown bottom margin is computed and then thrown away. The - // recursion would re-run the whole compile up to MAX_FOOTNOTE_PASSES - // times and return the identical layout. Skip it rather than pay for it. - const reserve = footnoteBandHeight(annotationNoteLineCount(footnotes), theme); + // Source capacity normally covers this list. Keep a bounded fallback + // for custom measurement functions. Frozen layers share the primary + // plot and reconcile any larger reserve in their assembly pass. + const reserve = computeNoteBandHeight(annotationNoteLineCount(footnotes), theme); const currentReserve = options.footnoteReserve ?? 0; if ( !options.frozenChartArea && reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES ) { - const grow = ((spec as ChartSpec).chromeLayout ?? options.chromeLayout) === 'grow'; - return compileChart(spec, { + return compileChartInternal(spec, { ...optionsInput, - height: optionsInput.height + (grow ? reserve - currentReserve : 0), footnoteReserve: reserve, footnotePass: (options.footnotePass ?? 0) + 1, }); @@ -1060,8 +1095,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart ) : []; if (seriesSummary.length) { - const reserve = footnoteBandHeight( - annotationNoteLineCount(footnotes ?? []) + + const reserve = computeNoteBandHeight( + Math.max(noteCapacity, annotationNoteLineCount(footnotes ?? [])) + seriesSummary.reduce((count, entry) => count + entry.lines.length, 0), theme, ); @@ -1071,10 +1106,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart reserve > previousReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES ) { - const grow = ((spec as ChartSpec).chromeLayout ?? options.chromeLayout) === 'grow'; - return compileChart(spec, { + return compileChartInternal(spec, { ...optionsInput, - height: optionsInput.height + (grow ? reserve - previousReserve : 0), footnoteReserve: reserve, footnotePass: (options.footnotePass ?? 0) + 1, }); @@ -1110,8 +1143,17 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart // Assign animationIndex for stagger ordering when animation is enabled assignAnimationIndices(marks, resolvedAnimation); + const noteBandHeight = Math.max( + options.footnoteReserve ?? 0, + computeNoteBandHeight( + Math.max(noteCapacity, annotationNoteLineCount(footnotes ?? [])) + + seriesSummary.reduce((count, entry) => count + entry.lines.length, 0), + theme, + ), + ); const chrome = { ...dims.chrome, + ...(noteBandHeight > 0 ? { noteBandHeight } : {}), ...(footnotes ? { footnotes } : {}), ...(seriesSummary.length ? { seriesSummary } : {}), }; @@ -1239,6 +1281,7 @@ function compileFaceted( strategy, watermark, plan, + false, ); const chartArea = dims.chartArea; @@ -1260,10 +1303,33 @@ function compileFaceted( // computeDimensions), so leftmost-panel tick labels render into that margin // just like the single-chart path. const effectiveColumns = facetDirection === 'column' ? facetValues.length : facetChannel.columns; - const grid = computeFacetGrid(facetValues, effectiveColumns, chartArea, { - left: 0, - bottom: bottomReservation, - }); + const facetAnnotationMeasure: AnnotationMeasureTextFn = options.measureText + ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width + : heuristicMeasure; + const hasOutsideReferences = chartSpec.annotations.some( + (a) => a.type === 'refline' && a.labelPlacement === 'outside' && !!a.label, + ); + const referenceInsets = hasOutsideReferences + ? { + measure: (width: number) => + referenceLabelReserve(chartSpec.annotations, width, facetAnnotationMeasure), + innerAxisWidth: + (chartSpec.resolve?.scale?.y ?? (facetDirection === 'row' ? 'independent' : 'shared')) === + 'independent' + ? plan.leftGutter + : 0, + } + : undefined; + const grid = computeFacetGrid( + facetValues, + effectiveColumns, + chartArea, + { + left: 0, + bottom: bottomReservation, + }, + referenceInsets, + ); // If panels are too short, grow the compile height and restart (once). if ( @@ -1271,14 +1337,17 @@ function compileFaceted( grid.panels.length > 0 && grid.panels[0].area.height < MIN_PANEL_HEIGHT ) { - const neededChartHeight = facetMinHeight(facetValues.length, grid.columns); + const neededChartHeight = + grid.minimumHeight ?? facetMinHeight(facetValues.length, grid.columns); const heightGrowth = neededChartHeight - chartArea.height; if (heightGrowth > 0) { - return compileChart(rawSpec, { + const grown = compileChartInternal(rawSpec, { ...optionsInput, height: options.height + heightGrowth, facetHeightGrown: true, }); + if (grown.chrome.grownHeight !== undefined) grown.chrome.grownHeight += heightGrowth; + return grown; } } @@ -1371,7 +1440,11 @@ function compileFaceted( data: panelData, encoding: panelSpecWithDomains.encoding as Encoding, markType: chartSpec.markType, - totalWidth: gridPanel.area.width, + // Outside-label cells use figure coordinates throughout, including + // the tick-label clamp. Keep the legacy path unchanged in 8.x. + totalWidth: gridPanel.referenceContainer + ? gridPanel.area.x + gridPanel.area.width + : gridPanel.area.width, }); if (!isRadial) { @@ -1420,12 +1493,22 @@ function compileFaceted( let panelAnnotations = computeAnnotations(panelSpecWithDomains, { scales: panelScales, chartArea: gridPanel.area, + referenceContainer: gridPanel.referenceContainer, + referenceReserve: gridPanel.referenceContainer + ? referenceLabelReserve( + chartSpec.annotations, + gridPanel.referenceContainer.width, + facetAnnotationMeasure, + ) + : undefined, strategy, isDark: theme.isDark, obstacles: [], - svg: { width: options.width, height: options.height }, + svg: { width: dims.total.width, height: dims.total.height }, measure: annotationMeasure, fontFamily: theme.fonts.family, + colors: theme.colors, + onWarn: (message) => emitSpecWarnings([message], options.onWarn), autoThin: panelSpecWithDomains.autoThin, topBound: dims.chrome.topHeight > 0 ? dims.topPad + dims.chrome.topHeight : undefined, }); @@ -1440,13 +1523,17 @@ function compileFaceted( // is per-spec-annotation, so number by spec index and reuse that number in // every panel that demoted it -- otherwise the list repeats itself once per // panel. - if (chartSpec.autoThin && panelAnnotations.length > 1) { + if ( + chartSpec.autoThin && + (panelAnnotations.length > 1 || chartSpec.compactAnnotationLimit != null) + ) { const thinned = thinAnnotations( panelAnnotations, chartSpec.annotations, annotationMeasure, gridPanel.area, gridPanel.area, + options.width < 400 && options.height >= 200 ? chartSpec.compactAnnotationLimit : undefined, ); panelAnnotations = thinned.annotations; // `thinAnnotations` numbers footnotes 1..n *within the panel it was given*, @@ -1496,16 +1583,8 @@ function compileFaceted( }); } - // The footnote list renders below the grid, but nothing reserved space for it - // -- thinning only just now discovered how many lines there are. Recompile with - // that band carved out of the bottom margin, same contract as the single-chart - // path (see the matching block in compileChart). computeDimensions applies - // options.footnoteReserve generically, so the facet grid shrinks with it. - // - // A frozen area short-circuits this, same as the single-chart path: compileLayer - // pins every leaf to the primary layout's coordinate space, so the grown bottom - // margin is computed and then thrown away. Recursing would re-run the whole - // compile up to MAX_FOOTNOTE_PASSES times for an identical layout. + // Facets share the source-capacity preflight. Reconcile an unexpected + // measurement overflow once at figure level, never once per panel. const wrappedFootnotes = wrapAnnotationNotes( allFootnotes, dims.total.width, @@ -1513,10 +1592,10 @@ function compileFaceted( options.measureText, ); if (allFootnotes.length > 0 && !options.frozenChartArea) { - const reserve = footnoteBandHeight(annotationNoteLineCount(wrappedFootnotes), theme); + const reserve = computeNoteBandHeight(annotationNoteLineCount(wrappedFootnotes), theme); const currentReserve = options.footnoteReserve ?? 0; if (reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES) { - return compileChart(rawSpec, { + return compileChartInternal(rawSpec, { ...optionsInput, footnoteReserve: reserve, footnotePass: (options.footnotePass ?? 0) + 1, @@ -1589,7 +1668,14 @@ function compileFaceted( // Figure-level marks are the union of all panel marks (for tooltip/keyboard nav) return { area: chartArea, - chrome: allFootnotes.length > 0 ? { ...dims.chrome, footnotes: wrappedFootnotes } : dims.chrome, + chrome: { + ...dims.chrome, + noteBandHeight: Math.max( + options.footnoteReserve ?? 0, + computeNoteBandHeight(annotationNoteLineCount(wrappedFootnotes), theme), + ), + ...(allFootnotes.length > 0 ? { footnotes: wrappedFootnotes } : {}), + }, metrics: dims.metrics, ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}), axes: { x: undefined, y: undefined }, @@ -1609,7 +1695,7 @@ function compileFaceted( ...(chartSpec.a11y?.hidden ? { hidden: true } : {}), }, theme, - dimensions: { width: options.width, height: options.height }, + dimensions: { width: dims.total.width, height: dims.total.height }, animation: resolvedAnimation, watermark, display: chartSpec.display, @@ -1640,7 +1726,22 @@ export function compileLayer(spec: LayerSpec, options: CompileOptions): ChartLay ) as unknown as LayerSpec; emitSpecWarnings(sugarWarnings, options.onWarn); const resolvedOptions = applySpecSize(expanded, options); - return compileLayerImpl(expanded, resolvedOptions, compileChart); + const layout = compileLayerImpl( + expanded, + { + ...resolvedOptions, + onDiagnostic: options.onDiagnostic + ? (diagnostic) => { + // Report note exhaustion against the assembled figure, not intermediate leaves. + if (diagnostic.code !== 'layout.note-capacity') options.onDiagnostic!(diagnostic); + } + : undefined, + }, + compileChartInternal, + ); + const diagnostic = noteCapacityDiagnostic(layout); + if (diagnostic) options.onDiagnostic?.(diagnostic); + return layout; } // --------------------------------------------------------------------------- diff --git a/packages/engine/src/compile/layer.ts b/packages/engine/src/compile/layer.ts index 401d3bb9..891cb22e 100644 --- a/packages/engine/src/compile/layer.ts +++ b/packages/engine/src/compile/layer.ts @@ -15,9 +15,9 @@ import { axisTitleOffset, BREAKPOINT_COMPACT_MAX, computeFieldFormatContext, + computeNoteBandHeight, defaultNumberFormatter, estimateTextWidth, - footnoteBandHeight, getBreakpoint, getHeightClass, getLayoutStrategy, @@ -27,7 +27,9 @@ import { } from '@opendata-ai/openchart-core'; import { scaleLinear } from 'd3-scale'; import { curveMonotoneX, area as d3area, line as d3line } from 'd3-shape'; +import { heuristicMeasure } from '../annotations/geometry'; import { annotationNoteLineCount } from '../annotations/notes'; +import { referenceLabelReserve } from '../annotations/reference-layout'; import { flattenLayers } from '../compiler/index'; import { normalizeSpec } from '../compiler/normalize'; import type { NormalizedChartSpec } from '../compiler/types'; @@ -341,8 +343,21 @@ function compileLayerIndependent( const legendReserve = rightLegends.length ? Math.max(...rightLegends.map((legend) => legend.bounds.width)) + 8 : 0; + const referenceReserve = referenceLabelReserve( + primary0.annotations ?? [], + options.width, + options.measureText + ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width + : heuristicMeasure, + ); const right = - options.width - pad - rightReserve - ENDPOINT_COLUMN_GAP - endpointWidth - legendReserve; + options.width - + pad - + rightReserve - + ENDPOINT_COLUMN_GAP - + endpointWidth - + legendReserve - + referenceReserve.right; const grow = (primary0.chromeLayout ?? options.chromeLayout) === 'grow'; const bottom = grow ? layout0.area.y + layout0.area.height @@ -364,7 +379,6 @@ function compileLayerIndependent( ...optionsWithReserve, frozenChartArea: area, footnoteReserve: reserve, - height: optionsWithReserve.height + (grow ? reserve : 0), }; layout0 = compileChart(primary0, alignedOptions); layout1 = compileChart(primary1, alignedOptions); @@ -372,18 +386,33 @@ function compileLayerIndependent( if (nextReserve <= reserve) break; const delta = nextReserve - reserve; reserve = nextReserve; - if (grow) { - layout0.dimensions = { ...layout0.dimensions, height: layout0.dimensions.height + delta }; - break; - } + if (grow) continue; if (options.frozenChartArea) break; area.height = Math.max(1, area.height - delta); } // The compiler places right legends next to the plot. In this assembly // the right axis and endpoint column occupy that space first. const legendShift = rightReserve + ENDPOINT_COLUMN_GAP + endpointWidth; - layout0 = moveRightLegends(layout0, legendShift); - layout1 = moveRightLegends(layout1, legendShift); + // Each leaf already places its legend after its own endpoint column. The + // assembled figure adds the right axis and the widest merged column. + const localColumnReserve = (layout: ChartLayout) => + layout.endpointLabels?.entries.length + ? ENDPOINT_COLUMN_GAP + layout.endpointLabels.bounds.width + : 0; + layout0 = moveRightLegends(layout0, legendShift - localColumnReserve(layout0)); + layout1 = moveRightLegends(layout1, legendShift - localColumnReserve(layout1)); + } + + // Without endpoint columns the secondary leaf still needs the primary's + // stable note band. It owns no annotations, so an independent compile would + // otherwise stretch its scale below the assembled plot. + if (!columns.length && noteReserve(layout0) > 0) { + const reserve = noteReserve(layout0); + layout1 = compileChart(primary1, { + ...optionsWithReserve, + frozenChartArea: layout0.area, + footnoteReserve: reserve, + }); } const y2Axis = layout1.axes.y @@ -622,10 +651,13 @@ function compileLayerIndependent( } function noteReserve(layout: ChartLayout): number { - return footnoteBandHeight( - annotationNoteLineCount(layout.chrome.footnotes ?? []) + - (layout.chrome.seriesSummary?.reduce((count, entry) => count + entry.lines.length, 0) ?? 0), - layout.theme, + return ( + layout.chrome.noteBandHeight ?? + computeNoteBandHeight( + annotationNoteLineCount(layout.chrome.footnotes ?? []) + + (layout.chrome.seriesSummary?.reduce((count, entry) => count + entry.lines.length, 0) ?? 0), + layout.theme, + ) ); } diff --git a/packages/engine/src/compile/spec-sugar.ts b/packages/engine/src/compile/spec-sugar.ts index 8d751879..fc04f50f 100644 --- a/packages/engine/src/compile/spec-sugar.ts +++ b/packages/engine/src/compile/spec-sugar.ts @@ -1,3 +1,4 @@ +import { collectMarkOptionWarnings } from '../compiler/mark-options'; /** * Pre-validation spec sugar expansion. * @@ -1018,8 +1019,10 @@ function expandValueFormatSugar( export function expandSpecSugar( spec: Record, warnings: string[] = [], + markOptionWarnings = true, ): Record { if (!spec || typeof spec !== 'object' || Array.isArray(spec)) return spec; + if (markOptionWarnings) warnings.push(...collectMarkOptionWarnings(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 new file mode 100644 index 00000000..7184b074 --- /dev/null +++ b/packages/engine/src/compiler/__tests__/mark-options.test.ts @@ -0,0 +1,117 @@ +import { describe, expect, it } from 'vitest'; +import { expandSpecSugar } from '../../compile/spec-sugar'; +import { collectMarkOptionWarnings } from '../mark-options'; + +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'); + } + } + }); + 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( + { + layer: [ + { + layer: [ + { + mark: { type: 'density', binCount: 8 }, + 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.', + ]); +}); + +it('accepts waffle corner radius, which the renderer consumes', () => { + expect(collectMarkOptionWarnings({ mark: { type: 'waffle', cornerRadius: 3 } })).toEqual([]); +}); diff --git a/packages/engine/src/compiler/index.ts b/packages/engine/src/compiler/index.ts index 458f54e0..12b0bfe6 100644 --- a/packages/engine/src/compiler/index.ts +++ b/packages/engine/src/compiler/index.ts @@ -17,7 +17,7 @@ import { validateSpec } from './validate'; * @returns CompileResult with the normalized spec and any warnings. * @throws Error if the spec is invalid. */ -export function compile(spec: unknown): CompileResult { +export function compile(spec: unknown, options?: { markOptionWarnings?: boolean }): CompileResult { const validation = validateSpec(spec); if (!validation.valid || !validation.normalized) { @@ -25,7 +25,8 @@ export function compile(spec: unknown): CompileResult { throw new Error(`Invalid spec:\n${errorMessages}`); } - const warnings: string[] = []; + const warnings: string[] = + options?.markOptionWarnings === false ? [] : [...(validation.warnings ?? [])]; const normalized = normalizeSpec(validation.normalized, warnings); return { spec: normalized, warnings }; diff --git a/packages/engine/src/compiler/mark-options.ts b/packages/engine/src/compiler/mark-options.ts new file mode 100644 index 00000000..b4999f5c --- /dev/null +++ b/packages/engine/src/compiler/mark-options.ts @@ -0,0 +1,57 @@ +import type { MarkDef, 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> = { + point: ['line', 'area', 'density'], + interpolate: ['line', 'area', 'density'], + orient: ['bar', 'histogram'], + cornerRadius: ['bar', 'rect', 'histogram', 'waffle'], + 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'], +}; + +/** Run before sugar: diagnostics point to authored fields, including stripped histogram/density options. */ +export function collectMarkOptionWarnings(input: unknown, path = ''): string[] { + if (!input || typeof input !== 'object' || Array.isArray(input)) return []; + const spec = input as Record; + const warnings: string[] = []; + if (spec.mark && typeof spec.mark === 'object' && !Array.isArray(spec.mark)) { + const mark = spec.mark as MarkDef; + 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 (Array.isArray(spec.layer)) + spec.layer.forEach((child, i) => { + warnings.push(...collectMarkOptionWarnings(child, `${path}/layer/${i}`)); + }); + return warnings; +} diff --git a/packages/engine/src/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index 1751033d..b5d0c67e 100644 --- a/packages/engine/src/compiler/normalize.ts +++ b/packages/engine/src/compiler/normalize.ts @@ -174,7 +174,10 @@ function inferEncodingTypes(encoding: Encoding, data: DataRow[], warnings: strin // --------------------------------------------------------------------------- /** Apply default styles to annotations that don't have them. */ -function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation[] { +function normalizeAnnotations( + annotations: Annotation[] | undefined, + warnings: string[], +): Annotation[] { if (!annotations || annotations.length === 0) return []; // The spec-sugar pass rewrites the deprecated `type: 'rule'` annotation @@ -183,6 +186,14 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation return annotations.map((ann) => { switch (ann.type) { case 'text': + if ( + ann.connector !== null && + typeof ann.connector === 'object' && + ann.connector.type !== 'curve' && + ann.connector.bend !== undefined + ) { + 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 @@ -193,18 +204,18 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation ...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. case 'range': return { ...ann, opacity: ann.opacity ?? 0.1, - fill: ann.fill ?? '#000000', }; case 'refline': return { ...ann, style: ann.style ?? 'dashed', strokeWidth: ann.strokeWidth ?? 1, - stroke: ann.stroke ?? '#666666', opacity: ann.opacity ?? 0.8, }; default: @@ -491,10 +502,12 @@ function normalizeChartSpec(spec: ChartSpec, warnings: string[]): NormalizedChar metrics: spec.metrics, seriesSearch: normalizeSeriesSearch(spec, encoding, warnings), youDrawIt: normalizeYouDrawIt(spec), - annotations: normalizeAnnotations(spec.annotations), + annotations: normalizeAnnotations(spec.annotations, warnings), labels: normalizeLabels(spec.labels), legend: spec.legend, endpointLabels: spec.endpointLabels, + compactAnnotationLimit: + typeof spec.responsive === 'object' ? spec.responsive.compactAnnotationLimit : undefined, responsive: typeof spec.responsive === 'object' ? true : (spec.responsive ?? true), autoThin: typeof spec.responsive === 'object' @@ -621,7 +634,7 @@ function normalizeGraphSpec(spec: GraphSpec, warnings: string[]): NormalizedGrap nodeOverrides: spec.nodeOverrides, seedNode, chrome: normalizeChrome(spec.chrome), - annotations: normalizeAnnotations(spec.annotations), + annotations: normalizeAnnotations(spec.annotations, warnings), theme: spec.theme ?? {}, darkMode: spec.darkMode ?? 'off', watermark: spec.watermark ?? true, diff --git a/packages/engine/src/compiler/types.ts b/packages/engine/src/compiler/types.ts index 665d566c..8fd1b539 100644 --- a/packages/engine/src/compiler/types.ts +++ b/packages/engine/src/compiler/types.ts @@ -137,6 +137,7 @@ export interface NormalizedChartSpec { responsive: boolean; /** Whether auto-thinning of annotations at narrow widths is enabled. Resolved from `responsive.autoThin`. */ autoThin: boolean; + compactAnnotationLimit?: number | null; theme: ThemeConfig; darkMode: DarkMode; /** Whether the OpenData watermark is enabled. */ @@ -247,6 +248,8 @@ export interface ValidationError { /** Result of spec validation. */ export interface ValidationResult { + /** Advisory authored-option warnings; incompatible options remain accepted in 8.x. */ + warnings?: string[]; /** Whether the spec is valid. */ valid: boolean; /** Validation errors (empty if valid). */ diff --git a/packages/engine/src/compiler/validate.ts b/packages/engine/src/compiler/validate.ts index 0bfdb895..423a9e7c 100644 --- a/packages/engine/src/compiler/validate.ts +++ b/packages/engine/src/compiler/validate.ts @@ -1,3 +1,4 @@ +import { collectMarkOptionWarnings } from './mark-options'; /** * Runtime spec validation. * @@ -2349,7 +2350,71 @@ function validateFieldMetadata(obj: Record, errors: ValidationE * field type compatibility. Returns structured errors with machine-readable * codes and actionable suggestions for each problem found. */ -export function validateSpec(spec: unknown): ValidationResult { +function validateAnnotationLayout( + spec: Record, + errors: ValidationError[], + prefix = '', +): void { + const responsive = spec.responsive as Record | undefined; + const limit = + responsive && typeof responsive === 'object' ? responsive.compactAnnotationLimit : undefined; + if (limit != null && (typeof limit !== 'number' || !Number.isInteger(limit) || limit < 0)) { + errors.push({ + code: 'INVALID_VALUE', + path: `${prefix}responsive.compactAnnotationLimit`, + message: 'compactAnnotationLimit must be a nonnegative integer or null', + suggestion: 'Use null for unlimited inline annotations.', + }); + } + if (Array.isArray(spec.annotations)) { + for (const [index, annotation] of spec.annotations.entries()) { + for (const field of ['maxWidth', 'subtitleFontSize'] as const) { + const value = annotation?.[field]; + if (value === undefined || (field === 'maxWidth' && value === null)) continue; + if (typeof value !== 'number' || !Number.isFinite(value) || value <= 0) + errors.push({ + code: 'INVALID_VALUE', + path: `${prefix}annotations[${index}].${field}`, + message: `${field} must be a positive finite number`, + suggestion: + field === 'maxWidth' + ? 'Use null for unlimited width.' + : 'Omit subtitleFontSize to use the default ratio.', + }); + } + if ( + annotation?.labelPlacement !== undefined && + !['auto', 'start', 'end', 'outside'].includes(annotation.labelPlacement) + ) + errors.push({ + code: 'INVALID_VALUE', + path: `${prefix}annotations[${index}].labelPlacement`, + message: 'Unknown reference label placement', + suggestion: 'Use auto, start, end or outside.', + }); + const bend = annotation?.connector?.bend; + if ( + bend !== undefined && + (typeof bend !== 'number' || !Number.isFinite(bend) || bend < -1 || bend > 1) + ) { + errors.push({ + code: 'INVALID_VALUE', + path: `${prefix}annotations[${index}].connector.bend`, + message: 'Connector bend must be a finite number between -1 and 1', + suggestion: 'Omit bend for the automatic curve, or use a number in [-1, 1].', + }); + } + } + } + + if (Array.isArray(spec.layer)) + for (const [index, child] of spec.layer.entries()) { + if (child && typeof child === 'object' && !Array.isArray(child)) + validateAnnotationLayout(child, errors, `${prefix}layer[${index}].`); + } +} + +function validateSpecShape(spec: unknown): ValidationResult { const errors: ValidationError[] = []; // Basic shape check @@ -2369,6 +2434,7 @@ export function validateSpec(spec: unknown): ValidationResult { } const obj = spec as Record; + validateAnnotationLayout(obj, errors); validateFieldMetadata(obj, errors); if (obj.chrome !== undefined) { const chrome = obj.chrome; @@ -2542,3 +2608,10 @@ export function validateSpec(spec: unknown): ValidationResult { normalized: spec as VizSpec, }; } + +/** Validation warnings remain advisory throughout 8.x. */ +export function validateSpec(spec: unknown): ValidationResult { + const result = validateSpecShape(spec); + const warnings = collectMarkOptionWarnings(spec); + return warnings.length ? { ...result, warnings } : result; +} diff --git a/packages/engine/src/design/diagnostics.ts b/packages/engine/src/design/diagnostics.ts index 349b95f2..ee064203 100644 --- a/packages/engine/src/design/diagnostics.ts +++ b/packages/engine/src/design/diagnostics.ts @@ -2,6 +2,21 @@ import type { ChartLayout, ChartSpec, DesignDiagnostic } from '@opendata-ai/open import { computeLabelBounds, contrastRatio, isOpaqueColor } from '@opendata-ai/openchart-core'; import { resolveObservationIdentities } from './identity'; +/** A fixed figure budget may be exhausted by the stable source-note capacity. */ +export function noteCapacityDiagnostic(layout: ChartLayout): DesignDiagnostic | undefined { + if ((layout.chrome.noteBandHeight ?? 0) <= 0 || layout.area.height > 0) return undefined; + return { + code: 'layout.note-capacity', + path: '/annotations', + origin: 'author', + status: 'failed', + severity: 'error', + viewport: layout.dimensions, + message: + 'Reserved annotation note capacity leaves no plotting space. Increase the requested height, explicitly choose chromeLayout: "grow", or reduce annotation text/count.', + }; +} + /** Checks use resolved geometry and authored metadata, never headline interpretation. */ export function diagnoseChart(spec: ChartSpec, layout: ChartLayout): DesignDiagnostic[] { const diagnostics: DesignDiagnostic[] = []; @@ -120,7 +135,9 @@ export function diagnoseChart(spec: ChartSpec, layout: ChartLayout): DesignDiagn 'author', ); } - if (layout.area.width < 80 || layout.area.height < 80) + const exhaustedNotes = noteCapacityDiagnostic(layout); + if (exhaustedNotes) diagnostics.push(exhaustedNotes); + else if (layout.area.width < 80 || layout.area.height < 80) emit( 'layout.constrained', '/chromeLayout', diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2f67976b..ca0e8838 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -91,8 +91,10 @@ export { } from './annotations/constants'; export type { AnnotationMeasureTextFn, ArrowheadPoints } from './annotations/geometry'; export { + automaticControlPoint, computeArrowheadPoints, heuristicMeasure, + refreshConnector, } from './annotations/geometry'; export type { ObstacleKind, PlacementObstacle } from './annotations/placement'; export type { RichSpan } from './annotations/rich-text'; @@ -181,7 +183,15 @@ export type { TileMapSpec, VizSpec, } from '@opendata-ai/openchart-core'; - +export type { AnnotationEditContext } from './annotations/edit-context'; +export { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, + previewConnectorBend, + previewConnectorOffset, +} from './annotations/edit-context'; +export { interpolateAnnotationGeometry } from './annotations/interpolate'; export { diagnoseChart } from './design/diagnostics'; export type { ObservationComparison } from './design/identity'; export { diff --git a/packages/engine/src/layout/dimensions.ts b/packages/engine/src/layout/dimensions.ts index 82fb1ebc..1c2db272 100644 --- a/packages/engine/src/layout/dimensions.ts +++ b/packages/engine/src/layout/dimensions.ts @@ -1,3 +1,5 @@ +import { heuristicMeasure } from '../annotations/geometry'; +import { referenceLabelReserve } from '../annotations/reference-layout'; /** * Dimension computation for the chart layout. * @@ -190,6 +192,7 @@ export function computeDimensions( strategy: LayoutStrategy | undefined, watermark: boolean, plan: LayoutPlan, + reserveReferenceLabels = true, ): LayoutDimensions { const { width, height } = options; @@ -239,7 +242,14 @@ export function computeDimensions( // Also used below to gate out the metric bar / series-search band, which // are sized for full tiles and would blow the same guardrail at this height. const isCrampedHeight = getHeightClass(height) === 'cramped'; - const chrome = plan.chrome; + const chrome = { ...plan.chrome }; + const referenceReserve = referenceLabelReserve( + reserveReferenceLabels ? spec.annotations : [], + width, + options.measureText + ? (text, font) => options.measureText!(text, font.fontSize, font.fontWeight).width + : heuristicMeasure, + ); // chromeLayout 'grow': the plot keeps the full height budget and the SVG // grows by the chrome height. effectiveHeight is the height the plot-area @@ -249,7 +259,10 @@ export function computeDimensions( // effectiveHeight === height, keeping that path byte-identical. const chromeLayout = resolveChromeLayout(spec, options); const effectiveHeight = - chromeLayout === 'grow' ? height + chrome.topHeight + chrome.bottomHeight : height; + chromeLayout === 'grow' + ? height + chrome.topHeight + chrome.bottomHeight + (options.footnoteReserve ?? 0) + : height; + if (chromeLayout === 'grow') 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 @@ -266,7 +279,7 @@ export function computeDimensions( const margins: Margins = { top: chrome.topHeight + sparkPad.top, right: sparkPad.right, - bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace, + bottom: chrome.bottomHeight + sparkPad.bottom + xAxisSpace + (options.footnoteReserve ?? 0), left: sparkPad.left + yAxisSpace, }; @@ -357,7 +370,8 @@ export function computeDimensions( // subtitle-to-legend gap tight while reserving physical space for ticks // that protrude above the chart area. const margins: Margins = { - top: topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation, + top: + topPad + chrome.topHeight + tentativeMetricsHeight + searchReservation + referenceReserve.top, right: hPad + (isRadial ? hPad : axisMargin), bottom: bottomMargin(chrome.bottomHeight, padding, xAxisHeight), left: hPad + (isRadial ? hPad : axisMargin), @@ -369,9 +383,6 @@ export function computeDimensions( // 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). - // 3. Right-edge text annotations — stack ADDITIVELY on top of (1) and (2) - // so a callout at maxX lands between the chart area and any column to - // its right. const labelDensity = spec.labels.density; const labelsHiddenByStrategy = strategy?.labelMode === 'none'; const seriesCount = countColorSeries(spec); @@ -430,37 +441,9 @@ export function computeDimensions( } } - // (3) Right-edge text annotations. The annotation text and the endpoint - // column occupy the same right-side space, so take the max rather than - // stacking additively. - if ( - strategy?.annotationPosition !== 'tooltip-only' && - spec.annotations.length > 0 && - encoding.x - ) { - const xField = encoding.x.field; - let maxX: string | number | undefined; - for (const row of spec.data) { - const v = row[xField]; - if (v != null && (maxX == null || String(v) >= String(maxX))) maxX = v as string | number; - } - if (maxX != null) { - const maxXStr = String(maxX); - for (const ann of spec.annotations) { - if (ann.type === 'text' && String(ann.x) === maxXStr) { - const textWidth = estimateTextWidth(ann.text, ann.fontSize ?? 11, ann.fontWeight ?? 600); - const dx = ann.offset?.dx ?? 0; - const anchor = ann.anchor ?? 'auto'; - const baseRightExtent = - anchor === 'right' ? textWidth : anchor === 'left' ? 0 : textWidth / 2; - const rightOverflow = Math.max(0, baseRightExtent + dx); - if (rightOverflow > 0) { - margins.right = Math.max(margins.right, hPad + rightOverflow + 12); - } - } - } - } - } + // 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. margins.left = Math.max(margins.left, plan.leftGutter); @@ -526,7 +509,7 @@ export function computeDimensions( // a chart with only one of them reserves exactly what it did before. const colorReserve = colorLegendInGutter ? legendBoundsWidth + 8 : 0; const sizeReserve = sizeLegend ? sizeLegend.width + SIZE_LEGEND_GAP : 0; - margins.right += Math.max(colorReserve, sizeReserve); + margins.right += Math.max(colorReserve, sizeReserve) + referenceReserve.right; // effectiveAxisGap sits between the legend (or chrome, if no legend) and // the chart area. When a top legend is present, the legendGap already @@ -580,7 +563,9 @@ export function computeDimensions( const fallbackEffectiveAxisGap = hasTopLegend ? inlineTickOverhang : fallbackTopAxisGap; const newTop = topPad + fallbackChrome.topHeight + tentativeMetricsHeight + searchReservation; const topDelta = margins.top - newTop; - const newBottom = bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight); + const newBottom = + bottomMargin(fallbackChrome.bottomHeight, padding, xAxisHeight) + + (options.footnoteReserve ?? 0); const bottomDelta = margins.bottom - newBottom; if (topDelta > 0 || bottomDelta > 0) { diff --git a/packages/engine/src/layout/facet.ts b/packages/engine/src/layout/facet.ts index b07ca8d8..c8c5e69c 100644 --- a/packages/engine/src/layout/facet.ts +++ b/packages/engine/src/layout/facet.ts @@ -10,6 +10,7 @@ export interface FacetGridPanel { key: string; area: Rect; headerPos: { x: number; y: number }; + referenceContainer?: Rect; row: number; col: number; } @@ -18,6 +19,7 @@ export interface FacetGridResult { panels: FacetGridPanel[]; columns: number; rows: number; + minimumHeight?: number; } export function autoColumns(panelCount: number, availableWidth: number): number { @@ -36,6 +38,10 @@ export function computeFacetGrid( requestedColumns: number | undefined, totalArea: Rect, outerAxisReservation: { left: number; bottom: number }, + referenceInsets?: { + measure: (width: number) => { right: number; top: number }; + innerAxisWidth: number; + }, ): FacetGridResult { const n = facetValues.length; if (n === 0) return { panels: [], columns: 0, rows: 0 }; @@ -46,7 +52,9 @@ export function computeFacetGrid( // Responsive degradation: reduce columns until panels are wide enough while (columns > 1) { const panelWidth = (usableWidth - (columns - 1) * PANEL_GAP) / columns; - if (panelWidth >= MIN_PANEL_WIDTH) break; + const innerAxis = referenceInsets?.innerAxisWidth ?? 0; + const right = referenceInsets?.measure(panelWidth - innerAxis).right ?? 0; + if (panelWidth - innerAxis - right >= MIN_PANEL_WIDTH) break; columns--; } @@ -56,12 +64,16 @@ export function computeFacetGrid( // applies per-row (as overhead), not just at the grid bottom. const xAxisPerRow = outerAxisReservation.bottom; const usableHeight = totalArea.height; + const topInset = + referenceInsets?.measure(panelWidth - (columns > 1 ? referenceInsets.innerAxisWidth : 0)).top ?? + 0; const panelHeight = Math.max( 0, - (usableHeight - rows * (HEADER_HEIGHT + xAxisPerRow) - (rows - 1) * PANEL_GAP) / rows, + (usableHeight - rows * (HEADER_HEIGHT + topInset + xAxisPerRow) - (rows - 1) * PANEL_GAP) / + rows, ); - const rowStride = HEADER_HEIGHT + panelHeight + xAxisPerRow + PANEL_GAP; + const rowStride = HEADER_HEIGHT + topInset + panelHeight + xAxisPerRow + PANEL_GAP; const panels: FacetGridPanel[] = []; for (let i = 0; i < n; i++) { @@ -70,14 +82,26 @@ export function computeFacetGrid( const x = totalArea.x + outerAxisReservation.left + col * (panelWidth + PANEL_GAP); const y = totalArea.y + row * rowStride; + const leftInset = col > 0 ? (referenceInsets?.innerAxisWidth ?? 0) : 0; + const reserve = referenceInsets?.measure(panelWidth - leftInset); panels.push({ key: facetValues[i], area: { - x, - y: y + HEADER_HEIGHT, - width: panelWidth, + x: x + leftInset, + y: y + HEADER_HEIGHT + topInset, + width: Math.max(0, panelWidth - leftInset - (reserve?.right ?? 0)), height: panelHeight, }, + ...(referenceInsets + ? { + referenceContainer: { + x: x + leftInset, + y: y + HEADER_HEIGHT, + width: panelWidth - leftInset, + height: topInset + panelHeight, + }, + } + : {}), headerPos: { x: x + panelWidth / 2, y: y + HEADER_HEIGHT * 0.6, @@ -87,5 +111,16 @@ export function computeFacetGrid( }); } - return { panels, columns, rows }; + return { + panels, + columns, + rows, + ...(referenceInsets + ? { + minimumHeight: + rows * (MIN_PANEL_HEIGHT + HEADER_HEIGHT + topInset + xAxisPerRow) + + (rows - 1) * PANEL_GAP, + } + : {}), + }; } diff --git a/packages/engine/src/layout/shared.ts b/packages/engine/src/layout/shared.ts index e1bf4297..df7abb51 100644 --- a/packages/engine/src/layout/shared.ts +++ b/packages/engine/src/layout/shared.ts @@ -57,19 +57,12 @@ export const INLINE_TICK_OVERHANG_PAD = 6; * the plot. `'grow'`: the plot keeps the full height budget and the final SVG * grows by the chrome height. * - * Spec-level `chromeLayout` wins over the compile-option default. Faceted specs - * never grow in v1: faceting already grows the SVG for short panels, and - * composing both growth mechanisms is out of scope, so faceted specs are pinned - * to `'subtract'`. + * Spec-level `chromeLayout` wins over the compile-option default. The outer + * chart or facet assembly owns growth exactly once. */ export function resolveChromeLayout( spec: { chromeLayout?: 'subtract' | 'grow'; encoding?: unknown } | undefined, options: Pick | undefined, ): 'subtract' | 'grow' { - const encoding = spec?.encoding as - | { facet?: unknown; row?: unknown; column?: unknown } - | undefined; - const isFaceted = !!(encoding?.facet || encoding?.row || encoding?.column); - if (isFaceted) return 'subtract'; return spec?.chromeLayout ?? options?.chromeLayout ?? 'subtract'; } diff --git a/packages/engine/src/migration/v8.ts b/packages/engine/src/migration/v8.ts index 3b209ef0..9b300912 100644 --- a/packages/engine/src/migration/v8.ts +++ b/packages/engine/src/migration/v8.ts @@ -1,4 +1,5 @@ import type { ChartSpec, LayerSpec } from '@opendata-ai/openchart-core'; +import { legacyEditorial } from '@opendata-ai/openchart-core'; import { buildPrimarySpec } from '../compile/layer'; import { expandSpecSugar } from '../compile/spec-sugar'; import { flattenLayers, normalizeSpec } from '../compiler/normalize'; @@ -128,6 +129,107 @@ 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 (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.', + ); + } + } + // 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); @@ -187,6 +289,9 @@ export function migrateSpecV8(input: T): { spec: T; repairs: SpecRepair[] } { 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, diff --git a/packages/engine/tsconfig.json b/packages/engine/tsconfig.json index d466ea0c..fbe107d6 100644 --- a/packages/engine/tsconfig.json +++ b/packages/engine/tsconfig.json @@ -5,6 +5,13 @@ "outDir": "dist", "rootDir": "src" }, - "include": ["src"], - "exclude": ["node_modules", "dist", "src/**/*.test.ts"] + "include": [ + "src" + ], + "exclude": [ + "node_modules", + "dist", + "src/**/*.test.ts", + "src/**/*.bench.ts" + ] } diff --git a/packages/react/src/__tests__/Chart.test.tsx b/packages/react/src/__tests__/Chart.test.tsx index 29755169..5d39030c 100644 --- a/packages/react/src/__tests__/Chart.test.tsx +++ b/packages/react/src/__tests__/Chart.test.tsx @@ -165,3 +165,32 @@ describe(' renderer prop', () => { }); }); }); + +it.each([ + undefined, + 'subtract', + 'grow', +] as const)('mounts, updates and destroys note sizing mode %s', async (chromeLayout) => { + const spec = { + ...lineSpec, + animation: false as const, + chromeLayout, + annotations: [ + { + type: 'text' as const, + x: '2020-01-01', + y: 10, + text: 'A wrapped note', + maxWidth: 60, + responsive: false as const, + }, + ], + }; + const { container, rerender, unmount } = await renderChart({ spec }); + const initial = container.querySelector('svg')!.getAttribute('viewBox'); + rerender(); + await waitFor(() => expect(container.querySelector('.oc-title')?.textContent).toBe('Changed')); + expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe(initial); + unmount(); + expect(container.querySelector('svg')).toBeNull(); +}); diff --git a/packages/svelte/src/__tests__/Chart.test.ts b/packages/svelte/src/__tests__/Chart.test.ts index 8296f34e..9d50f527 100644 --- a/packages/svelte/src/__tests__/Chart.test.ts +++ b/packages/svelte/src/__tests__/Chart.test.ts @@ -209,3 +209,32 @@ it('reacts to transaction listener presence without enabling all charts for edit await rerender({ spec, onedittransaction: undefined }); expect(cursor()).not.toBe('grab'); }); + +it.each([ + undefined, + 'subtract', + 'grow', +] as const)('mounts, updates and destroys note sizing mode %s', async (chromeLayout) => { + const spec = { + ...lineSpec, + animation: false as const, + chromeLayout, + annotations: [ + { + type: 'text' as const, + x: '2020-01-01', + y: 10, + text: 'A wrapped note', + maxWidth: 60, + responsive: false as const, + }, + ], + }; + const { container, rerender, unmount } = await renderChart({ spec }); + const initial = container.querySelector('svg')!.getAttribute('viewBox'); + await rerender({ spec: { ...spec, chrome: { ...spec.chrome, title: 'Changed' } } }); + await waitFor(() => expect(container.querySelector('.oc-title')?.textContent).toBe('Changed')); + expect(container.querySelector('svg')!.getAttribute('viewBox')).toBe(initial); + unmount(); + expect(container.querySelector('svg')).toBeNull(); +}); diff --git a/packages/vanilla/src/__tests__/annotation-animation.test.ts b/packages/vanilla/src/__tests__/annotation-animation.test.ts new file mode 100644 index 00000000..473f6e41 --- /dev/null +++ b/packages/vanilla/src/__tests__/annotation-animation.test.ts @@ -0,0 +1,86 @@ +import { type ChartSpec, sampleMotion } from '@opendata-ai/openchart-core'; +import { compileChart } from '@opendata-ai/openchart-engine'; +import { describe, expect, it } from 'vitest'; +import { renderChartSVG } from '../svg-renderer'; +import { applyTweenState, snapTweenToFinal } from '../transition/apply'; +import { buildAnnotationTweens } from '../transition/chrome-tweens'; +import { hasAnnotationChanged } from '../transition/gate'; +import type { Tween } from '../transition/types'; + +const spec: ChartSpec = { + mark: 'point', + animation: false, + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + annotations: [ + { + type: 'text', + id: 'callout', + x: 5, + y: 5, + text: 'Heading', + subtitle: 'Subtitle', + anchor: 'left', + offset: { dx: -90, dy: -80 }, + connector: { type: 'curve', bend: 0.5 }, + dot: true, + }, + ], +}; + +describe('annotation transition rendering', () => { + it('keeps an unchanged data marker fixed while the label moves and ends at exact fresh SVG geometry', () => { + const before = compileChart(spec, { width: 600, height: 400 }); + const after = compileChart( + { ...spec, annotations: [{ ...spec.annotations![0], offset: { dx: -30, dy: -20 } }] }, + { width: 600, height: 400 }, + ); + const host = document.createElement('div'); + const svg = renderChartSVG(after, host) as SVGSVGElement; + const final = svg.querySelector('.oc-annotation')!.innerHTML; + const tweens: Tween[] = []; + buildAnnotationTweens(svg, before, after, tweens); + const tween = tweens.find((t) => t.tweenType === 'annotation')!; + expect(tween).toBeDefined(); + applyTweenState(tween, 50, { duration: 100 }, { duration: 100 }, 0, 100); + const group = svg.querySelector('.oc-annotation')!; + expect(group.hasAttribute('transform')).toBe(false); + expect(Number(group.querySelector('.oc-annotation-dot')!.getAttribute('cx'))).toBe( + after.annotations[0].dot!.x, + ); + expect(Number(group.querySelector('.oc-annotation-label')!.getAttribute('x'))).toBeCloseTo( + before.annotations[0].label!.x + + (after.annotations[0].label!.x - before.annotations[0].label!.x) * + sampleMotion({ duration: 100 }, 50).position, + ); + snapTweenToFinal(tween); + expect(group.innerHTML).toBe(final); + }); + it('recognizes connector-only geometry changes at the transition gate', () => { + const before = compileChart(spec, { width: 600, height: 400 }); + const after = compileChart( + { + ...spec, + annotations: [{ ...spec.annotations![0], connector: { type: 'curve', bend: -0.5 } }], + }, + { width: 600, height: 400 }, + ); + expect(before.annotations[0].label!.x).toBe(after.annotations[0].label!.x); + expect(hasAnnotationChanged(before, after)).toBe(true); + }); + it('keeps enter fades and removes previous-only annotations without ghosting', () => { + const before = compileChart({ ...spec, annotations: [] }, { width: 600, height: 400 }); + const after = compileChart(spec, { width: 600, height: 400 }); + const svg = renderChartSVG(after, document.createElement('div')) as SVGSVGElement; + const tweens: Tween[] = []; + buildAnnotationTweens(svg, before, after, tweens); + expect(tweens[0]).toMatchObject({ kind: 'enter', fromOpacity: 0, toOpacity: 1 }); + const removed = renderChartSVG(before, document.createElement('div')) as SVGSVGElement; + const exits: Tween[] = []; + buildAnnotationTweens(removed, after, before, exits); + expect(exits).toEqual([]); + }); +}); diff --git a/packages/vanilla/src/__tests__/annotation-gestures.test.ts b/packages/vanilla/src/__tests__/annotation-gestures.test.ts new file mode 100644 index 00000000..e1fc8cc6 --- /dev/null +++ b/packages/vanilla/src/__tests__/annotation-gestures.test.ts @@ -0,0 +1,192 @@ +import type { ChartSpec, EditTransaction } from '@opendata-ai/openchart-core'; +import { applyEditTransaction } from '@opendata-ai/openchart-core'; +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createMouseEvent } from '../__test-fixtures__/dom'; +import { createChart } from '../mount'; + +const spec: ChartSpec = { + mark: 'point', + animation: false, + responsive: { autoThin: false }, + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + annotations: [ + { + type: 'text', + id: 'a', + x: 10, + y: 5, + text: 'Heading', + subtitle: 'A wider subtitle', + connector: 'curve', + }, + ], +}; +const cleanup: (() => void)[] = []; +afterEach(() => { + for (const fn of cleanup.splice(0)) fn(); + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); +function mount(controlled = false) { + let authored = spec; + let width = 600; + let height = 400; + const container = document.createElement('div'); + document.body.append(container); + container.getBoundingClientRect = () => ({ + width, + height, + x: 0, + y: 0, + left: 0, + top: 0, + right: width, + bottom: height, + toJSON: () => ({}), + }); + const transactions: EditTransaction[] = []; + const chart = createChart(container, spec, { + autoResize: false, + editable: true, + onEditTransaction: (tx) => { + transactions.push(tx); + if (controlled) { + const result = applyEditTransaction(authored, tx); + if (!result.ok) throw new Error('fixture transaction failed'); + authored = result.spec; + chart.update(authored); + } + }, + revision: 1, + }); + cleanup.push(() => chart.destroy()); + const group = () => container.querySelector('.oc-annotation-text')!; + const start = () => { + group().dispatchEvent(createMouseEvent('mousedown', 100, 100)); + document.dispatchEvent(createMouseEvent('mousemove', 140, 120)); + }; + const end = () => document.dispatchEvent(createMouseEvent('mouseup', 140, 120)); + return { + container, + chart, + transactions, + group, + start, + end, + setSize: (w: number, h: number) => { + width = w; + height = h; + }, + }; +} + +describe('annotation gestures', () => { + it('commits explicit anchor, offset and keep-inline as one transaction', () => { + const m = mount(); + m.start(); + m.end(); + expect(m.transactions).toHaveLength(1); + expect(m.transactions[0].ops.map((op) => op.path)).toEqual([ + '/annotations/0/anchor', + '/annotations/0/offset', + '/annotations/0/responsive', + ]); + const result = applyEditTransaction(spec, m.transactions[0]); + expect(result.ok).toBe(true); + }); + it.each([ + 'Escape', + 'pointercancel', + 'touchcancel', + 'replace', + 'revision', + 'width', + ])('%s cancels with no transaction', (path) => { + const m = mount(); + m.start(); + if (path === 'Escape') + document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + else if (path === 'replace') m.chart.update(structuredClone(spec)); + else if (path === 'revision') m.chart.update(spec, { revision: 2 }); + else if (path === 'width') { + m.setSize(500, 400); + m.chart.resize(); + } else document.dispatchEvent(new Event(path)); + m.end(); + expect(m.transactions).toHaveLength(0); + }); + it('a height-only resize keeps the drag active', () => { + const m = mount(); + m.start(); + m.setSize(600, 430); + m.chart.resize(); + m.end(); + expect(m.transactions).toHaveLength(1); + }); + it('a no-op host leaves authored geometry after the keyboard batch commits', () => { + 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('keyup', { key: 'ArrowLeft', bubbles: true })); + expect(m.transactions).toHaveLength(1); + expect(m.group().querySelector('.oc-annotation-label')!.getAttribute('x')).toBe(initial); + }); + it('starts a drag after a pending nudge synchronously updates the host', () => { + const m = mount(true); + m.group().dispatchEvent(createMouseEvent('click')); + m.container.querySelector('svg')!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + altKey: true, + bubbles: true, + }), + ); + m.start(); + m.end(); + expect(m.transactions).toHaveLength(2); + }); + it('resize retains a pending nudge committed synchronously by the host', () => { + const m = mount(true); + m.group().dispatchEvent(createMouseEvent('click')); + const initial = m.chart.layout.annotations[0].label!.x; + m.container.querySelector('svg')!.dispatchEvent( + new KeyboardEvent('keydown', { + key: 'ArrowLeft', + altKey: true, + bubbles: true, + }), + ); + m.chart.resize(); + 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', () => { + 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: 'ArrowDown', + altKey: true, + shiftKey: true, + bubbles: true, + }), + ); + svg.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true })); + expect(m.transactions).toHaveLength(1); + expect(m.transactions[0].ops.find((op) => op.path.endsWith('/responsive'))).toMatchObject({ + value: false, + }); + }); +}); diff --git a/packages/vanilla/src/__tests__/chrome-layout-grow.test.ts b/packages/vanilla/src/__tests__/chrome-layout-grow.test.ts index 4d198839..9581ddc5 100644 --- a/packages/vanilla/src/__tests__/chrome-layout-grow.test.ts +++ b/packages/vanilla/src/__tests__/chrome-layout-grow.test.ts @@ -71,3 +71,67 @@ describe('chromeLayout: grow (rendered SVG)', () => { expect(omitted).toBe(500); }); }); + +it('auto-height notes do not consume the top legend allowance', async () => { + const { createAutoHeightContainer } = await import('../__test-fixtures__/dom'); + const { createChart } = await import('../mount'); + const spec: ChartSpec = { + mark: 'point', + chromeLayout: 'grow', + animation: false, + data: [ + { x: 0, y: 0, s: 'A' }, + { x: 10, y: 10, s: 'B' }, + ], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + color: { field: 's', type: 'nominal' }, + }, + chrome: { title: 'Title' }, + legend: { position: 'top' }, + }; + const chart = createChart(createAutoHeightContainer(800), spec); + const before = chart.layout; + chart.update({ + ...spec, + annotations: [ + { type: 'text', id: 'a', x: 1, y: 1, text: 'One', responsive: false }, + { type: 'text', id: 'b', x: 8, y: 8, text: 'Two', responsive: false }, + ], + }); + const after = chart.layout; + expect(after.area.height).toBeCloseTo(before.area.height, 5); + expect(after.dimensions.height - before.dimensions.height).toBeCloseTo( + after.chrome.noteBandHeight!, + 5, + ); + chart.destroy(); +}); + +it('auto-height facets consume engine growth once across updates', async () => { + const { createAutoHeightContainer } = await import('../__test-fixtures__/dom'); + const { createChart } = await import('../mount'); + const spec: ChartSpec = { + mark: 'point', + chromeLayout: 'grow', + animation: false, + data: Array.from({ length: 6 }, (_, group) => [ + { x: 0, y: 0, group: String(group) }, + { x: 10, y: 10, group: String(group) }, + ]).flat(), + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + facet: { field: 'group', type: 'nominal', columns: 1 }, + }, + chrome: { title: 'Six panels' }, + }; + const expected = compileChart(spec, { width: 390, height: 400 }); + const chart = createChart(createAutoHeightContainer(390), spec); + expect(chart.layout.dimensions.height).toBeCloseTo(expected.dimensions.height); + const initial = chart.layout.dimensions.height; + chart.update(spec); + expect(chart.layout.dimensions.height).toBe(initial); + chart.destroy(); +}); diff --git a/packages/vanilla/src/__tests__/chrome-renderer.test.ts b/packages/vanilla/src/__tests__/chrome-renderer.test.ts index 40df081a..700af43d 100644 --- a/packages/vanilla/src/__tests__/chrome-renderer.test.ts +++ b/packages/vanilla/src/__tests__/chrome-renderer.test.ts @@ -137,3 +137,48 @@ it('uses the engine note lines for rendering, footer spacing, and static export' [...exported.querySelectorAll('.oc-footnotes tspan')].map((span) => span.textContent), ).toEqual([...parent.querySelectorAll('.oc-footnotes tspan')].map((span) => span.textContent)); }); + +it.each([ + 'subtract', + 'grow', +] as const)('keeps exported source and watermark fixed when notes disappear in %s', (chromeLayout) => { + const spec: ChartSpec = { + mark: 'point', + chromeLayout, + data: [ + { x: 0, y: 0 }, + { x: 1, y: 1 }, + ], + encoding: { x: { field: 'x', type: 'quantitative' }, y: { field: 'y', type: 'quantitative' } }, + chrome: { source: 'Source' }, + annotations: Array.from({ length: 4 }, (_, i) => ({ + type: 'text', + id: `note-${i}`, + x: 0.5, + y: 0.5, + text: `A long explanatory callout ${i}`, + subtitle: 'Additional context', + })), + }; + const parse = (spec: ChartSpec) => + new DOMParser().parseFromString( + renderStaticSVG(spec, { width: 390, height: 600 }), + 'image/svg+xml', + ); + const before = parse(spec); + const after = parse({ + ...spec, + annotations: spec.annotations!.map((annotation) => ({ ...annotation, responsive: false })), + }); + expect(before.querySelectorAll('.oc-footnotes').length).toBeGreaterThan(0); + expect(after.querySelectorAll('.oc-footnotes')).toHaveLength(0); + for (const selector of ['.oc-source', '.oc-chrome-ref text']) { + expect(before.querySelector(selector)).not.toBeNull(); + expect(after.querySelector(selector)!.getAttribute('y')).toEqual( + before.querySelector(selector)!.getAttribute('y'), + ); + } + expect(after.documentElement.getAttribute('viewBox')).toEqual( + before.documentElement.getAttribute('viewBox'), + ); +}); diff --git a/packages/vanilla/src/__tests__/drag-cancellation.test.ts b/packages/vanilla/src/__tests__/drag-cancellation.test.ts new file mode 100644 index 00000000..2b7b2ca5 --- /dev/null +++ b/packages/vanilla/src/__tests__/drag-cancellation.test.ts @@ -0,0 +1,69 @@ +import { afterEach, describe, expect, it, vi } from 'vitest'; +import { createDragHandler } from '../interactions/drag-handler'; + +const cleanups: (() => void)[] = []; +afterEach(() => { + for (const cleanup of cleanups.splice(0)) cleanup(); + document.body.replaceChildren(); + vi.restoreAllMocks(); +}); +function setup() { + const svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg'); + const element = document.createElementNS(svg.namespaceURI, 'g') as SVGGElement; + svg.append(element); + document.body.append(svg); + const onEnd = vi.fn(); + const onMove = vi.fn(); + const onCancel = vi.fn(); + const setDragging = vi.fn(); + const cleanup = createDragHandler({ + svg, + element, + onEnd, + onMove, + onCancel, + setDragging, + animationFrame: true, + }); + cleanups.push(cleanup); + element.dispatchEvent(new MouseEvent('mousedown', { clientX: 10, clientY: 10 })); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 30, clientY: 20 })); + return { svg, element, cleanup, onEnd, onMove, onCancel, setDragging }; +} + +describe('drag cancellation', () => { + it.each([ + 'pointercancel', + 'touchcancel', + 'escape', + 'replacement', + 'cleanup', + ])('%s emits no edit and restores interaction state', (path) => { + const drag = setup(); + if (path === 'escape') document.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape' })); + else if (path === 'replacement') drag.svg.dispatchEvent(new Event('oc-cancel-drag')); + else if (path === 'cleanup') drag.cleanup(); + else document.dispatchEvent(new Event(path)); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 40, clientY: 40 })); + expect(drag.onEnd).not.toHaveBeenCalled(); + expect(drag.onCancel).toHaveBeenCalledOnce(); + expect(drag.svg.style.userSelect).toBe(''); + expect(drag.element.style.cursor).toBe('grab'); + expect(drag.setDragging).toHaveBeenLastCalledWith(false); + }); + it('coalesces previews into one animation frame and commits the final pointer coordinates', () => { + let frame: FrameRequestCallback | undefined; + vi.spyOn(window, 'requestAnimationFrame').mockImplementation((callback) => { + frame = callback; + return 1; + }); + vi.spyOn(window, 'cancelAnimationFrame').mockImplementation(() => {}); + const drag = setup(); + document.dispatchEvent(new MouseEvent('mousemove', { clientX: 40, clientY: 30 })); + expect(drag.onMove).not.toHaveBeenCalled(); + frame!(0); + expect(drag.onMove).toHaveBeenCalledExactlyOnceWith(30, 20); + document.dispatchEvent(new MouseEvent('mouseup', { clientX: 50, clientY: 40 })); + expect(drag.onEnd).toHaveBeenCalledExactlyOnceWith(40, 30, true); + }); +}); diff --git a/packages/vanilla/src/__tests__/edit-events.test.ts b/packages/vanilla/src/__tests__/edit-events.test.ts index e83a2cbf..f03ecd82 100644 --- a/packages/vanilla/src/__tests__/edit-events.test.ts +++ b/packages/vanilla/src/__tests__/edit-events.test.ts @@ -169,9 +169,11 @@ describe('edit events', () => { const edit: ElementEdit = onEdit.mock.calls[0][0]; expect(edit.type).toBe('annotation'); if (edit.type === 'annotation') { - // Original offset (10, -20) + drag delta (50, 30) = (60, 10) + // Canonical placement converts the implicit diagonal anchor into explicit right. expect(edit.offset.dx).toBe(60); - expect(edit.offset.dy).toBe(10); + 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'); } @@ -341,7 +343,9 @@ describe('edit events', () => { if (edit.type === 'annotation') { expect(edit.annotation.text).toBe('Peak'); expect(edit.offset.dx).toBe(60); - expect(edit.offset.dy).toBe(10); + expect(edit.offset.dy).toBe(-18); + expect(edit.anchor).toBe('right'); + expect(edit.responsive).toBe(false); } chart.destroy(); @@ -1218,7 +1222,13 @@ describe('edit events', () => { 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[0].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', + ], + ); chart.destroy(); }); diff --git a/packages/vanilla/src/__tests__/events.test.ts b/packages/vanilla/src/__tests__/events.test.ts index 81bffaa2..7ea1780a 100644 --- a/packages/vanilla/src/__tests__/events.test.ts +++ b/packages/vanilla/src/__tests__/events.test.ts @@ -315,7 +315,7 @@ describe('chart event handlers', () => { chart.destroy(); }); - it('applies transform during drag', () => { + it('moves label geometry without translating the data anchor during drag', async () => { const onAnnotationEdit = vi.fn(); const chart = createChart(container, textAnnotatedSpec, { onAnnotationEdit }); @@ -325,12 +325,15 @@ describe('chart event handlers', () => { return; } + const startX = Number(annotation.querySelector('.oc-annotation-label')!.getAttribute('x')); annotation.dispatchEvent(createMouseEvent('mousedown', 100, 100)); document.dispatchEvent(createMouseEvent('mousemove', 140, 120)); - // During drag, the annotation group should have a transform - const transform = annotation.getAttribute('transform'); - expect(transform).toContain('translate'); + 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)); diff --git a/packages/vanilla/src/__tests__/note-band-compat.test.ts b/packages/vanilla/src/__tests__/note-band-compat.test.ts new file mode 100644 index 00000000..88d0ed98 --- /dev/null +++ b/packages/vanilla/src/__tests__/note-band-compat.test.ts @@ -0,0 +1,39 @@ +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 { renderChartSVG } from '../svg-renderer'; + +it('normal rendering does not call the deprecated adapter, which warns once when used', () => { + const warn = vi.spyOn(console, 'warn').mockImplementation(() => {}); + try { + const layout = compileChart( + { + mark: 'point', + data: [ + { x: 0, y: 0 }, + { x: 10, y: 10 }, + ], + encoding: { + x: { field: 'x', type: 'quantitative' }, + y: { field: 'y', type: 'quantitative' }, + }, + responsive: { compactAnnotationLimit: 0 }, + annotations: [{ type: 'text', id: 'a', x: 5, y: 5, text: 'A note' }], + }, + { width: 390, height: 400 }, + ); + renderChartSVG(layout, createContainer()); + 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); + } finally { + warn.mockRestore(); + document.body.innerHTML = ''; + } +}); diff --git a/packages/vanilla/src/__tests__/update-scrolly-matrix.test.ts b/packages/vanilla/src/__tests__/update-scrolly-matrix.test.ts index 655a2e9e..2ebe0be1 100644 --- a/packages/vanilla/src/__tests__/update-scrolly-matrix.test.ts +++ b/packages/vanilla/src/__tests__/update-scrolly-matrix.test.ts @@ -544,7 +544,7 @@ describe('annotation steps through update()', () => { chart.destroy(); }); - it('annotation move tweens via a transform offset and clears it on completion', () => { + it('annotation move samples label geometry and settles at the final position', () => { const { chart, svg } = mountChart(columnSpec(COL_A, { annotations: [NOTE] })); chart.update(columnSpec(COL_A, { annotations: [{ ...NOTE, x: 'Q3', y: 120 }] })); @@ -552,13 +552,17 @@ describe('annotation steps through update()', () => { const annotation = svg().querySelector('.oc-annotation') as SVGElement; pumpRaf(0); pumpRaf(250); - // Mid-flight: sliding from the old position via an SVG transform. - expect(annotation.getAttribute('transform')).toMatch(/translate/); + // Positions are sampled independently, without moving the entire group. + const midX = Number(annotation.querySelector('.oc-annotation-label')!.getAttribute('x')); + expect(annotation.hasAttribute('transform')).toBe(false); // A moved annotation is the same annotation: it must not re-fade. expect(annotation.style.opacity).not.toBe('0'); pumpRaf(5000); - // Completion drops the offset so the DOM matches what render() produced. + expect(Number(annotation.querySelector('.oc-annotation-label')!.getAttribute('x'))).not.toBe( + midX, + ); + // Completion matches what render() produced. expect(annotation.hasAttribute('transform')).toBe(false); chart.destroy(); diff --git a/packages/vanilla/src/interactions/annotation-nudges.ts b/packages/vanilla/src/interactions/annotation-nudges.ts new file mode 100644 index 00000000..6f25f74f --- /dev/null +++ b/packages/vanilla/src/interactions/annotation-nudges.ts @@ -0,0 +1,97 @@ +import type { + ChartLayout, + ElementEdit, + ElementRef, + ResolvedAnnotation, + TextAnnotation, +} from '@opendata-ai/openchart-core'; +import { annotationSource } from '@opendata-ai/openchart-core'; +import { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, +} from '@opendata-ai/openchart-engine'; +import { updateAnnotationGeometry } from '../renderers/annotations'; +import { findElementByRef } from './selection'; + +/** Batch keyboard repeats into one placement transaction; flush before other gestures. */ +export function createAnnotationNudges( + read: () => { + layout: ChartLayout; + spec: unknown; + svg: SVGElement | null; + selected: ElementRef | null; + }, + emit: (edit: ElementEdit) => void, +) { + let pending: + | { + ctx: ReturnType; + ref: ElementRef; + authored: TextAnnotation; + original: ResolvedAnnotation; + dx: number; + dy: number; + } + | undefined; + let timer: ReturnType | undefined; + const flush = () => { + if (timer !== undefined) clearTimeout(timer); + timer = undefined; + const batch = pending; + pending = undefined; + if (!batch) return; + const preview = previewAnnotationMove(batch.ctx, batch.ref, batch); + const placement = inverseLabelPlacement(batch.ctx, batch.ref, preview.bounds!); + const { svg, layout } = read(); + const target = svg && findElementByRef(svg, batch.ref); + if (target) + updateAnnotationGeometry(target, batch.original, layout.theme.colors.neutral.surface); + emit({ + type: 'annotation', + element: batch.ref, + annotation: batch.authored, + offset: placement.offset!, + anchor: placement.anchor, + responsive: false, + }); + }; + const keydown = (event: KeyboardEvent): boolean => { + if (!event.altKey || !['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown'].includes(event.key)) + return false; + const { layout, spec, svg, selected } = read(); + if (!svg || selected?.type !== 'annotation') return false; + const authored = annotationSource(spec).annotations[selected.index]; + if (authored?.type !== 'text') return false; + if (!pending) { + const ctx = createAnnotationEditContext(layout, spec); + try { + previewAnnotationMove(ctx, selected, { dx: 0, dy: 0 }); + } catch { + return false; + } + const annotations = + selected.facetInstance !== undefined + ? layout.facet?.panels.find((panel) => panel.key === selected.facetInstance)?.annotations + : layout.annotations; + const original = annotations?.find((annotation) => annotation.specIndex === selected.index); + if (!original) return false; + pending = { ctx, ref: selected, authored, original, dx: 0, dy: 0 }; + } + event.preventDefault(); + const step = event.shiftKey ? 10 : 1; + pending.dx += event.key === 'ArrowLeft' ? -step : event.key === 'ArrowRight' ? step : 0; + pending.dy += event.key === 'ArrowUp' ? -step : event.key === 'ArrowDown' ? step : 0; + const target = findElementByRef(svg, pending.ref); + if (target) + updateAnnotationGeometry( + target, + previewAnnotationMove(pending.ctx, pending.ref, pending), + layout.theme.colors.neutral.surface, + ); + if (timer !== undefined) clearTimeout(timer); + timer = setTimeout(flush, 120); + return true; + }; + return { keydown, flush }; +} diff --git a/packages/vanilla/src/interactions/drag-handler.ts b/packages/vanilla/src/interactions/drag-handler.ts index 5b735824..a2cf245a 100644 --- a/packages/vanilla/src/interactions/drag-handler.ts +++ b/packages/vanilla/src/interactions/drag-handler.ts @@ -1,175 +1,123 @@ -/** - * Reusable drag handler for SVG elements. - * Handles mouse and touch events, viewBox scaling, threshold detection, - * click suppression after drag, and cursor state. - */ - +/** Mouse/touch drag lifecycle, with one cancellation path and optional frame-coalesced previews. */ export interface DragConfig { element: SVGElement; svg: SVGSVGElement; onMove: (dx: number, dy: number) => void; onEnd: (dx: number, dy: number, moved: boolean) => void; + onCancel?: () => void; + onStart?: () => void; setDragging: (dragging: boolean) => void; threshold?: number; + animationFrame?: boolean; } export function createDragHandler(config: DragConfig): () => void { const { element, svg, onMove, onEnd, setDragging, threshold = 3 } = config; - const cleanups: Array<() => void> = []; - - let activeDocMouseMove: ((e: MouseEvent) => void) | null = null; - let activeDocMouseUp: ((e: MouseEvent) => void) | null = null; - let activeDocTouchMove: ((e: TouchEvent) => void) | null = null; - let activeDocTouchEnd: ((e: TouchEvent) => void) | null = null; - let activeDocTouchCancel: ((e: TouchEvent) => void) | null = null; - - function getScale(): { scaleX: number; scaleY: number } { - const viewBox = svg.viewBox?.baseVal; - const svgRect = svg.getBoundingClientRect(); - return { - scaleX: viewBox?.width && svgRect.width ? viewBox.width / svgRect.width : 1, - scaleY: viewBox?.height && svgRect.height ? viewBox.height / svgRect.height : 1, - }; - } + let cancelActive: (() => void) | undefined; function startDrag(startX: number, startY: number): void { + cancelActive?.(); + config.onStart?.(); setDragging(true); - const { scaleX, scaleY } = getScale(); - + const viewBox = svg.viewBox?.baseVal; + const rect = svg.getBoundingClientRect(); + const scaleX = viewBox?.width && rect.width ? viewBox.width / rect.width : 1; + const scaleY = viewBox?.height && rect.height ? viewBox.height / rect.height : 1; element.style.cursor = 'grabbing'; svg.style.userSelect = 'none'; - - const handleMove = (clientX: number, clientY: number) => { - const dx = (clientX - startX) * scaleX; - const dy = (clientY - startY) * scaleY; - onMove(dx, dy); + let frame: number | undefined; + let dx = 0; + let dy = 0; + let active = true; + const preview = () => { + frame = undefined; + if (active) onMove(dx, dy); }; - - const cleanupDocListeners = () => { - if (activeDocMouseMove) { - document.removeEventListener('mousemove', activeDocMouseMove); - activeDocMouseMove = null; - } - if (activeDocMouseUp) { - document.removeEventListener('mouseup', activeDocMouseUp); - activeDocMouseUp = null; - } - if (activeDocTouchMove) { - document.removeEventListener('touchmove', activeDocTouchMove); - activeDocTouchMove = null; - } - if (activeDocTouchEnd) { - document.removeEventListener('touchend', activeDocTouchEnd); - activeDocTouchEnd = null; - } - if (activeDocTouchCancel) { - document.removeEventListener('touchcancel', activeDocTouchCancel); - activeDocTouchCancel = null; - } + const move = (x: number, y: number) => { + dx = (x - startX) * scaleX; + dy = (y - startY) * scaleY; + if (!config.animationFrame) preview(); + else if (frame === undefined) frame = requestAnimationFrame(preview); }; - - const handleEnd = (clientX: number, clientY: number) => { - const dx = (clientX - startX) * scaleX; - const dy = (clientY - startY) * scaleY; - const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold; - - onEnd(dx, dy, moved); - - if (moved) { - element.addEventListener( - 'click', - (clickE) => { - clickE.stopPropagation(); - }, - { capture: true, once: true }, - ); - } - + const detach = () => { + active = false; + if (frame !== undefined) cancelAnimationFrame(frame); + frame = undefined; + document.removeEventListener('mousemove', mouseMove); + document.removeEventListener('mouseup', mouseUp); + document.removeEventListener('touchmove', touchMove); + document.removeEventListener('touchend', touchEnd); + document.removeEventListener('touchcancel', cancel); + document.removeEventListener('pointercancel', cancel); + document.removeEventListener('keydown', keyDown, true); + svg.removeEventListener('oc-cancel-drag', cancel); element.style.cursor = 'grab'; svg.style.userSelect = ''; - - cleanupDocListeners(); + cancelActive = undefined; + }; + const cancel = () => { + if (!active) return; + detach(); + config.onCancel?.(); setDragging(false); }; - - const onMouseMove = (moveEvent: MouseEvent) => { - handleMove(moveEvent.clientX, moveEvent.clientY); + const end = (x: number, y: number) => { + if (!active) return; + dx = (x - startX) * scaleX; + dy = (y - startY) * scaleY; + const moved = Math.abs(dx) > threshold || Math.abs(dy) > threshold; + detach(); + onEnd(dx, dy, moved); + if (moved) + element.addEventListener('click', (event) => event.stopPropagation(), { + capture: true, + once: true, + }); + setDragging(false); }; - const onMouseUp = (upEvent: MouseEvent) => { - handleEnd(upEvent.clientX, upEvent.clientY); + const mouseMove = (event: MouseEvent) => move(event.clientX, event.clientY); + const mouseUp = (event: MouseEvent) => end(event.clientX, event.clientY); + const touchMove = (event: TouchEvent) => { + if (!event.touches.length) return; + event.preventDefault(); + move(event.touches[0].clientX, event.touches[0].clientY); }; - document.addEventListener('mousemove', onMouseMove); - document.addEventListener('mouseup', onMouseUp); - activeDocMouseMove = onMouseMove; - activeDocMouseUp = onMouseUp; - - const onTouchMove = (moveEvent: TouchEvent) => { - if (moveEvent.touches.length > 0) { - moveEvent.preventDefault(); - handleMove(moveEvent.touches[0].clientX, moveEvent.touches[0].clientY); - } + const touchEnd = (event: TouchEvent) => { + const touch = event.changedTouches[0]; + end(touch?.clientX ?? startX, touch?.clientY ?? startY); }; - const onTouchEnd = (endEvent: TouchEvent) => { - const touch = endEvent.changedTouches[0]; - if (touch) { - handleEnd(touch.clientX, touch.clientY); - } else { - handleEnd(startX, startY); + const keyDown = (event: KeyboardEvent) => { + if (event.key === 'Escape') { + event.preventDefault(); + cancel(); } }; - document.addEventListener('touchmove', onTouchMove, { passive: false }); - document.addEventListener('touchend', onTouchEnd); - document.addEventListener('touchcancel', onTouchEnd); - activeDocTouchMove = onTouchMove; - activeDocTouchEnd = onTouchEnd; - activeDocTouchCancel = onTouchEnd; + document.addEventListener('mousemove', mouseMove); + document.addEventListener('mouseup', mouseUp); + document.addEventListener('touchmove', touchMove, { passive: false }); + document.addEventListener('touchend', touchEnd); + document.addEventListener('touchcancel', cancel); + document.addEventListener('pointercancel', cancel); + document.addEventListener('keydown', keyDown, true); + svg.addEventListener('oc-cancel-drag', cancel); + cancelActive = cancel; } - const handleMouseDown = (e: Event) => { - const mouseEvent = e as MouseEvent; - mouseEvent.preventDefault(); - startDrag(mouseEvent.clientX, mouseEvent.clientY); + const mouseDown = (event: MouseEvent) => { + if (event.button !== 0) return; + event.preventDefault(); + startDrag(event.clientX, event.clientY); }; - - const handleTouchStart = (e: Event) => { - const touchEvent = e as TouchEvent; - if (touchEvent.touches.length === 1) { - touchEvent.preventDefault(); - startDrag(touchEvent.touches[0].clientX, touchEvent.touches[0].clientY); - } + const touchStart = (event: TouchEvent) => { + if (event.touches.length !== 1) return; + event.preventDefault(); + startDrag(event.touches[0].clientX, event.touches[0].clientY); }; - - element.addEventListener('mousedown', handleMouseDown); - element.addEventListener('touchstart', handleTouchStart, { passive: false }); - cleanups.push(() => { - element.removeEventListener('mousedown', handleMouseDown); - element.removeEventListener('touchstart', handleTouchStart); - }); - + element.addEventListener('mousedown', mouseDown); + element.addEventListener('touchstart', touchStart, { passive: false }); return () => { - for (const cleanup of cleanups) { - cleanup(); - } - if (activeDocMouseMove) { - document.removeEventListener('mousemove', activeDocMouseMove); - activeDocMouseMove = null; - } - if (activeDocMouseUp) { - document.removeEventListener('mouseup', activeDocMouseUp); - activeDocMouseUp = null; - } - if (activeDocTouchMove) { - document.removeEventListener('touchmove', activeDocTouchMove); - activeDocTouchMove = null; - } - if (activeDocTouchEnd) { - document.removeEventListener('touchend', activeDocTouchEnd); - activeDocTouchEnd = null; - } - if (activeDocTouchCancel) { - document.removeEventListener('touchcancel', activeDocTouchCancel); - activeDocTouchCancel = null; - } - svg.style.userSelect = ''; + element.removeEventListener('mousedown', mouseDown); + element.removeEventListener('touchstart', touchStart); + cancelActive?.(); }; } diff --git a/packages/vanilla/src/interactions/edit-transactions.ts b/packages/vanilla/src/interactions/edit-transactions.ts index 2aeda7d9..c183af7d 100644 --- a/packages/vanilla/src/interactions/edit-transactions.ts +++ b/packages/vanilla/src/interactions/edit-transactions.ts @@ -1,4 +1,4 @@ -import type { Annotation, EditOp, EditTransaction, ElementEdit } from '@opendata-ai/openchart-core'; +import type { EditOp, EditTransaction, ElementEdit } from '@opendata-ai/openchart-core'; type Node = Record; const object = (value: unknown): Node | undefined => @@ -6,29 +6,9 @@ const object = (value: unknown): Node | undefined => ? (value as Node) : undefined; -/** Matches layer compilation's parent-or-first-leaf annotation ownership. */ -export function annotationSource(spec: unknown): { annotations: Annotation[]; path: string } { - const root = object(spec); - if (Array.isArray(root?.annotations)) - return { annotations: root.annotations, path: '/annotations' }; - function firstLeaf( - node: Node | undefined, - path: string, - ): { annotations: Annotation[]; path: string } | undefined { - if (Array.isArray(node?.layer)) { - for (const [index, child] of node.layer.entries()) { - const result = firstLeaf(object(child), `${path}/layer/${index}`); - if (result) return result; - } - } else if (node && 'mark' in node) { - return { - annotations: Array.isArray(node.annotations) ? node.annotations : [], - path: `${path}/annotations`, - }; - } - } - return firstLeaf(root, '') ?? { annotations: [], path: '/annotations' }; -} +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. */ export function editToTransaction( @@ -67,7 +47,9 @@ export function editToTransaction( const path = `${source.path}/${edit.element.index}`; switch (edit.type) { case 'annotation': + if (edit.anchor !== undefined) set(`${path}/anchor`, edit.anchor, expected); set(`${path}/offset`, edit.offset, expected); + if (edit.responsive !== undefined) set(`${path}/responsive`, edit.responsive, expected); break; case 'annotation-connector': set( diff --git a/packages/vanilla/src/interactions/editing-drags.ts b/packages/vanilla/src/interactions/editing-drags.ts index eaac009f..39ed181c 100644 --- a/packages/vanilla/src/interactions/editing-drags.ts +++ b/packages/vanilla/src/interactions/editing-drags.ts @@ -9,10 +9,19 @@ import { type GraphSpec, type RangeAnnotation, type RefLineAnnotation, + type ResolvedAnnotation, type TextAnnotation, } from '@opendata-ai/openchart-core'; +import { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, + previewConnectorOffset, +} from '@opendata-ai/openchart-engine'; +import { updateAnnotationGeometry } from '../renderers/annotations'; import { createDragHandler } from './drag-handler'; import { invertScale } from './invert'; +import { buildElementRef } from './selection'; /** * Wire drag-to-reposition on text annotation labels. @@ -26,91 +35,70 @@ export function wireAnnotationDrag( | undefined, onEdit: ((edit: ElementEdit) => void) | undefined, setDragging: (dragging: boolean) => void, + layout: ChartLayout, + spec: unknown, ): () => void { - const annotationElements = svg.querySelectorAll('.oc-annotation-text'); const cleanups: Array<() => void> = []; - - for (const el of annotationElements) { - const indexStr = el.getAttribute('data-annotation-index'); - if (indexStr === null) continue; - - const index = Number(indexStr); - const specAnnotation = specAnnotations[index]; - if (!specAnnotation || specAnnotation.type !== 'text') continue; - - const textAnnotation = specAnnotation as TextAnnotation; - const annotationG = el as SVGGElement; - - annotationG.style.cursor = 'grab'; - - const connectorLine = annotationG.querySelector('line.oc-annotation-connector'); - const origX2 = connectorLine ? Number(connectorLine.getAttribute('x2')) : 0; - const origY2 = connectorLine ? Number(connectorLine.getAttribute('y2')) : 0; - - const curvedPath = annotationG.querySelector('path.oc-annotation-connector'); - const arrowhead = annotationG.querySelector('polyline.oc-annotation-arrowhead'); - const hasCurvedConnector = curvedPath !== null; - - const origDx = textAnnotation.offset?.dx ?? 0; - const origDy = textAnnotation.offset?.dy ?? 0; - - const cleanup = createDragHandler({ - element: annotationG, - svg: svg as unknown as SVGSVGElement, - onMove: (dx, dy) => { - annotationG.setAttribute('transform', `translate(${dx}, ${dy})`); - - if (connectorLine && !hasCurvedConnector) { - connectorLine.setAttribute('x2', String(origX2 - dx)); - connectorLine.setAttribute('y2', String(origY2 - dy)); - } - - if (hasCurvedConnector) { - if (curvedPath) curvedPath.setAttribute('display', 'none'); - if (arrowhead) arrowhead.setAttribute('display', 'none'); - } - }, - onEnd: (dx, dy, moved) => { - annotationG.removeAttribute('transform'); - - if (connectorLine && !hasCurvedConnector) { - connectorLine.setAttribute('x2', String(origX2)); - connectorLine.setAttribute('y2', String(origY2)); - } - - if (hasCurvedConnector) { - if (curvedPath) curvedPath.removeAttribute('display'); - if (arrowhead) arrowhead.removeAttribute('display'); - } - - if (moved) { - const newOffset: AnnotationOffset = { - dx: origDx + dx, - dy: origDy + dy, - }; - onAnnotationEdit?.(textAnnotation, newOffset); + const ctx = createAnnotationEditContext(layout, spec); + for (const group of svg.querySelectorAll('.oc-annotation-text')) { + const ref = buildElementRef(group, specAnnotations); + if (ref?.type !== 'annotation') continue; + const authored = specAnnotations[ref.index]; + if (authored?.type !== 'text') continue; + let initial: ResolvedAnnotation; + try { + initial = previewAnnotationMove(ctx, ref, { dx: 0, dy: 0 }); + } catch { + continue; + } + if (!initial.label?.visible || initial.footnoteIndex !== undefined) continue; + const original = initial; + const restore = () => + updateAnnotationGeometry(group, original, layout.theme.colors.neutral.surface); + group.style.cursor = 'grab'; + cleanups.push( + createDragHandler({ + element: group, + svg: svg as SVGSVGElement, + setDragging, + animationFrame: true, + onMove: (dx, dy) => + updateAnnotationGeometry( + group, + previewAnnotationMove(ctx, ref, { dx, dy }), + layout.theme.colors.neutral.surface, + ), + onCancel: restore, + onEnd: (dx, dy, moved) => { + restore(); + 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: elementRef.annotation( - index, - textAnnotation.id, - `${annotationG.getAttribute('data-annotation-path') ?? `/annotations/${index}`}/offset`, - ), - annotation: textAnnotation, - offset: newOffset, + element: { ...ref, path: `${ref.path}/offset` }, + annotation: authored, + offset: placement.offset!, + anchor: placement.anchor, + responsive: false, }); - } - }, - setDragging, - }); - - cleanups.push(cleanup); + }, + }), + ); } - return () => { - for (const cleanup of cleanups) { - cleanup(); - } + for (const cleanup of cleanups) cleanup(); }; } @@ -123,161 +111,98 @@ export function wireConnectorEndpointDrag( specAnnotations: Annotation[], onEdit: (edit: ElementEdit) => void, setDragging: (dragging: boolean) => void, + layout: ChartLayout, + spec: unknown, ): () => void { - const SVG_NS = 'http://www.w3.org/2000/svg'; const cleanups: Array<() => void> = []; - const annotationGroups = svg.querySelectorAll('.oc-annotation-text'); - - for (const el of annotationGroups) { - const annotationG = el as SVGGElement; - const indexStr = annotationG.getAttribute('data-annotation-index'); - if (indexStr === null) continue; - - const index = Number(indexStr); - const specAnnotation = specAnnotations[index]; - if (!specAnnotation || specAnnotation.type !== 'text') continue; - - const textAnnotation = specAnnotation as TextAnnotation; - - const connectorLine = annotationG.querySelector('line.oc-annotation-connector'); - const curvedPath = annotationG.querySelector('path.oc-annotation-connector'); - if (!connectorLine && !curvedPath) continue; - - let fromX: number, fromY: number, toX: number, toY: number; - if (connectorLine) { - fromX = Number(connectorLine.getAttribute('x1')) || 0; - fromY = Number(connectorLine.getAttribute('y1')) || 0; - toX = Number(connectorLine.getAttribute('x2')) || 0; - toY = Number(connectorLine.getAttribute('y2')) || 0; - } else { - const pathD = curvedPath!.getAttribute('d') ?? ''; - const mMatch = pathD.match(/M\s*([\d.e+-]+)\s+([\d.e+-]+)/); - fromX = mMatch ? Number(mMatch[1]) : 0; - fromY = mMatch ? Number(mMatch[2]) : 0; - // The open-V arrowhead is a polyline "baseLeft tip baseRight": the tip is - // the middle point. - const arrowhead = annotationG.querySelector('polyline.oc-annotation-arrowhead'); - const points = arrowhead?.getAttribute('points')?.split(' ') ?? []; - const [px, py] = (points[1] ?? '0,0').split(','); - toX = Number(px) || 0; - toY = Number(py) || 0; + const ctx = createAnnotationEditContext(layout, spec); + for (const group of svg.querySelectorAll('.oc-annotation-text')) { + const ref = buildElementRef(group, specAnnotations); + if (ref?.type !== 'annotation') continue; + const authored = specAnnotations[ref.index]; + if (authored?.type !== 'text') continue; + let original: ResolvedAnnotation; + try { + original = previewAnnotationMove(ctx, ref, { dx: 0, dy: 0 }); + } catch { + continue; } - - const endpoints: Array<{ name: 'from' | 'to'; cx: number; cy: number }> = [ - { name: 'from', cx: fromX, cy: fromY }, - { name: 'to', cx: toX, cy: toY }, - ]; - - const createdHandles: SVGCircleElement[] = []; - - for (const ep of endpoints) { - if (!Number.isFinite(ep.cx) || !Number.isFinite(ep.cy)) continue; - - const handleEl = document.createElementNS(SVG_NS, 'circle') as SVGCircleElement; - handleEl.setAttribute('class', 'oc-connector-handle'); - handleEl.setAttribute('data-endpoint', ep.name); - handleEl.setAttribute('cx', String(ep.cx)); - handleEl.setAttribute('cy', String(ep.cy)); - handleEl.setAttribute('r', '4'); - handleEl.setAttribute('opacity', '0'); - handleEl.setAttribute('fill', 'currentColor'); - handleEl.setAttribute('stroke', 'currentColor'); - annotationG.appendChild(handleEl); - createdHandles.push(handleEl); - - const origCx = ep.cx; - const origCy = ep.cy; - - const stopProp = (e: Event) => { - e.stopPropagation(); + const connector = original.label?.connector; + if (!connector || original.footnoteIndex !== undefined) continue; + const handles: SVGCircleElement[] = []; + for (const end of ['from', 'to'] as const) { + const point = connector[end]; + const handle = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + handle.setAttribute('class', 'oc-connector-handle'); + handle.setAttribute('data-endpoint', end); + handle.setAttribute('cx', String(point.x)); + handle.setAttribute('cy', String(point.y)); + handle.setAttribute('r', '4'); + handle.setAttribute('opacity', '0'); + handle.setAttribute('fill', 'currentColor'); + handle.setAttribute('stroke', 'currentColor'); + handle.style.cursor = 'grab'; + group.appendChild(handle); + handles.push(handle); + const stop = (event: Event) => event.stopPropagation(); + handle.addEventListener('mousedown', stop); + handle.addEventListener('touchstart', stop); + const restore = () => { + updateAnnotationGeometry(group, original, layout.theme.colors.neutral.surface); + handle.setAttribute('cx', String(point.x)); + handle.setAttribute('cy', String(point.y)); }; - handleEl.addEventListener('mousedown', stopProp); - handleEl.addEventListener('touchstart', stopProp); - cleanups.push(() => { - handleEl.removeEventListener('mousedown', stopProp); - handleEl.removeEventListener('touchstart', stopProp); - }); - - const cleanup = createDragHandler({ - element: handleEl, - svg: svg as unknown as SVGSVGElement, - onMove: (dx, dy) => { - handleEl.setAttribute('cx', String(origCx + dx)); - handleEl.setAttribute('cy', String(origCy + dy)); - - if (connectorLine) { - if (ep.name === 'from') { - connectorLine.setAttribute('x1', String(origCx + dx)); - connectorLine.setAttribute('y1', String(origCy + dy)); - } else { - connectorLine.setAttribute('x2', String(origCx + dx)); - connectorLine.setAttribute('y2', String(origCy + dy)); - } - } - }, - onEnd: (dx, dy, moved) => { - handleEl.setAttribute('cx', String(origCx)); - handleEl.setAttribute('cy', String(origCy)); - - if (connectorLine) { - if (ep.name === 'from') { - connectorLine.setAttribute('x1', String(origCx)); - connectorLine.setAttribute('y1', String(origCy)); - } else { - connectorLine.setAttribute('x2', String(origCx)); - connectorLine.setAttribute('y2', String(origCy)); - } - } - - if (moved) { - const existingOffset = textAnnotation.connectorOffset?.[ep.name]; - const origEndDx = existingOffset?.dx ?? 0; - const origEndDy = existingOffset?.dy ?? 0; + cleanups.push( + createDragHandler({ + element: handle, + svg: svg as SVGSVGElement, + setDragging, + animationFrame: true, + onMove: (dx, dy) => { + const preview = previewConnectorOffset(ctx, ref, end, { dx, dy }); + updateAnnotationGeometry(group, preview, layout.theme.colors.neutral.surface); + const position = preview.label?.connector?.[end] ?? point; + handle.setAttribute('cx', String(position.x)); + handle.setAttribute('cy', String(position.y)); + }, + onCancel: restore, + onEnd: (dx, dy, moved) => { + restore(); + if (!moved) return; + const previous = authored.connectorOffset?.[end]; onEdit({ type: 'annotation-connector', - element: elementRef.annotation( - index, - textAnnotation.id, - `${annotationG.getAttribute('data-annotation-path') ?? `/annotations/${index}`}/connectorOffset/${ep.name}`, - ), - annotation: textAnnotation, - endpoint: ep.name, - offset: { dx: origEndDx + dx, dy: origEndDy + dy }, + element: { ...ref, path: `${ref.path}/connectorOffset/${end}` }, + annotation: authored, + endpoint: end, + offset: { dx: (previous?.dx ?? 0) + dx, dy: (previous?.dy ?? 0) + dy }, }); - } - }, - setDragging, + }, + }), + ); + cleanups.push(() => { + handle.removeEventListener('mousedown', stop); + handle.removeEventListener('touchstart', stop); + handle.remove(); }); - - cleanups.push(cleanup); } - - const showHandles = () => { - for (const h of createdHandles) { - h.setAttribute('opacity', '0.6'); - } - }; - const hideHandles = () => { - for (const h of createdHandles) { - h.setAttribute('opacity', '0'); - } - }; - - annotationG.addEventListener('mouseenter', showHandles); - annotationG.addEventListener('mouseleave', hideHandles); + const show = () => + handles.forEach((handle) => { + handle.setAttribute('opacity', '0.6'); + }); + const hide = () => + handles.forEach((handle) => { + handle.setAttribute('opacity', '0'); + }); + group.addEventListener('mouseenter', show); + group.addEventListener('mouseleave', hide); cleanups.push(() => { - annotationG.removeEventListener('mouseenter', showHandles); - annotationG.removeEventListener('mouseleave', hideHandles); - for (const h of createdHandles) { - h.remove(); - } + group.removeEventListener('mouseenter', show); + group.removeEventListener('mouseleave', hide); }); } - return () => { - for (const cleanup of cleanups) { - cleanup(); - } + for (const cleanup of cleanups) cleanup(); }; } @@ -329,6 +254,9 @@ export function wireAnnotationLabelDrag( (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 = ''; @@ -407,6 +335,9 @@ export function wireChromeDrag( (textEl as SVGElement & ElementCSSInlineStyle).style.transform = `translate(${dx}px, ${dy}px)`; }, + onCancel: () => { + textEl.style.transform = ''; + }, onEnd: (dx, dy, moved) => { (textEl as SVGElement & ElementCSSInlineStyle).style.transform = ''; @@ -468,6 +399,9 @@ export function wireLegendDrag( (legendG as SVGElement & ElementCSSInlineStyle).style.transform = `translate(${dx}px, ${dy}px)`; }, + onCancel: () => { + legendG.style.transform = ''; + }, onEnd: (dx, dy, moved) => { (legendG as SVGElement & ElementCSSInlineStyle).style.transform = ''; @@ -523,6 +457,9 @@ export function wireSeriesLabelDrag( (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 = ''; @@ -610,6 +547,10 @@ export function wireAnchorDrag( handleEl.setAttribute('cx', String(anchorX + dx)); handleEl.setAttribute('cy', String(anchorY + dy)); }, + onCancel: () => { + handleEl.setAttribute('cx', String(anchorX)); + handleEl.setAttribute('cy', String(anchorY)); + }, onEnd: (dx, dy, moved) => { handleEl.setAttribute('cx', String(anchorX)); handleEl.setAttribute('cy', String(anchorY)); diff --git a/packages/vanilla/src/mount.ts b/packages/vanilla/src/mount.ts index 895ad3f7..ad64c8d5 100644 --- a/packages/vanilla/src/mount.ts +++ b/packages/vanilla/src/mount.ts @@ -1,4 +1,5 @@ import { warnDeprecated } from './deprecations'; +import { createAnnotationNudges } from './interactions/annotation-nudges'; import { annotationSource, editToTransaction } from './interactions/edit-transactions'; import { canTransitionSpecShape } from './transition/gate'; import { reconcileSVG } from './transition/reconcile'; @@ -292,6 +293,7 @@ export function createChart( let destroyed = false; let isDragging = false; let pendingRender = false; + let gestureWidth = 0; // Animation state let isFirstRender = true; @@ -429,12 +431,15 @@ export function createChart( } } - return ( + return Math.max( + 0, layout.chrome.topHeight + - layout.chrome.bottomHeight + - topLegendBlock + - (layout.metrics?.height ?? 0) + - facetGrowth + layout.chrome.bottomHeight + + topLegendBlock + + (layout.metrics?.height ?? 0) + + facetGrowth + + (layout.chrome.grownHeight !== undefined ? (layout.chrome.noteBandHeight ?? 0) : 0) - + (layout.chrome.grownHeight ?? 0), ); } @@ -617,6 +622,15 @@ export function createChart( warnDeprecated('The mount responsive option is deprecated; use autoResize.'); let editRevision = options?.revision; + const annotationNudges = createAnnotationNudges( + () => ({ + layout: currentLayout, + spec: currentSpec, + svg: svgElement, + selected: selectedElement, + }), + emitEdit, + ); function emitEdit(edit: ElementEdit): void { const tx = editToTransaction(currentSpec, edit, editRevision); @@ -636,6 +650,7 @@ export function createChart( } function selectElement(ref: ElementRef): void { + annotationNudges.flush(); if (!svgElement) return; const target = findElementByRef(svgElement, ref); @@ -653,6 +668,7 @@ export function createChart( } function deselectElement(): void { + annotationNudges.flush(); if (!selectedElement) return; if (isTextEditingActive && textEditCleanup) { @@ -841,6 +857,9 @@ export function createChart( const handleKeyDown = (e: KeyboardEvent) => { const specAnnotations = getSpecAnnotations(); + if (!isTextEditingActive && annotationNudges.keydown(e)) return; + if (e.key === 'Escape' || e.key === 'Delete' || e.key === 'Backspace') + annotationNudges.flush(); switch (e.key) { case 'Delete': @@ -907,9 +926,57 @@ export function createChart( }; svg.addEventListener('keydown', handleKeyDown); + const flushNudges = () => annotationNudges.flush(); + svg.addEventListener('keyup', flushNudges); + const startGesture = (event: MouseEvent | TouchEvent) => { + const target = event.target as Element; + const ref = buildElementRef(target, getSpecAnnotations()); + const previousLayout = currentLayout; + // Capture the specific handle before a controlled update replaces it. + const role = target.closest('.oc-connector-handle, .oc-anchor-handle, .oc-annotation-label'); + const roleClass = role?.getAttribute('class'); + const endpoint = role?.getAttribute('data-endpoint'); + annotationNudges.flush(); + if (destroyed || !ref || !svgElement || currentLayout === previousLayout) return; + const group = findElementByRef(svgElement, ref); + if (!group) return; + const replacement = roleClass + ? ([...group.querySelectorAll('*')].find( + (el) => + el.getAttribute('class') === roleClass && + el.getAttribute('data-endpoint') === endpoint, + ) ?? group) + : group; + event.stopImmediatePropagation(); + event.preventDefault(); + const init = { bubbles: true, cancelable: true }; + replacement.dispatchEvent( + event.type === 'mousedown' + ? new MouseEvent('mousedown', { + ...init, + clientX: (event as MouseEvent).clientX, + clientY: (event as MouseEvent).clientY, + button: (event as MouseEvent).button, + }) + : new TouchEvent('touchstart', { + ...init, + touches: [...(event as TouchEvent).touches], + changedTouches: [...(event as TouchEvent).changedTouches], + }), + ); + }; + svg.addEventListener('mousedown', startGesture, true); + svg.addEventListener('touchstart', startGesture, true); + document.addEventListener('pointercancel', flushNudges, true); + document.addEventListener('touchcancel', flushNudges, true); return () => { svg.removeEventListener('keydown', handleKeyDown); + svg.removeEventListener('keyup', flushNudges); + svg.removeEventListener('mousedown', startGesture, true); + svg.removeEventListener('touchstart', startGesture, true); + document.removeEventListener('pointercancel', flushNudges, true); + document.removeEventListener('touchcancel', flushNudges, true); }; } @@ -1136,6 +1203,7 @@ export function createChart( // Shared setDragging callback for all drag handlers const setDragging = (dragging: boolean) => { + if (dragging) gestureWidth = getContainerDimensions().width; isDragging = dragging; if (!dragging && pendingRender) { pendingRender = false; @@ -1157,6 +1225,8 @@ export function createChart( options?.onAnnotationEdit, emitEdit, setDragging, + currentLayout, + currentSpec, ); } @@ -1169,7 +1239,14 @@ export function createChart( const onEditFn = emitEdit; editCleanups.push( - wireConnectorEndpointDrag(svgElement, dragAnnotations, onEditFn, setDragging), + wireConnectorEndpointDrag( + svgElement, + dragAnnotations, + onEditFn, + setDragging, + currentLayout, + currentSpec, + ), ); editCleanups.push( wireAnnotationLabelDrag(svgElement, dragAnnotations, onEditFn, setDragging), @@ -1346,13 +1423,26 @@ export function createChart( } function doUpdate( - newSpec: ChartSpec | GraphSpec, + newSpec: ChartSpec | LayerSpec | GraphSpec, updateOpts: UpdateOptions | undefined, manual: boolean, layoutOnly = false, ): TransitionHandle | null { if (destroyed) return null; + annotationNudges.flush(); + if (destroyed) return null; + // A controlled host may commit the flushed nudge synchronously. + if (layoutOnly) newSpec = currentSpec; + if (isDragging) { + if (layoutOnly && getContainerDimensions().width === gestureWidth) { + pendingRender = true; + return null; + } + pendingRender = false; + svgElement?.dispatchEvent(new Event('oc-cancel-drag')); + } + // Capture pre-update state for transition gating const prevSpec = currentSpec; const prevLayout = currentLayout; @@ -1538,6 +1628,7 @@ export function createChart( } function destroy(): void { + annotationNudges.flush(); if (destroyed) return; destroyed = true; diff --git a/packages/vanilla/src/renderers/annotations.ts b/packages/vanilla/src/renderers/annotations.ts index 5d1fcc9a..adfce582 100644 --- a/packages/vanilla/src/renderers/annotations.ts +++ b/packages/vanilla/src/renderers/annotations.ts @@ -5,6 +5,7 @@ import type { ChartLayout, Point, ResolvedAnnotation } from '@opendata-ai/openchart-core'; import { ARROWHEAD_LENGTH, + automaticControlPoint, BOLD_SPAN_FONT_WEIGHT, computeArrowheadPoints, DEFAULT_ANNOTATION_FONT_SIZE, @@ -65,13 +66,9 @@ function renderCurvedConnector( stroke: string, showArrow: boolean, exit: 'horizontal' | 'vertical' | undefined, + control?: Point, ): void { - const dx = to.x - from.x; - const dy = to.y - from.y; - - const vertical = exit ? exit === 'vertical' : Math.abs(dy) >= Math.abs(dx); - const cpx = from.x + dx * (vertical ? 0.2 : 0.6); - const cpy = from.y + dy * (vertical ? 0.6 : 0.2); + const { x: cpx, y: cpy } = control ?? automaticControlPoint(from, to, exit); // Tangent at the tip runs from the control point to the tip. const tx = to.x - cpx; @@ -238,7 +235,7 @@ function renderAnnotation( if (annotation.label.connector) { const c = annotation.label.connector; if (c.style === 'curve') { - renderCurvedConnector(g, c.from, c.to, c.stroke, c.arrow, c.exit); + renderCurvedConnector(g, c.from, c.to, c.stroke, c.arrow, c.exit, c.control); } else if (c.style === 'drop-line') { // Quiet voice: hairline, slightly recessed, snapped to the pixel grid. const connector = createSVGElement('line'); @@ -319,7 +316,12 @@ function renderAnnotation( const fontSize = annotation.label.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE; const lineHeight = fontSize * (annotation.label.style.lineHeight ?? DEFAULT_LINE_HEIGHT); - fillRichText(text, annotation.label.text, annotation.label.x, lineHeight); + fillRichText( + text, + annotation.label.lines?.join('\n') ?? annotation.label.text, + annotation.label.x, + lineHeight, + ); // Render background rect behind text if specified, otherwise use // paint-order stroke halo to knock out lines behind text @@ -364,7 +366,12 @@ function renderAnnotation( const subFontSize = annotation.subtitle.style.fontSize ?? DEFAULT_ANNOTATION_FONT_SIZE; const subLineHeight = subFontSize * (annotation.subtitle.style.lineHeight ?? DEFAULT_LINE_HEIGHT); - fillRichText(sub, annotation.subtitle.text, annotation.subtitle.x, subLineHeight); + fillRichText( + sub, + annotation.subtitle.lines?.join('\n') ?? annotation.subtitle.text, + annotation.subtitle.x, + subLineHeight, + ); g.appendChild(sub); } } @@ -387,3 +394,37 @@ export function renderAnnotations(parent: SVGElement, layout: ChartLayout): void renderAnnotation(g, layout.annotations[i], i, bgColor); } } + +/** Apply engine geometry while retaining the group, listeners, and editing handles. */ +export function updateAnnotationGeometry( + group: SVGElement, + annotation: ResolvedAnnotation, + bgColor?: string, +): void { + const scratch = createSVGElement('g'); + renderAnnotation(scratch, annotation, annotation.specIndex ?? 0, bgColor); + const fresh = scratch.firstElementChild!; + const previous = [...group.children].filter((child) => + [...child.classList].some((name) => name.startsWith('oc-annotation-')), + ); + const used = new Set(); + for (const child of [...fresh.children]) { + const target = previous.find( + (old) => + !used.has(old) && + old.tagName === child.tagName && + old.getAttribute('class') === child.getAttribute('class'), + ); + if (target) { + used.add(target); + for (const attr of [...target.attributes]) + if (!child.hasAttribute(attr.name)) target.removeAttribute(attr.name); + for (const attr of [...child.attributes]) target.setAttribute(attr.name, attr.value); + target.replaceChildren(...child.childNodes); + group.appendChild(target); + } else group.appendChild(child); + } + for (const old of previous) if (!used.has(old)) old.remove(); + for (const handle of [...group.querySelectorAll('.oc-connector-handle, .oc-anchor-handle')]) + group.appendChild(handle); +} diff --git a/packages/vanilla/src/renderers/brand.ts b/packages/vanilla/src/renderers/brand.ts index f9bb8117..031d7800 100644 --- a/packages/vanilla/src/renderers/brand.ts +++ b/packages/vanilla/src/renderers/brand.ts @@ -3,8 +3,7 @@ */ import type { ChartLayout } from '@opendata-ai/openchart-core'; -import { BRAND_MIN_WIDTH, textAscent } from '@opendata-ai/openchart-core'; -import { footnoteBandHeight } from './chrome'; +import { BRAND_MIN_WIDTH, computeNoteBandHeight, textAscent } from '@opendata-ai/openchart-core'; import { createSVGElement, setAttrs, XLINK_NS } from './svg-dom'; const BRAND_URL = 'https://tryopendata.ai'; @@ -36,7 +35,8 @@ export function renderBrand(parent: SVGElement, layout: ChartLayout): void { // renderChrome pushes the whole footer row down past the footnote list; the // brand shares that row, so it takes the same shift or the footnotes land on // top of it. - const bandHeight = footnoteBandHeight(layout); + const bandHeight = + layout.chrome.noteBandHeight ?? computeNoteBandHeight(layout.chrome, layout.theme); const chromeY = firstBottom ? bottomOffset + firstBottom.y + bandHeight : bottomOffset + layout.theme.spacing.chartToFooter + bottomLegendOffset + bandHeight; diff --git a/packages/vanilla/src/renderers/chrome.ts b/packages/vanilla/src/renderers/chrome.ts index 99647d3c..13bafc0a 100644 --- a/packages/vanilla/src/renderers/chrome.ts +++ b/packages/vanilla/src/renderers/chrome.ts @@ -8,33 +8,19 @@ import type { ResolvedChromeElement, } from '@opendata-ai/openchart-core'; import { + computeNoteBandHeight, FOOTNOTE_LINE_HEIGHT, - footnoteBandHeight as footnoteBandHeightFor, textAscent, truncateToWidth, wrapText, } from '@opendata-ai/openchart-core'; +import { warnDeprecated } from '../deprecations'; import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom'; -/** - * Vertical space the auto-thinning footnote list occupies below the plot. - * Everything on the footer row (source, byline, footer, brand) shifts down by - * this much so the footnotes don't land on top of it. Zero when there are no - * footnotes. The brand watermark renders from a separate path - * (`renderBrand`), so it has to read the same number from here rather than - * recompute it. - * - * A `ChartLayout`-shaped adapter over the core helper -- deliberately not a - * second implementation. The engine reserves this band in the bottom margin - * from the same function, so a local copy of the arithmetic is a standing - * invitation for the two to drift and drop the footnotes onto the watermark. - */ +/** @deprecated Read layout.chrome.noteBandHeight. */ export function footnoteBandHeight(layout: ChartLayout): number { - return footnoteBandHeightFor( - (layout.chrome.footnotes?.reduce((count, note) => count + (note.lines?.length ?? 1), 0) ?? 0) + - (layout.chrome.seriesSummary?.reduce((count, entry) => count + entry.lines.length, 0) ?? 0), - layout.theme, - ); + warnDeprecated('footnoteBandHeight is deprecated; read layout.chrome.noteBandHeight.'); + return computeNoteBandHeight(layout.chrome, layout.theme); } export function renderChromeElement( @@ -166,7 +152,8 @@ export function renderChrome(parent: SVGElement, layout: ChartLayout): void { // Footnotes from auto-thinned annotations, rendered above source/byline. // Each footnote gets its own line to avoid horizontal overflow. - const bandHeight = footnoteBandHeight(layout); + const bandHeight = + layout.chrome.noteBandHeight ?? computeNoteBandHeight(layout.chrome, layout.theme); let noteLine = 0; if (chrome.footnotes && chrome.footnotes.length > 0) { const fontSize = layout.theme.fonts.sizes.small; diff --git a/packages/vanilla/src/transition/apply.ts b/packages/vanilla/src/transition/apply.ts index 73a80b38..fdbf131e 100644 --- a/packages/vanilla/src/transition/apply.ts +++ b/packages/vanilla/src/transition/apply.ts @@ -1,5 +1,6 @@ import { type MotionPhase, sampleMotion } from '@opendata-ai/openchart-core'; -import { buildArcPath } from '@opendata-ai/openchart-engine'; +import { buildArcPath, interpolateAnnotationGeometry } from '@opendata-ai/openchart-engine'; +import { updateAnnotationGeometry } from '../renderers/annotations'; import { applyCanvasGridlinesState, @@ -189,16 +190,12 @@ export function applyTweenState( break; } case 'annotation': { - // Ease the offset back to zero: the group is already rendered at its - // final position, so (0,0) is the destination. SVG transform attribute, - // not CSS -- CSS transform would replace any SVG transform attribute - // (see .claude/rules/svg-animation.md), and this composes cleanly since - // the annotations renderer sets none. - const dx = tw.fromDx * (1 - eased); - const dy = tw.fromDy * (1 - eased); - if (dx !== 0 || dy !== 0) { - tw.el.setAttribute('transform', `translate(${dx} ${dy})`); - } + if (tw.before && tw.after) + updateAnnotationGeometry( + tw.el, + interpolateAnnotationGeometry(tw.before, tw.after, eased), + tw.background, + ); break; } } @@ -299,9 +296,7 @@ export function snapTweenToFinal(tw: Tween): void { tw.el.setAttribute(tw.attr, tw.to); break; case 'annotation': - // The group is rendered at its final position; drop the offset entirely - // rather than writing translate(0 0), so the DOM returns to the exact - // shape render() produced. + if (tw.after) updateAnnotationGeometry(tw.el, tw.after, tw.background); tw.el.removeAttribute('transform'); break; } diff --git a/packages/vanilla/src/transition/chrome-tweens.ts b/packages/vanilla/src/transition/chrome-tweens.ts index f3bc8c7e..bcab3bfd 100644 --- a/packages/vanilla/src/transition/chrome-tweens.ts +++ b/packages/vanilla/src/transition/chrome-tweens.ts @@ -94,10 +94,8 @@ export function buildColorTweens( * queried from the `svg` root. A `marksContainer.querySelector` would never * find it. * - * Surviving annotations tween from their old position to their new one via a - * transform offset (the alternative -- re-resolving label x/y, connector path, - * dot, subtitle and background rect individually -- is a lot of surface for no - * extra fidelity). Entering ones fade in. See the note at the bottom for why + * Surviving annotations sample label and data-anchor geometry independently. + * Entering ones fade in. See the note at the bottom for why * exiting ones do not fade out. */ export function buildAnnotationTweens( @@ -130,24 +128,21 @@ export function buildAnnotationTweens( tweenType: 'annotation', kind: 'enter', el, - fromDx: 0, - fromDy: 0, + fromOpacity: 0, toOpacity: 1, }); continue; } - // Surviving: hold at full opacity, slide from the old position if it moved. - const dx = (before.label?.x ?? 0) - (next[i].label?.x ?? 0); - const dy = (before.label?.y ?? 0) - (next[i].label?.y ?? 0); - if (dx === 0 && dy === 0) continue; + if (JSON.stringify(before) === JSON.stringify(next[i])) continue; tweens.push({ tweenType: 'annotation', kind: 'update', el, - fromDx: dx, - fromDy: dy, + before, + after: next[i], + background: nextLayout.theme.colors.neutral.surface, }); } diff --git a/packages/vanilla/src/transition/gate.ts b/packages/vanilla/src/transition/gate.ts index 3e1b22b9..af06edf2 100644 --- a/packages/vanilla/src/transition/gate.ts +++ b/packages/vanilla/src/transition/gate.ts @@ -213,8 +213,7 @@ export function hasAnnotationChanged(prevLayout: ChartLayout, nextLayout: ChartL if (prevKeys[i] !== nextKeys[i]) return true; const p = prev[i]; const n = next[i]; - if (p.label?.x !== n.label?.x || p.label?.y !== n.label?.y) return true; - if (p.opacity !== n.opacity) return true; + if (JSON.stringify(p) !== JSON.stringify(n)) return true; } return false; } diff --git a/packages/vanilla/src/transition/types.ts b/packages/vanilla/src/transition/types.ts index 7227cb8d..2f3db331 100644 --- a/packages/vanilla/src/transition/types.ts +++ b/packages/vanilla/src/transition/types.ts @@ -1,4 +1,4 @@ -import type { ArcMark, Point, RectMark } from '@opendata-ai/openchart-core'; +import type { ArcMark, Point, RectMark, ResolvedAnnotation } from '@opendata-ai/openchart-core'; // --------------------------------------------------------------------------- // Public types @@ -302,9 +302,9 @@ export interface AnnotationTween { tweenType: 'annotation'; kind: 'update' | 'enter' | 'exit'; el: SVGElement; - /** Pixel delta applied as a transform; annotations position via many child attrs. */ - fromDx: number; - fromDy: number; + before?: ResolvedAnnotation; + after?: ResolvedAnnotation; + background?: string; ghost?: SVGElement; fromOpacity?: number; toOpacity?: number; diff --git a/packages/vue/src/__tests__/useChart.test.ts b/packages/vue/src/__tests__/useChart.test.ts index 828246af..5cc84095 100644 --- a/packages/vue/src/__tests__/useChart.test.ts +++ b/packages/vue/src/__tests__/useChart.test.ts @@ -124,3 +124,33 @@ describe('useChart', () => { expect(destroySpy).toHaveBeenCalledTimes(1); }); }); + +it.each([ + undefined, + 'subtract', + 'grow', +] as const)('mounts, updates and destroys note sizing mode %s', async (chromeLayout) => { + const spec = { + ...lineSpec, + animation: false as const, + chromeLayout, + annotations: [ + { + type: 'text' as const, + x: '2020-01-01', + y: 10, + text: 'A wrapped note', + maxWidth: 60, + responsive: false as const, + }, + ], + }; + const wrapper = await mountHarness(spec); + const initial = wrapper.find('svg').attributes('viewBox'); + await wrapper.setProps({ spec: { ...spec, chrome: { title: 'Changed' } } }); + await flushPromises(); + expect(wrapper.find('.oc-title').text()).toBe('Changed'); + expect(wrapper.find('svg').attributes('viewBox')).toBe(initial); + wrapper.unmount(); + expect(wrapper.find('svg').exists()).toBe(false); +}); diff --git a/playwright.config.ts b/playwright.config.ts index 9ad1742d..f4b17dce 100644 --- a/playwright.config.ts +++ b/playwright.config.ts @@ -23,6 +23,16 @@ export default defineConfig({ colorScheme: 'light', }, projects: [ + { + name: 'annotation-invariants', + testDir: './e2e/annotation-invariants', + use: { ...devices['Desktop Chrome'], deviceScaleFactor: 1 }, + }, + { + name: 'annotation-invariants-webkit', + testDir: './e2e/annotation-invariants', + use: { ...devices['Desktop Safari'], deviceScaleFactor: 1 }, + }, { name: 'visual', testDir: './e2e/visual',