From 15a67236f3d70492b9e493e783f5d085b74da74c Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Fri, 16 Oct 2026 23:20:54 -0500 Subject: [PATCH 1/8] feat: establish annotation geometry and cancellation groundwork Add engine curve controls, editing snapshots, validation, and frame-coalesced cancellable drag infrastructure. Pin the maximum-x first-drag regression; its margin-reservation fix awaits the requested visual-scope decision before gesture integration and phase gates. --- CHANGELOG.md | 9 + docs/codebase-map.md | 3 +- packages/core/schema/chart.schema.json | 4 + packages/core/schema/vizspec.schema.json | 4 + packages/core/src/types/layout.ts | 4 + packages/core/src/types/spec.ts | 2 + .../__tests__/connector-control.test.ts | 63 +++++ .../__tests__/edit-context.test.ts | 134 ++++++++++ packages/engine/src/annotations/compute.ts | 5 + .../engine/src/annotations/edit-context.ts | 186 ++++++++++++++ packages/engine/src/annotations/geometry.ts | 26 +- packages/engine/src/annotations/placement.ts | 2 +- .../engine/src/annotations/resolve-text.ts | 54 +++- packages/engine/src/compiler/normalize.ts | 16 +- packages/engine/src/compiler/validate.ts | 17 ++ packages/engine/src/index.ts | 10 +- .../src/__tests__/drag-cancellation.test.ts | 69 ++++++ .../vanilla/src/interactions/drag-handler.ts | 230 +++++++----------- packages/vanilla/src/renderers/annotations.ts | 11 +- 19 files changed, 692 insertions(+), 157 deletions(-) create mode 100644 packages/engine/src/annotations/__tests__/connector-control.test.ts create mode 100644 packages/engine/src/annotations/__tests__/edit-context.test.ts create mode 100644 packages/engine/src/annotations/edit-context.ts create mode 100644 packages/vanilla/src/__tests__/drag-cancellation.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 9aeb1232..5d71df47 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,14 @@ # Changelog +## Unreleased — 8.11 + +- 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. +- Treat an authored zero offset as automatic annotation placement. + ## Unreleased — 8.10 - Add atomic `EditTransaction` operations, pure `applyEditTransaction` with undo, diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 6aedfc46..ae66f52e 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: 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. | diff --git a/packages/core/schema/chart.schema.json b/packages/core/schema/chart.schema.json index 1d0e3519..4fe696ec 100644 --- a/packages/core/schema/chart.schema.json +++ b/packages/core/schema/chart.schema.json @@ -8254,6 +8254,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." diff --git a/packages/core/schema/vizspec.schema.json b/packages/core/schema/vizspec.schema.json index f01cd8d4..72767f5a 100644 --- a/packages/core/schema/vizspec.schema.json +++ b/packages/core/schema/vizspec.schema.json @@ -8283,6 +8283,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." diff --git a/packages/core/src/types/layout.ts b/packages/core/src/types/layout.ts index 6a2443a6..c29d6a3c 100644 --- a/packages/core/src/types/layout.ts +++ b/packages/core/src/types/layout.ts @@ -672,6 +672,10 @@ 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. */ diff --git a/packages/core/src/types/spec.ts b/packages/core/src/types/spec.ts index fab01dc2..6704cee4 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; } 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..e32aebc7 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/connector-control.test.ts @@ -0,0 +1,63 @@ +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, {}, { dx: 0 }, { dx: 0, dy: 0 }])('treats %j as automatic', (offset) => { + expect(isAutoPlacement({ offset })).toBe(true); + }); + it.each([{ 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..08d934b8 --- /dev/null +++ b/packages/engine/src/annotations/__tests__/edit-context.test.ts @@ -0,0 +1,134 @@ +import type { ChartSpec, TextAnnotation } from '@opendata-ai/openchart-core'; +import { elementRef } from '@opendata-ai/openchart-core'; +import { describe, expect, it } from 'vitest'; +import { compileChart } from '../../compile'; +import { validateSpec } from '../../compiler/validate'; +import { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, + previewConnectorBend, +} from '../edit-context'; + +export const editSpec: ChartSpec = { + mark: 'point', + animation: false, + 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('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('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([-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); + }); +}); diff --git a/packages/engine/src/annotations/compute.ts b/packages/engine/src/annotations/compute.ts index 71d55749..bdaf597f 100644 --- a/packages/engine/src/annotations/compute.ts +++ b/packages/engine/src/annotations/compute.ts @@ -1,3 +1,4 @@ +import { rememberAnnotationGeometry } from './edit-context'; /** * Annotation computation: converts spec-level annotations to pixel-positioned * ResolvedAnnotation objects using the resolved scales. @@ -281,5 +282,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..8d5e3bdb --- /dev/null +++ b/packages/engine/src/annotations/edit-context.ts @@ -0,0 +1,186 @@ +/** Frozen, engine-owned geometry for a single annotation editing gesture. */ +import type { + ChartLayout, + ElementRef, + Rect, + ResolvedAnnotation, + TextAnnotation, +} 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(); + +function annotationOwner( + spec: unknown, + path = '', +): { annotations: unknown[]; path: string } | undefined { + if (!spec || typeof spec !== 'object') return undefined; + const node = spec as Record; + if (Array.isArray(node.annotations)) + return { annotations: node.annotations, path: `${path}/annotations` }; + if (Array.isArray(node.layer)) { + for (const [i, child] of node.layer.entries()) { + const found = annotationOwner(child, `${path}/layer/${i}`); + if (found) return found; + } + } else if ('mark' in node) return { annotations: [], path: `${path}/annotations` }; +} + +/** Snapshot a compiled layout without asking callers to reconstruct scales or obstacles. */ +export function createAnnotationEditContext( + layout: ChartLayout, + spec: unknown, +): AnnotationEditContext { + const source = annotationOwner(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, + ); + 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: { + ...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: renderedBox.x - bounds.x, dy: renderedBox.y - bounds.y }, + 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; +} diff --git a/packages/engine/src/annotations/geometry.ts b/packages/engine/src/annotations/geometry.ts index 7d0a58bb..d8b7608b 100644 --- a/packages/engine/src/annotations/geometry.ts +++ b/packages/engine/src/annotations/geometry.ts @@ -313,6 +313,22 @@ 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, @@ -372,5 +388,13 @@ export function refreshConnector( const ly = to.y - from.y; if (!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/placement.ts b/packages/engine/src/annotations/placement.ts index 7e914f14..1dbf07b5 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?.dx ?? 0) !== 0 || (annotation.offset?.dy ?? 0) !== 0) 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/resolve-text.ts b/packages/engine/src/annotations/resolve-text.ts index 8b6a2e9e..3c00a7cf 100644 --- a/packages/engine/src/annotations/resolve-text.ts +++ b/packages/engine/src/annotations/resolve-text.ts @@ -41,6 +41,7 @@ import { import { type AnnotationMeasureTextFn, applyOffset, + automaticControlPoint, computeAnchorOffset, computeTextBlockBounds, connectorExit, @@ -108,13 +109,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 +129,45 @@ 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, +): 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 ?? (isDark ? DARK_TEXT_FILL : LIGHT_TEXT_FILL), + ), + ...(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. @@ -361,6 +405,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 } : {}), + } + : {}), }; } } diff --git a/packages/engine/src/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index 1751033d..e8a2e5d9 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,13 @@ function normalizeAnnotations(annotations: Annotation[] | undefined): Annotation return annotations.map((ann) => { switch (ann.type) { case 'text': + if ( + 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 @@ -491,7 +501,7 @@ 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, @@ -621,7 +631,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/validate.ts b/packages/engine/src/compiler/validate.ts index 0bfdb895..1d2f67e5 100644 --- a/packages/engine/src/compiler/validate.ts +++ b/packages/engine/src/compiler/validate.ts @@ -551,6 +551,23 @@ function validatePointSize(spec: Record, errors: ValidationErro // --------------------------------------------------------------------------- function validateChartSpec(spec: Record, errors: ValidationError[]): void { + if (Array.isArray(spec.annotations)) { + for (const [index, annotation] of spec.annotations.entries()) { + const bend = annotation?.connector?.bend; + if ( + bend !== undefined && + (typeof bend !== 'number' || !Number.isFinite(bend) || bend < -1 || bend > 1) + ) { + errors.push({ + code: 'INVALID_VALUE', + path: `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].', + }); + } + } + } + const markType = typeof spec.mark === 'string' ? spec.mark : (spec.mark as Record)?.type; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 2f67976b..08dafd5e 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,13 @@ export type { TileMapSpec, VizSpec, } from '@opendata-ai/openchart-core'; - +export type { AnnotationEditContext } from './annotations/edit-context'; +export { + createAnnotationEditContext, + inverseLabelPlacement, + previewAnnotationMove, + previewConnectorBend, +} from './annotations/edit-context'; export { diagnoseChart } from './design/diagnostics'; export type { ObservationComparison } from './design/identity'; export { 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/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/renderers/annotations.ts b/packages/vanilla/src/renderers/annotations.ts index 5d1fcc9a..8017880e 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'); From 9d132ec952bad61458fed158fec4e37c53d07e0c Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Fri, 16 Oct 2026 23:42:42 -0500 Subject: [PATCH 2/8] feat(annotations): share edit geometry and cancellable gestures --- CHANGELOG.md | 12 +- docs/codebase-map.md | 13 +- docs/migrating-v8.md | 22 + docs/spec-reference.md | 4 + e2e/annotation-invariants/curves.spec.ts | 86 ++++ e2e/annotation-invariants/gestures.spec.ts | 123 ++++++ .../src/gallery/features-annotations.demos.ts | 67 +++ .../gallery/features-annotations.stories.tsx | 89 +++- .../gallery/features-edit-mode.stories.tsx | 16 +- llms-full.txt | 4 + .../core/src/helpers/annotation-source.ts | 31 ++ packages/core/src/index.ts | 1 + packages/core/src/types/events.ts | 4 + .../engine/src/__tests__/dimensions.test.ts | 6 +- .../__tests__/connector-control.test.ts | 10 +- .../__tests__/edit-context.test.ts | 143 ++++++- .../annotations/__tests__/interpolate.test.ts | 69 ++++ .../annotations/__tests__/preview.bench.ts | 24 ++ packages/engine/src/annotations/collisions.ts | 24 +- .../engine/src/annotations/edit-context.ts | 56 ++- packages/engine/src/annotations/geometry.ts | 19 +- .../engine/src/annotations/interpolate.ts | 59 +++ packages/engine/src/annotations/placement.ts | 2 +- .../engine/src/annotations/resolve-text.ts | 37 +- packages/engine/src/compiler/normalize.ts | 1 + packages/engine/src/compiler/validate.ts | 47 ++- packages/engine/src/index.ts | 2 + packages/engine/src/layout/dimensions.ts | 37 +- packages/engine/tsconfig.json | 11 +- .../__tests__/annotation-animation.test.ts | 86 ++++ .../src/__tests__/annotation-gestures.test.ts | 192 +++++++++ .../vanilla/src/__tests__/edit-events.test.ts | 18 +- packages/vanilla/src/__tests__/events.test.ts | 11 +- .../__tests__/update-scrolly-matrix.test.ts | 12 +- .../src/interactions/annotation-nudges.ts | 97 +++++ .../src/interactions/edit-transactions.ts | 30 +- .../vanilla/src/interactions/editing-drags.ts | 381 ++++++++---------- packages/vanilla/src/mount.ts | 92 ++++- packages/vanilla/src/renderers/annotations.ts | 34 ++ packages/vanilla/src/transition/apply.ts | 23 +- .../vanilla/src/transition/chrome-tweens.ts | 19 +- packages/vanilla/src/transition/gate.ts | 3 +- packages/vanilla/src/transition/types.ts | 8 +- playwright.config.ts | 10 + 44 files changed, 1632 insertions(+), 403 deletions(-) create mode 100644 e2e/annotation-invariants/curves.spec.ts create mode 100644 e2e/annotation-invariants/gestures.spec.ts create mode 100644 packages/core/src/helpers/annotation-source.ts create mode 100644 packages/engine/src/annotations/__tests__/interpolate.test.ts create mode 100644 packages/engine/src/annotations/__tests__/preview.bench.ts create mode 100644 packages/engine/src/annotations/interpolate.ts create mode 100644 packages/vanilla/src/__tests__/annotation-animation.test.ts create mode 100644 packages/vanilla/src/__tests__/annotation-gestures.test.ts create mode 100644 packages/vanilla/src/interactions/annotation-nudges.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 5d71df47..feca6109 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -7,7 +7,17 @@ warn and ignore bend. - Export frozen annotation editing contexts, move/bend previews, and inverse label placement from the engine. -- Treat an authored zero offset as automatic annotation placement. +- 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. +- 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. ## Unreleased — 8.10 diff --git a/docs/codebase-map.md b/docs/codebase-map.md index ae66f52e..d23ceaa8 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -371,7 +371,7 @@ The browser authoring application lives in the separate [OpenStudio repository]( | Concern | Source / exports | | --- | --- | | Atomic authored-spec edits and inverse operations | `packages/core/src/helpers/edit-transactions.ts`: public `applyEditTransaction`, `EditTransactionResult`; `types/events.ts`: `EditOp`, `EditTransaction`, optional revision and callback | -| Authored annotation ownership and legacy event bridge | `packages/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` | @@ -382,3 +382,14 @@ 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 8.11 additions + +| 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` | diff --git a/docs/migrating-v8.md b/docs/migrating-v8.md index 175a098d..a60ac2f9 100644 --- a/docs/migrating-v8.md +++ b/docs/migrating-v8.md @@ -1296,3 +1296,25 @@ 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 needs a separate decision before removing the legacy renderer. + +### Annotation geometry and editing in 8.11 + +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. diff --git a/docs/spec-reference.md b/docs/spec-reference.md index b9ff256a..07cbb806 100644 --- a/docs/spec-reference.md +++ b/docs/spec-reference.md @@ -562,6 +562,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: 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/examples/src/gallery/features-annotations.demos.ts b/examples/src/gallery/features-annotations.demos.ts index 3bad7baa..902bf114 100644 --- a/examples/src/gallery/features-annotations.demos.ts +++ b/examples/src/gallery/features-annotations.demos.ts @@ -1,3 +1,4 @@ +import type { ChartSpec } from '@opendata-ai/openchart-core'; /** * Demo registry for the Annotations gallery page. * @@ -23,3 +24,69 @@ 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 } } + : {}), + })), +}; diff --git a/examples/src/gallery/features-annotations.stories.tsx b/examples/src/gallery/features-annotations.stories.tsx index fbcc88fb..563dea66 100644 --- a/examples/src/gallery/features-annotations.stories.tsx +++ b/examples/src/gallery/features-annotations.stories.tsx @@ -10,10 +10,13 @@ */ import type { ChartSpec } 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 { annotationCurveSpecs, annotationGestureSpec } from './features-annotations.demos'; const ACCENT = '#0e7490'; @@ -695,3 +698,87 @@ 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 }: { spec: ChartSpec }) { + const host = useRef(null); + useEffect(() => { + const node = host.current!; + const chart = createChart(node, spec); + const expose = () => { + node.dataset.layout = JSON.stringify(chart.layout); + }; + const exportSVG = () => { + node.dataset.export = chart.export('svg'); + }; + node.addEventListener('fixture-layout', expose); + node.addEventListener('fixture-export', exportSVG); + return () => { + node.removeEventListener('fixture-layout', expose); + node.removeEventListener('fixture-export', exportSVG); + chart.destroy(); + }; + }, [spec]); + return
; +} +export function AnnotationCurves() { + return ( +
+ {annotationCurveSpecs.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/llms-full.txt b/llms-full.txt index 64205055..080d6bca 100644 --- a/llms-full.txt +++ b/llms-full.txt @@ -1487,6 +1487,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: 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..f5917cc6 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -231,6 +231,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/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/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/annotations/__tests__/connector-control.test.ts b/packages/engine/src/annotations/__tests__/connector-control.test.ts index e32aebc7..31377f38 100644 --- a/packages/engine/src/annotations/__tests__/connector-control.test.ts +++ b/packages/engine/src/annotations/__tests__/connector-control.test.ts @@ -50,10 +50,16 @@ describe('engine connector control', () => { }); describe('manual placement predicate', () => { - it.each([undefined, {}, { dx: 0 }, { dx: 0, dy: 0 }])('treats %j as automatic', (offset) => { + it.each([undefined])('treats %j as automatic', (offset) => { expect(isAutoPlacement({ offset })).toBe(true); }); - it.each([{ dx: 1 }, { dy: -1 }])('treats %j as manual', (offset) => { + 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', () => { diff --git a/packages/engine/src/annotations/__tests__/edit-context.test.ts b/packages/engine/src/annotations/__tests__/edit-context.test.ts index 08d934b8..067b6721 100644 --- a/packages/engine/src/annotations/__tests__/edit-context.test.ts +++ b/packages/engine/src/annotations/__tests__/edit-context.test.ts @@ -1,19 +1,21 @@ import type { ChartSpec, TextAnnotation } from '@opendata-ai/openchart-core'; import { elementRef } from '@opendata-ai/openchart-core'; import { describe, expect, it } from 'vitest'; -import { compileChart } from '../../compile'; +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, - autoThin: false, + responsive: { autoThin: false }, data: [ { x: 0, y: 0 }, { x: 10, y: 10 }, @@ -65,6 +67,22 @@ describe('resolved annotation editing', () => { expect(committed.dot).toEqual(layout.annotations[0].dot); expect(layout.annotations).toEqual(before); }); + it('keeps a crowded note-bearing plot fixed when a first drag changes note eligibility', () => { + const spec = structuredClone(annotationCrowdedSpec); + const size = { width: 390, height: 600 }; + 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: -20 }); + 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); + }); 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); @@ -81,6 +99,96 @@ describe('resolved annotation editing', () => { 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); @@ -109,6 +217,37 @@ describe('resolved annotation editing', () => { 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, 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__/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/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/edit-context.ts b/packages/engine/src/annotations/edit-context.ts index 8d5e3bdb..0f184e43 100644 --- a/packages/engine/src/annotations/edit-context.ts +++ b/packages/engine/src/annotations/edit-context.ts @@ -6,6 +6,7 @@ import type { 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'; @@ -41,28 +42,12 @@ export interface AnnotationEditContext { const snapshots = new WeakMap(); -function annotationOwner( - spec: unknown, - path = '', -): { annotations: unknown[]; path: string } | undefined { - if (!spec || typeof spec !== 'object') return undefined; - const node = spec as Record; - if (Array.isArray(node.annotations)) - return { annotations: node.annotations, path: `${path}/annotations` }; - if (Array.isArray(node.layer)) { - for (const [i, child] of node.layer.entries()) { - const found = annotationOwner(child, `${path}/layer/${i}`); - if (found) return found; - } - } else if ('mark' in node) return { annotations: [], path: `${path}/annotations` }; -} - /** Snapshot a compiled layout without asking callers to reconstruct scales or obstacles. */ export function createAnnotationEditContext( layout: ChartLayout, spec: unknown, ): AnnotationEditContext { - const source = annotationOwner(spec); + const source = annotationSource(spec); const entries: Entry[] = []; const panels = layout.facet ? layout.facet.panels.map((p) => ({ annotations: p.annotations, facet: p.key })) @@ -139,7 +124,10 @@ export function inverseLabelPlacement( const bounds = entry.base.bounds!; return { anchor: entry.anchor, - offset: { dx: renderedBox.x - bounds.x, dy: renderedBox.y - bounds.y }, + offset: { + dx: Math.round((renderedBox.x - bounds.x) * 1e10) / 1e10, + dy: Math.round((renderedBox.y - bounds.y) * 1e10) / 1e10, + }, responsive: false, }; } @@ -184,3 +172,35 @@ export function previewConnectorBend( ); 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 d8b7608b..a0edda33 100644 --- a/packages/engine/src/annotations/geometry.ts +++ b/packages/engine/src/annotations/geometry.ts @@ -333,6 +333,7 @@ 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 @@ -356,13 +357,24 @@ 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 = + 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 @@ -386,7 +398,8 @@ 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, 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/placement.ts b/packages/engine/src/annotations/placement.ts index 1dbf07b5..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?.dx ?? 0) !== 0 || (annotation.offset?.dy ?? 0) !== 0) 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/resolve-text.ts b/packages/engine/src/annotations/resolve-text.ts index 3c00a7cf..55e9f587 100644 --- a/packages/engine/src/annotations/resolve-text.ts +++ b/packages/engine/src/annotations/resolve-text.ts @@ -490,7 +490,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 @@ -507,7 +509,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 }; @@ -517,11 +519,26 @@ function resolveDropLineAnnotation( // 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); + 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, + ); 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, @@ -567,7 +584,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/compiler/normalize.ts b/packages/engine/src/compiler/normalize.ts index e8a2e5d9..f27a1478 100644 --- a/packages/engine/src/compiler/normalize.ts +++ b/packages/engine/src/compiler/normalize.ts @@ -187,6 +187,7 @@ function normalizeAnnotations( switch (ann.type) { case 'text': if ( + ann.connector !== null && typeof ann.connector === 'object' && ann.connector.type !== 'curve' && ann.connector.bend !== undefined diff --git a/packages/engine/src/compiler/validate.ts b/packages/engine/src/compiler/validate.ts index 1d2f67e5..2908766f 100644 --- a/packages/engine/src/compiler/validate.ts +++ b/packages/engine/src/compiler/validate.ts @@ -551,23 +551,6 @@ function validatePointSize(spec: Record, errors: ValidationErro // --------------------------------------------------------------------------- function validateChartSpec(spec: Record, errors: ValidationError[]): void { - if (Array.isArray(spec.annotations)) { - for (const [index, annotation] of spec.annotations.entries()) { - const bend = annotation?.connector?.bend; - if ( - bend !== undefined && - (typeof bend !== 'number' || !Number.isFinite(bend) || bend < -1 || bend > 1) - ) { - errors.push({ - code: 'INVALID_VALUE', - path: `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].', - }); - } - } - } - const markType = typeof spec.mark === 'string' ? spec.mark : (spec.mark as Record)?.type; @@ -2366,6 +2349,35 @@ function validateFieldMetadata(obj: Record, errors: ValidationE * field type compatibility. Returns structured errors with machine-readable * codes and actionable suggestions for each problem found. */ +function validateAnnotationBends( + spec: Record, + errors: ValidationError[], + prefix = '', +): void { + if (Array.isArray(spec.annotations)) { + for (const [index, annotation] of spec.annotations.entries()) { + 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)) + validateAnnotationBends(child, errors, `${prefix}layer[${index}].`); + } +} + export function validateSpec(spec: unknown): ValidationResult { const errors: ValidationError[] = []; @@ -2386,6 +2398,7 @@ export function validateSpec(spec: unknown): ValidationResult { } const obj = spec as Record; + validateAnnotationBends(obj, errors); validateFieldMetadata(obj, errors); if (obj.chrome !== undefined) { const chrome = obj.chrome; diff --git a/packages/engine/src/index.ts b/packages/engine/src/index.ts index 08dafd5e..ca0e8838 100644 --- a/packages/engine/src/index.ts +++ b/packages/engine/src/index.ts @@ -189,7 +189,9 @@ export { 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..f92059ce 100644 --- a/packages/engine/src/layout/dimensions.ts +++ b/packages/engine/src/layout/dimensions.ts @@ -369,9 +369,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 +427,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); 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/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__/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__/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/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..ff10ea58 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; @@ -617,6 +619,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 +647,7 @@ export function createChart( } function selectElement(ref: ElementRef): void { + annotationNudges.flush(); if (!svgElement) return; const target = findElementByRef(svgElement, ref); @@ -653,6 +665,7 @@ export function createChart( } function deselectElement(): void { + annotationNudges.flush(); if (!selectedElement) return; if (isTextEditingActive && textEditCleanup) { @@ -841,6 +854,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 +923,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 +1200,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 +1222,8 @@ export function createChart( options?.onAnnotationEdit, emitEdit, setDragging, + currentLayout, + currentSpec, ); } @@ -1169,7 +1236,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 +1420,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 +1625,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 8017880e..6f81241a 100644 --- a/packages/vanilla/src/renderers/annotations.ts +++ b/packages/vanilla/src/renderers/annotations.ts @@ -384,3 +384,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/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/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', From 6c2cbf8ec8971bd6fac05ad261aa7bb57b80a630 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 00:02:17 -0500 Subject: [PATCH 3/8] fix(annotations): stabilize note capacity across editing --- CHANGELOG.md | 5 + docs/codebase-map.md | 4 +- docs/migrating-v8.md | 20 ++ packages/core/src/types/layout.ts | 11 +- .../src/__tests__/compile-footnotes.test.ts | 201 +++++++++++++++++- .../src/__tests__/compile-layer.test.ts | 39 ++++ .../__tests__/edit-context.test.ts | 13 +- .../src/annotations/__tests__/notes.test.ts | 33 +++ packages/engine/src/annotations/notes.ts | 47 +++- packages/engine/src/compile.ts | 107 ++++++---- packages/engine/src/compile/layer.ts | 29 ++- packages/engine/src/design/diagnostics.ts | 19 +- packages/engine/src/layout/dimensions.ts | 10 +- .../src/__tests__/chrome-renderer.test.ts | 45 ++++ packages/vanilla/src/renderers/chrome.ts | 12 +- 15 files changed, 523 insertions(+), 72 deletions(-) create mode 100644 packages/engine/src/annotations/__tests__/notes.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index feca6109..a9395715 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,11 @@ - 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. diff --git a/docs/codebase-map.md b/docs/codebase-map.md index d23ceaa8..36128cb7 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -62,7 +62,7 @@ | 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` | @@ -260,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 diff --git a/docs/migrating-v8.md b/docs/migrating-v8.md index a60ac2f9..f26e2a82 100644 --- a/docs/migrating-v8.md +++ b/docs/migrating-v8.md @@ -1318,3 +1318,23 @@ canonical annotation refs (including `facetInstance` for facets), and recreate i 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. + +### 8.11: 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 in 8.11. + +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` for a non-faceted chart, or reduce the +annotation text/count. The compiler does not silently change sizing modes. diff --git a/packages/core/src/types/layout.ts b/packages/core/src/types/layout.ts index c29d6a3c..dc4b7b0a 100644 --- a/packages/core/src/types/layout.ts +++ b/packages/core/src/types/layout.ts @@ -130,6 +130,8 @@ 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; /** 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. */ @@ -2261,11 +2263,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/engine/src/__tests__/compile-footnotes.test.ts b/packages/engine/src/__tests__/compile-footnotes.test.ts index c8a7c513..71d7d532 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,48 @@ describe('auto-thinning in faceted charts', () => { } }); + it.each([ + false, + true, + ])('retains facet subtract behavior with spec-level grow=%s until the facet opt-in phase', (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' }, + ); + expect(grown.dimensions).toEqual(baseline.dimensions); + expect(grown.area).toEqual(baseline.area); + expect(grown.facet!.panels.map((panel) => panel.area)).toEqual( + baseline.facet!.panels.map((panel) => panel.area), + ); + expect(grown.chrome.bottomAnchorY).toEqual(baseline.chrome.bottomAnchorY); + }); + + 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/annotations/__tests__/edit-context.test.ts b/packages/engine/src/annotations/__tests__/edit-context.test.ts index 067b6721..2e23b437 100644 --- a/packages/engine/src/annotations/__tests__/edit-context.test.ts +++ b/packages/engine/src/annotations/__tests__/edit-context.test.ts @@ -67,13 +67,18 @@ describe('resolved annotation editing', () => { expect(committed.dot).toEqual(layout.annotations[0].dot); expect(layout.annotations).toEqual(before); }); - it('keeps a crowded note-bearing plot fixed when a first drag changes note eligibility', () => { + 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 }; + 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: -20 }); + 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, @@ -82,6 +87,8 @@ describe('resolved annotation editing', () => { 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 }] }; 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/notes.ts b/packages/engine/src/annotations/notes.ts index 977abed4..2868e2f1 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,43 @@ 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, +): number { + if (annotations.length <= 1) 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/compile.ts b/packages/engine/src/compile.ts index 169ef71e..cb8530fb 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. * @@ -52,7 +52,11 @@ 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 { thinAnnotations } from './annotations/thinning'; import { computeEndpointLabels } from './endpoint-labels/compute'; @@ -579,6 +583,21 @@ 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) + : 0; + const stableNoteReserve = footnoteBandHeight(noteCapacity, theme); + if (!options.frozenChartArea && stableNoteReserve > (options.footnoteReserve ?? 0)) { + return compileChart(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 +689,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; } // --------------------------------------------------------------------------- @@ -991,23 +1013,9 @@ 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. + // 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 = footnoteBandHeight(annotationNoteLineCount(footnotes), theme); const currentReserve = options.footnoteReserve ?? 0; if ( @@ -1015,10 +1023,8 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart reserve > currentReserve && (options.footnotePass ?? 0) < MAX_FOOTNOTE_PASSES ) { - const grow = ((spec as ChartSpec).chromeLayout ?? options.chromeLayout) === 'grow'; return compileChart(spec, { ...optionsInput, - height: optionsInput.height + (grow ? reserve - currentReserve : 0), footnoteReserve: reserve, footnotePass: (options.footnotePass ?? 0) + 1, }); @@ -1061,7 +1067,7 @@ export function compileChart(spec: unknown, optionsInput: CompileOptions): Chart : []; if (seriesSummary.length) { const reserve = footnoteBandHeight( - annotationNoteLineCount(footnotes ?? []) + + Math.max(noteCapacity, annotationNoteLineCount(footnotes ?? [])) + seriesSummary.reduce((count, entry) => count + entry.lines.length, 0), theme, ); @@ -1071,10 +1077,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, { ...optionsInput, - height: optionsInput.height + (grow ? reserve - previousReserve : 0), footnoteReserve: reserve, footnotePass: (options.footnotePass ?? 0) + 1, }); @@ -1110,8 +1114,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, + footnoteBandHeight( + 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 } : {}), }; @@ -1232,7 +1245,7 @@ function compileFaceted( plan.xAxisExtent, ); const dims = computeDimensions( - panelChartSpec, + { ...panelChartSpec, chromeLayout: 'subtract' }, options, legendStub, theme, @@ -1496,16 +1509,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, @@ -1589,7 +1594,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, + footnoteBandHeight(annotationNoteLineCount(wrappedFootnotes), theme), + ), + ...(allFootnotes.length > 0 ? { footnotes: wrappedFootnotes } : {}), + }, metrics: dims.metrics, ...(dims.seriesSearch ? { seriesSearch: dims.seriesSearch } : {}), axes: { x: undefined, y: undefined }, @@ -1640,7 +1652,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, + }, + compileChart, + ); + 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..d299e30c 100644 --- a/packages/engine/src/compile/layer.ts +++ b/packages/engine/src/compile/layer.ts @@ -364,7 +364,6 @@ function compileLayerIndependent( ...optionsWithReserve, frozenChartArea: area, footnoteReserve: reserve, - height: optionsWithReserve.height + (grow ? reserve : 0), }; layout0 = compileChart(primary0, alignedOptions); layout1 = compileChart(primary1, alignedOptions); @@ -372,10 +371,7 @@ 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); } @@ -386,6 +382,18 @@ function compileLayerIndependent( layout1 = moveRightLegends(layout1, legendShift); } + // 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 ? { ...layout1.axes.y, @@ -622,10 +630,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 ?? + footnoteBandHeight( + annotationNoteLineCount(layout.chrome.footnotes ?? []) + + (layout.chrome.seriesSummary?.reduce((count, entry) => count + entry.lines.length, 0) ?? 0), + layout.theme, + ) ); } diff --git a/packages/engine/src/design/diagnostics.ts b/packages/engine/src/design/diagnostics.ts index 349b95f2..e5d44915 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" for non-faceted charts, 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/layout/dimensions.ts b/packages/engine/src/layout/dimensions.ts index f92059ce..8b99982b 100644 --- a/packages/engine/src/layout/dimensions.ts +++ b/packages/engine/src/layout/dimensions.ts @@ -249,7 +249,9 @@ 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; // 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 +268,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, }; @@ -549,7 +551,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/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/renderers/chrome.ts b/packages/vanilla/src/renderers/chrome.ts index 99647d3c..a7ad193f 100644 --- a/packages/vanilla/src/renderers/chrome.ts +++ b/packages/vanilla/src/renderers/chrome.ts @@ -30,10 +30,14 @@ import { applyTextStyle, createSVGElement, setAttrs } from './svg-dom'; * invitation for the two to drift and drop the footnotes onto the watermark. */ 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, + return ( + layout.chrome.noteBandHeight ?? + 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, + ) ); } From 94eb9e28d63b5b2e66625088b1be216037a85f43 Mon Sep 17 00:00:00 2001 From: Riley Hilliard Date: Sat, 17 Oct 2026 00:06:46 -0500 Subject: [PATCH 4/8] fix(layout): separate legends from endpoint columns --- CHANGELOG.md | 2 + docs/codebase-map.md | 5 ++ ...r-vertical-mobile-visual-mobile-darwin.png | Bin 50483 -> 46377 bytes .../bar-horizontal-gradient-visual-darwin.png | Bin 79314 -> 73820 bytes .../bar-vertical-visual-darwin.png | Bin 60797 -> 58179 bytes .../chart-with-annotations-visual-darwin.png | Bin 48384 -> 50978 bytes .../dark-mode-line-visual-darwin.png | Bin 46044 -> 46602 bytes .../legend-bottom-right-visual-darwin.png | Bin 53671 -> 52740 bytes .../scrolly-step-payoff-visual-darwin.png | Bin 34939 -> 34580 bytes .../src/testing/fixtures-lines.stories.tsx | 3 +- .../__tests__/compile-legend-gutter.test.ts | 51 ++++++++++++++++++ packages/engine/src/compile.ts | 48 +++++++++-------- packages/engine/src/compile/layer.ts | 10 +++- 13 files changed, 95 insertions(+), 24 deletions(-) create mode 100644 packages/engine/src/__tests__/compile-legend-gutter.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index a9395715..71a52364 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -23,6 +23,8 @@ - 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. ## Unreleased — 8.10 diff --git a/docs/codebase-map.md b/docs/codebase-map.md index 36128cb7..577b1aa4 100644 --- a/docs/codebase-map.md +++ b/docs/codebase-map.md @@ -393,3 +393,8 @@ transitions accept `MapTransitionSnapshot` only. | 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. 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 203297a4205af6dac1c360f856cd2a4b6df896eb..e044fb38138e9cd23313b1a15dd5aadf7c3c2025 100644 GIT binary patch literal 46377 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 S+@#<)Bu0AXx^J}|BK`-GiY9LW literal 50483 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 Oz$weC%hk(RhW#J1=;eL@ 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 22381a3dfe2078046af2dbf9047a8a2be8bd6cb5..f470434eb9319b9c9cc86843cef9a698d4e439a6 100644 GIT binary patch literal 73820 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