A visualization control changes the state that a chart is explaining. It is not a tooltip, a legend, or an arbitrary dashboard widget. Semiotic controls should be controlled, semantic, inspectable, and usable through the same chart state on pointer, keyboard, touch, and mobile HTML paths.
The control surface
DirectManipulationControl is the small shared primitive for an SVG control drawn in a frame overlay. The frame keeps its own scales; the control receives a pointer-to-value adapter. That division lets one component work over XY, ordinal, geographic, radial, and custom chart coordinates without embedding a frame dependency in the controls bundle.
4 of 8 values meet the current threshold of 60.
4 of 8 values currently meet the threshold. The control emits data-viz-control="threshold", supports pointer capture, and is a keyboard slider.
JSX
import { DirectManipulationControl } from "semiotic/controls" function ThresholdOverlay({ scaleY, width, threshold, setThreshold }) { return ( <DirectManipulationControl controlType="threshold" value={threshold} min={0} max={100} step={1} x={width - 12} y={scaleY(threshold)} label="Priority threshold" valueText={`Priority threshold: ${threshold}`} pointerToValue={(event) => scaleY.invert(pointerInOverlay(event).y)} onChange={setThreshold} labelText="drag threshold" /> ) } // The chart owns its scales. The control owns focus, keyboard nudging, // pointer capture, clamping, and a stable data-viz-control semantic.
Sentence Filter
SentenceFilter turns a readable title into a small set of controlled filters. Named placeholders become editorial-style buttons rather than a toolbar of inputs, while the application remains the source of truth. Change the amount or subject below and the compact chart is derived from the same filters object immediately.
Romeo and Juliet4
Twelfth Night4
Sonnet 1164
Much Ado2
As You Like It4
The Tempest2
The sample distributes 20 sentences across6 works. Both inline controls update this readout and chart immediately.
JSX
import { SentenceFilter } from "semiotic/controls" const [filters, setFilters] = useState({ amount: 20, subject: "love" }) <SentenceFilter as="h2" sentence="{amount} sentences about {subject}" filters={filters} definitions={{ amount: { type: "number", label: "Number of sentences", min: 10, max: 30, step: 5 }, subject: { type: "select", label: "Subject", options: subjectOptions }, }} onChange={setFilters} /> // Derive the chart from the same controlled object. const visible = sampleByWork(passages, filters.subject, filters.amount)
Six definition types, one wrapping sentence
A definition selects the appropriate editor: number, select, multiselect, range, toggle, or text with suggestions. This intentionally long sentence wraps at narrow widths, and its repeated {subject} placeholder stays synchronized in both positions.
Current query: 20 passages · plays + sonnets ·1590–1610 · annotated ·Juliet
JSX
definitions={{ amount: { type: "number", label: "Amount", min: 5, max: 50, step: 5 }, subject: { type: "select", label: "Subject", options: subjectOptions, searchable: true }, forms: { type: "multiselect", label: "Forms", options: formOptions, conjunction: "and" }, years: { type: "range", label: "Years", min: 1588, max: 1613 }, annotated: { type: "toggle", label: "Annotation status", trueLabel: "with annotations" }, speaker: { type: "text", label: "Speaker", suggestions: speakerOptions }, }}
Keyboard and screen-reader behavior
Open and close
Tab reaches each value in sentence order. Enter or Space opens it; Escape closes it and returns focus to the triggering word. Clicking outside also dismisses the editor.
Move and choose
Arrow keys move through options, Home and End jump in long lists, and select editors support typeahead. Native fields and sliders preserve their familiar keyboard behavior.
Hear the sentence
The wrapper exposes a continuous readable sentence, each button names its filter and value, and a polite live region announces changes without moving focus.
Formatting, repeated values, and custom editors
Use formatValue for visual phrasing and getAccessibleValue when that phrasing needs a clearer spoken form. renderControl receiveskey, value, filters, definition,setValue, and close; it replaces the popover editor without replacing the sentence parser or accessible trigger.
JSX
const budgetDefinition = { type: "number", label: "Budget", formatValue: (value) => `${value.toLocaleString()}`, getAccessibleValue: (value) => `${value.toLocaleString()} dollars`, } <SentenceFilter sentence="Products under {budget}" filters={filters} definitions={{ budget: budgetDefinition }} onChange={setFilters} /> <SentenceFilter sentence="Compare {subject} with other examples of {subject}" filters={filters} definitions={{ subject: { type: "select", label: "Subject", options: subjectOptions } }} onChange={setFilters} renderControl={({ value, definition, setValue, close }) => ( <ProjectSubjectPicker label={definition.label} value={value} onSelect={(next) => { setValue(next, "input"); close() }} /> )} /> // Both {subject} buttons stay synchronized; the template parser is unchanged.
API at a glance
Show the complete prop and definition reference
| Props | Contract |
|---|
sentence | Named-placeholder template. Static punctuation and whitespace are preserved, {{ and }} escape literal braces, and repeated placeholders share one value. |
filters, defaultFilters, definitions | Use filters for the documented controlled form, or defaultFilters for convenience. Definitions select and configure each inline editor. |
onChange | Receives the complete next filter object plus { key, previousValue, value, source } metadata. |
as, className, style | Choose the semantic wrapper and integrate it with the surrounding chart-title or design-system styles. |
size, align, wrap | Control title scale, alignment, and responsive wrapping without changing editor behavior. |
disabled, readOnly | Disable the component or retain the readable sentence while preventing edits. |
ariaLabel, id | Override the full-sentence accessible name and provide a stable instance identifier when needed. |
renderControl, onOpenChange | Replace only the popover editor or observe which placeholder is open; parsing and trigger behavior remain owned by SentenceFilter. |
Every definition also accepts label, description,disabled, allowClear, emptyLabel,accent, formatValue, and getAccessibleValue. Type-specific options are deliberately small:
| Type | Options | Default editor |
|---|
number | min, max, step, inputMode | Number field, slider, or both |
select | options, searchable | Single searchable option list |
multiselect | options, searchable, conjunction | Checkbox list with readable joined values |
range | min, max, step | Paired values for a numeric interval |
toggle | trueLabel, falseLabel | Two-state choice phrased for the sentence |
text | suggestions, placeholder | Free text with optional suggestions |
Option entries require value and label, with optionaldescription, count, disabled, andkeywords for richer searchable lists.
Design guidance
A good fit
Chart titles, compact exploratory controls, narrative analytics, and embedded filters with a few dimensions whose current values form a clear sentence.
Choose a form instead
Use a conventional filter panel for dozens of dimensions, complex query builders, lengthy validation, or state that cannot be explained naturally in one sentence.
The wrapper inherits surrounding typography. Stable data-semiotic-control anddata-sentence-filter-key attributes plus --sentence-filter-*custom properties support local theming without turning the values into dashboard chips.
Current control families
Direct overlays
DirectManipulationControl is for thresholds, partition boundaries, reporting windows, and other one-value manipulations embedded in a chart.
It exposes data-viz-control with one of the public semantic control types.
Range and brush
CircularBrush provides a self-contained cyclical range. XY and ordinal brushes remain frame-owned because they must track scale changes, bin snapping, streaming, and selections.
Mobile alternatives
MobileStandardControls and useMobileRangeControls provide native buttons, ranges, and legend controls that drive the same controlled state as desktop gestures.
Sentence titles
SentenceFilter embeds a small controlled filter vocabulary in readable prose, with native button triggers and definition-driven HTML editors.
Bundle strategy
Import frame-independent controls from semiotic/controls. This entry point carries React control surfaces but no frame renderer, canvas scene builder, geographic projection, physics kernel, or D3 brush lifecycle. Keep frame-specific brushes in their existing subpaths so importing a small control does not pull an XY or ordinal frame into an application.
JSX
// Frame-independent controls: no XY, ordinal, geo, network, or physics renderer. import { DirectManipulationControl, CircularBrush, MobileStandardControls, SentenceFilter, } from "semiotic/controls" // Frame-owned brushing stays close to its scale and streaming contracts. import { StreamXYFrame } from "semiotic/xy" import { StreamOrdinalFrame } from "semiotic/ordinal"
The root semiotic export remains available for compatibility. Prefer the controls entry for applications that place controls in their own shell, annotation layer, or design system.
What can be lifted, and what should stay put
| Existing surface | Status | Scope | Decision |
|---|
DirectManipulationControl | Public now | Frame-independent SVG overlay | Keep generic. It receives geometry and pointer-to-value conversion from the chart, then owns drag, keyboard, ARIA, and semantic control identity. |
CircularBrush | Public now | Cyclical range control | Move with the controls bundle. Its geometry is self-contained and it does not need frame state. |
MobileStandardControls + useMobileRangeControls | Public now | HTML fallback and mobile rail | Move with the controls bundle. It is the non-hover counterpart to chart-local gestures. |
XYBrushOverlay / OrdinalBrushOverlay | Frame-owned | Scale, bin, streaming, selection-aware range brush | Do not export raw D3 overlays. Lift shared selection semantics and accessibility conventions, while keeping scale lifecycles inside each frame. |
MinimapChart brush | Composite pattern | Overview + detail navigation | Keep as a chart HOC. A minimap is visual context plus a brush, not merely a generic control. |
DetailsPanel | Observation companion | Click/hover follow-up | Do not classify as a control. It consumes observations and supplies explanation after a selection is made. |
One contract, multiple frames
A shared control should describe state, not dictate rendering. Every frame can bind the same semantics through a local adapter: an XY threshold uses scale.invert; a geographic seam usesprojection.invert; a radial control uses angle-to-domain conversion. The control itself should not import any of those scale systems.
- XY and custom: overlay control plus an x/y scale adapter.
- Ordinal: overlay control plus a band or value-scale adapter.
- Geo: overlay control plus a projected-coordinate adapter.
- Network and physics: overlay control plus a layout-state adapter; dragging a node is not automatically a data control.
- HOCs: expose controlled props first. A control should update the same value a developer could pass directly.
Accessibility, observations, annotations, and AI
Accessibility
Controls use slider semantics, meaningful aria-valuetext, Arrow keys, Shift+Arrow, Home, End, and a visible focus state. Every drag needs an HTML or keyboard alternative.
Observability
Direct controls now emit control-start, control-change, and control-end into the existing onObservation stream. The adapter remains usable without an observation provider.
Annotations
Frame overlays default to non-interactive so labels do not steal hover. A real control explicitly opts into pointer events, remains above marks, and may drive annotation state through the same controlled value.
AI and portable recipes
Recipe controls declarations now give agents and serializers a stable vocabulary for target state, domain, keyboard alternative, annotation state, and expected control observations without serializing pointer handlers.
JS
// Portable recipe control declaration. controls: [{ id: "priority-threshold", type: "threshold", target: "priorityScore", domain: [0, 100], step: 1, label: "Priority threshold", valueText: "Priority threshold: {value}", keyboard: "slider", minimumTargetSize: 24, alternatives: ["number-input", "mobile-standard-control"], observations: ["control-start", "control-change", "control-end"], }]
Control audit
auditVisualizationControls is the portable counterpart to the scene and mobile audits. Recipe registration rejects declarations missing a semantic type, state target, ordered domain, keyboard path, value text, or 24px minimum target. It also reports observation coverage and invalid quantization steps.
JS
import { auditVisualizationControls } from "semiotic/controls" const audit = auditVisualizationControls({ controls: recipe.controls }) // Checks semantic type, state target, value domain, keyboard path, // human-readable value text, target size, and control-change observation.
Completed foundations
- Frame-agnostic control observations now forward into
onObservation. - Portable recipes can declare
controls with state, access, and observation metadata. - XY and ordinal brushes share keyboard and ARIA semantics while keeping their scale-specific D3 lifecycle in their own bundles.
- The portable control audit covers target size, keyboard path, value text, semantic type, state binding, domain, and step.