Subchapter 1.18
references/editing.mdMarkdown15 KBView on GitHub
The editing system lets users drag, select, delete, and inline-edit chart elements. Pass an onEdit callback to activate edit mode. The library fires typed callbacks for all interactions but never mutates the spec. The consumer receives events and decides whether/how to apply changes.
// React -- edit mode is on when onEdit is defined
<Chart spec={spec}
// Vanilla JS
createChart(container, spec, { onEdit: handleEdit });There are two categories of callbacks: spec-modifying edits (flow through onEdit) and UI state callbacks (separate props).
onEdit receives a ElementEdit discriminated union keyed by type. Variants cover annotation/connector drag, range/refline label drag, chrome drag, series-label drag, legend drag, legend toggle, delete, and text-edit. Load ElementEdit, ElementRef, AnnotationOffset, and ChromeKey from index.d.ts for the exact payload of each variant — the switch handlers below illustrate which fields each variant carries.
These fire for selection and text editing interactions but don’t imply spec changes:
<Chart
spec={spec}
onEdit={handleEdit}
onSelect={(element: ElementRef) => { /* element clicked */ }}
onDeselect={(element: ElementRef) => { /* element deselected */ }}
onTextEdit={(element: ElementRef, oldText: string, newText: string) => { /* text committed */ }}
/>onTextEdit fires on both the UI state callback and through onEdit as { type: 'text-edit' }. Handle it in whichever place makes sense for your architecture.
ElementRef is a discriminated union that identifies any selectable/editable element (annotation by index, chrome by key, series-label by series name, legend, or legend-entry). Load ElementRef from index.d.ts for the exact variant shapes.
Helper constructors avoid manually building these objects:
import { elementRef } from '@opendata-ai/openchart-core';
elementRef.annotation(2) // { type: 'annotation', index: 2 }
elementRef.annotation(2, 'my-id') // { type: 'annotation', index: 2, id: 'my-id' }
elementRef.chrome('title') // { type: 'chrome', key: 'title' }
elementRef.seriesLabel('Revenue') // { type: 'series-label', series: 'Revenue' }
elementRef.legend() // { type: 'legend' }
elementRef.legendEntry('Revenue', 0) // { type: 'legend-entry', series: 'Revenue', index: 0 }The optional id field on annotation refs provides stable identity across spec mutations (when annotations get reordered, added, or removed).
Each edit gives you the new offset. Write it back into the corresponding spec field to persist the position.
| Edit type | Spec field to update |
|---|---|
annotation | annotation.offset on the matching TextAnnotation |
annotation-connector | annotation.connectorOffset.from or .to on the matching TextAnnotation |
range-label | annotation.labelOffset on the matching RangeAnnotation |
refline-label | annotation.labelOffset on the matching RefLineAnnotation |
chrome | chrome[key] – change from string to { text, offset } |
series-label | labels.offsets[series] |
legend | legend.offset |
import { useState } from 'react';
import { Chart } from '@opendata-ai/openchart-react';
import type { ChartSpec, ElementEdit, TextAnnotation, RangeAnnotation, RefLineAnnotation } from '@opendata-ai/openchart-core';
function EditableChart() {
const [spec, setSpec] = useState<ChartSpec>({
mark: 'line',
data: myData,
encoding: { x: { field: 'date', type: 'temporal' }, y: { field: 'value', type: 'quantitative' } },
chrome: {
title: 'Revenue Growth Accelerates',
subtitle: 'Quarterly revenue, 2022-2024 ($B)',
},
legend: { position: 'top' },
labels: { density: 'auto' },
annotations: [
{ type: 'text', x: '2024-Q4', y: 72, text: 'Record quarter', connector: true, offset: { dx: -80, dy: -20 } },
{ type: 'range', x1: '2024-Q1', x2: '2024-Q4', label: 'Growth phase', fill: '#6366f1', opacity: 0.08 },
{ type: 'refline', y: 50, label: 'Target: $50B', style: 'dashed' },
],
});
const handleEdit = (edit: ElementEdit) => {
setSpec((prev) => {
switch (edit.type) {
case 'annotation':
return {
...prev,
annotations: prev.annotations!.map((a) =>
a.type === 'text' && (a as TextAnnotation).text === edit.annotation.text
? { ...a, offset: edit.offset }
: a
),
};
case 'annotation-connector':
return {
...prev,
annotations: prev.annotations!.map((a) => {
if (a.type !== 'text' || (a as TextAnnotation).text !== edit.annotation.text) return a;
const ta = a as TextAnnotation;
return { ...ta, connectorOffset: { ...ta.connectorOffset, [edit.endpoint]: edit.offset } };
}),
};
case 'range-label':
return {
...prev,
annotations: prev.annotations!.map((a) =>
a.type === 'range' && (a as RangeAnnotation).label === edit.annotation.label
? { ...a, labelOffset: edit.labelOffset }
: a
),
};
case 'refline-label':
return {
...prev,
annotations: prev.annotations!.map((a) =>
a.type === 'refline' && (a as RefLineAnnotation).label === edit.annotation.label
? { ...a, labelOffset: edit.labelOffset }
: a
),
};
case 'chrome':
return {
...prev,
chrome: { ...prev.chrome, [edit.key]: { text: edit.text, offset: edit.offset } },
};
case 'series-label':
return {
...prev,
labels: { ...prev.labels, offsets: { ...prev.labels?.offsets, [edit.series]: edit.offset } },
};
case 'legend':
return { ...prev, legend: { ...prev.legend, offset: edit.offset } };
}
});
};
return <Chart spec={spec} onEdit={handleEdit} />;
}This example covers drag repositioning. For deletion, text editing, and selection handling, see the full example below.
After handling edits, the spec shape looks like this:
{
// Text annotation with persisted drag positions
annotations: [
{
type: 'text',
x: '2024-Q4',
y: 72,
text: 'Record quarter',
connector: true,
offset: { dx: -80, dy: -20 }, // annotation label position
connectorOffset: {
from: { dx: 5, dy: 0 }, // connector endpoint at the label
to: { dx: 0, dy: -3 }, // connector endpoint at the data point
},
},
{
type: 'range',
x1: '2024-Q1',
x2: '2024-Q4',
label: 'Growth phase',
fill: '#6366f1',
opacity: 0.08,
labelOffset: { dx: 10, dy: 0 }, // range label position
},
{
type: 'refline',
y: 50,
label: 'Target: $50B',
style: 'dashed',
labelOffset: { dx: -5, dy: 0 }, // refline label position
},
],
// Chrome elements with persisted offsets
chrome: {
title: { text: 'Revenue Growth Accelerates', offset: { dx: 0, dy: 0 } },
subtitle: { text: 'Quarterly revenue, 2022-2024 ($B)', offset: { dx: 0, dy: 0 } },
},
// Series label offsets keyed by series name
labels: {
density: 'auto',
offsets: {
'Services': { dx: 12, dy: -4 },
'Devices': { dx: 8, dy: 0 },
},
},
// Legend position offset
legend: {
position: 'top',
offset: { dx: 20, dy: 0 },
},
}Click an element to select it. The library renders selection overlays (handles, highlight ring) inside the SVG.
const [selected, setSelected] = useState<ElementRef | null>(null);
<Chart
spec={spec}
onEdit={handleEdit}
selectedElement={selected ?? undefined}
onSelect={setSelected}
onDeselect={() => setSelected(null)}
/>selectedElement is the controlled prop. Pass it to drive selection from external UI (property panels, annotation lists, etc.). Selection persists across spec updates.
// Vanilla JS
const chart = createChart(container, spec, { onEdit: handleEdit });
chart.select(elementRef.annotation(0));
chart.getSelectedElement(); // ElementRef | null
chart.deselect();
// React via ChartHandle ref
const chartRef = useRef<ChartHandle>(null);
<Chart ref={chartRef} spec={spec} onEdit={handleEdit} />
chartRef.current?.select(elementRef.chrome('title'));
chartRef.current?.getSelectedElement();
chartRef.current?.deselect();Double-click a text annotation or chrome element to edit it inline. The library renders a text input overlay inside the SVG.
When committed, both onTextEdit (UI callback) and onEdit({ type: 'text-edit' }) fire. Handle the spec update in your onEdit handler:
case 'text-edit':
if (edit.element.type === 'annotation') {
return {
...prev,
annotations: prev.annotations?.map((a, i) =>
i === edit.element.index ? { ...a, text: edit.newText } : a
),
};
}
if (edit.element.type === 'chrome') {
const chrome = prev.chrome ?? {};
const existing = chrome[edit.element.key];
const updated = typeof existing === 'string'
? edit.newText
: { ...existing, text: edit.newText };
return { ...prev, chrome: { ...chrome, [edit.element.key]: updated } };
}
break;Press Delete or Backspace with an element selected. The library fires onEdit({ type: 'delete', element }) and the consumer removes it from the spec:
case 'delete':
if (edit.element.type === 'annotation') {
return {
...prev,
annotations: prev.annotations?.filter((_, i) => i !== edit.element.index),
};
}
break;Click a legend entry to show/hide a series. The vanilla adapter wires this up by default — it tracks a runtime hidden-series set, recompiles, rebalances the y-axis to the remaining visible series, and locks the color scale so remaining lines keep their original palette colors. Per-series UI hides too: endpoint chip, marker, dot annotation, and any text annotation anchored to the hidden series. The last visible series can’t be hidden (no-op).
onEdit({ type: 'legend-toggle', series, hidden }) and the dedicated onLegendToggle(series, hidden) callback are observation only — fire after the toggle has been applied internally. You don’t need to handle them to get the default behavior. Use them to mirror the state into your own store, persist to a URL, or override behavior.
To start with specific series hidden on first render, set hiddenSeries: string[] on the spec. Author-set hidden series can be re-shown at runtime by clicking the legend (the runtime-shown set takes precedence over the user-hidden set).
These work when the SVG has focus (click the chart first):
| Key | Action |
|---|---|
| Delete / Backspace | Delete selected element |
| Escape | Deselect current element, or cancel text edit |
| Tab | Cycle through editable elements |
| Enter | Enter text editing on selected text-editable element |
import { useState, useRef } from 'react';
import { Chart } from '@opendata-ai/openchart-react';
import type { ChartSpec, ElementEdit, ElementRef, TextAnnotation, ChartHandle } from '@opendata-ai/openchart-core';
function EditableChart() {
const [spec, setSpec] = useState<ChartSpec>(initialSpec);
const [selected, setSelected] = useState<ElementRef | null>(null);
const chartRef = useRef<ChartHandle>(null);
const handleEdit = (edit: ElementEdit) => {
setSpec((prev) => {
switch (edit.type) {
case 'delete':
if (edit.element.type === 'annotation') {
return {
...prev,
annotations: prev.annotations?.filter((_, i) => i !== edit.element.index),
};
}
return prev;
case 'text-edit':
if (edit.element.type === 'annotation') {
return {
...prev,
annotations: prev.annotations?.map((a, i) =>
i === edit.element.index ? { ...a, text: edit.newText } : a
),
};
}
if (edit.element.type === 'chrome') {
const chrome = prev.chrome ?? {};
const existing = chrome[edit.element.key];
return {
...prev,
chrome: {
...chrome,
[edit.element.key]: typeof existing === 'string'
? edit.newText
: { ...existing, text: edit.newText },
},
};
}
return prev;
case 'annotation':
return {
...prev,
annotations: prev.annotations!.map((a) =>
a.type === 'text' && (a as TextAnnotation).text === edit.annotation.text
? { ...a, offset: edit.offset }
: a
),
};
case 'chrome':
return {
...prev,
chrome: { ...prev.chrome, [edit.key]: { text: edit.text, offset: edit.offset } },
};
case 'series-label':
return {
...prev,
labels: { ...prev.labels, offsets: { ...prev.labels?.offsets, [edit.series]: edit.offset } },
};
case 'legend':
return { ...prev, legend: { ...prev.legend, offset: edit.offset } };
// ... handle other edit types
default:
return prev;
}
});
};
return (
<Chart
ref={chartRef}
spec={spec}
onEdit={handleEdit}
selectedElement={selected ?? undefined}
onSelect={setSelected}
onDeselect={() => setSelected(null)}
/>
);
}The library and the consumer have clear ownership lines:
| Library owns (inside the SVG) | Consumer owns (outside the SVG) |
|---|---|
| Selection overlay, hover states | Toolbars, property panels, buttons |
| Text editing input overlay | Undo/redo (store spec snapshots) |
| Drag handles, cursor changes | Which edits to accept or reject |
| Keyboard focus management | External selection UI (lists, trees) |
All edits are callbacks, never mutations. The consumer is always in control.
onEdit is defined – no separate prop neededonAnnotationEdit is the legacy API for text annotations only. Prefer onEdit for all new integrations – it covers all draggable elements