Linked analytics and chart presentation (experimental)

@honua/sdk-js/analytics is the versioned seam between accepted analytics results and whatever draws them. It is an experimental slice of #682.

Honua does not ship a chart suite and is not going to. What it does ship is the contract that lets a server-pushdown widget model, a bounded columnar reduction, a small accessible default presentation, and a specialist chart library participate in one filter / selection / temporal / capability / provenance model.

Two rules make the seam worth having:

  1. No renderer objects in SDK core. An adapter receives an AnalyticsArtifact and an AnalyticsPresentationHost, and emits protocol-neutral AnalyticsInteraction values. Canvas contexts, chart instances, and library option objects never cross back.
  2. No duplicate data ownership. An artifact is accepted once and shared by reference. Presentations own no persistent cache, and a later artifact is resolved by identity into an explicit patch / invalidate / ignore decision.

The artifact

An artifact is one of four kinds — category, histogram, aggregate, time-series — and always declares units, null handling, ordering, source identity, and provenance. Marks carry a stable key that is the interaction identity: a click, a hover, and a brush all resolve back through it.

Field Why it exists
identity.artifactId / sourceId / planFingerprint Lineage. A change to any of these invalidates a mounted presentation.
identity.sequence Orders deltas within one lineage. A lower sequence is a late delta and is ignored, so numbers never rewind.
identity.freshness authority / observedAt / staleAfter / validator, matching ColumnarFreshnessV1 so one badge serves both.
provenance.computedBy / pushdown Where the numbers were aggregated. A client reduction can never claim pushdown by default.
provenance.bounds Structured truncation: truncated, rowBudget, scannedRowCount, transferredRowCount. Count fields are absent rather than approximated when the producer does not expose them.
nullPolicy excluded, counted-as-zero, separate-bucket, propagated-as-null, or unknown — never implicit.
ordering Declared once and validated at accept time, so every linked presentation shows the same sequence.

Statuses are ready, partial, stale, unsupported, and error. All five render; none of them is allowed to look authoritative when it is not.

Accepting from a widget source

createWidgetSource() already prefers OData $apply, source.queryAggregate, and typed protocol histogram/time-series adapters, and only falls back to a bounded client scan. The bridge preserves that decision instead of flattening it:

import { createWidgetSource } from "@honua/sdk-js/contract";
import {
  acceptWidgetCategoriesArtifact,
  assertAnalyticsPushdown,
} from "@honua/sdk-js/analytics";

const widgets = createWidgetSource(source, { maxClientRows: 10_000 });
const response = await widgets.categories({ field: "status" });

const artifact = acceptWidgetCategoriesArtifact(response, {
  artifactId: "incidents-by-status",
  title: "Incidents by status",
  rowBudget: 10_000,
});

// Dashboards that must never pull a dataset to the browser can enforce it.
assertAnalyticsPushdown(artifact.provenance, "The status widget");

When the widget source had to bound a client-side scan, the artifact comes back partial, provenance.bounds.truncated is true, and the accessible projection says so in words. Nothing silently rounds up to "complete".

bounds.transferredRowCount is only populated on the pushdown path, where the aggregate rows genuinely are the transfer. After a client or mixed reduction the rows that crossed the wire are the underlying features — a number the widget response does not expose — so the field is left absent and a provenance note says why. Reporting "5 rows transferred" for five buckets distilled from a 10,000-row scan would understate the cost by three orders of magnitude, and an absent number is easier to handle correctly than a confident wrong one.

Linking to shared exploration state

bindAnalyticsToExploration() owns the mapping from interactions to the shared filters and selection slices — the same slices the MapLibre and table bindings in @honua/sdk-js/interactions already use.

Interaction Shared-state effect
mark-select on a category = or in clause on the dimension (is-null for the null bucket); publishes the mark's targets as selection when it has them
mark-select on a time series between clause on the temporal clause id — clicks and brushes share one temporal contract
range-brush between clause on the range clause id
temporal-brush between clause on the temporal clause id
hover Ephemeral. Shared with peer presentations through the binding, never written into the reducer, so it cannot pollute a shareable snapshot
clear Clears the channels this view owns

Interactions also carry the rendered artifact sequence when emitted by the built-in presentations and the µPlot adapter. A session ignores an event whose artifactId or artifactSequence no longer matches the accepted artifact, and reports the mismatch through onWarning. This closes the realtime race where a queued click or brush from an older chart could otherwise apply its mark keys to newer data. Third-party adapters should copy both identity fields from the mounted artifact into every emitted interaction.

Every commit is invertible. apply() captures the previous values of only the slices it touched and returns an undo() that restores them — never a whole-state snapshot restore, so a concurrent change published by a peer view survives an undo of a chart interaction.

An overflow ("other") bucket is explicitly non-filterable: selecting it writes no clause rather than a clause that would quietly match the wrong rows. A mixed null-plus-value selection keeps the value clause and drops the null bucket, rather than widening to is-null OR in (...) in only the protocols that could express it.

Two subtleties the binding handles for you:

One artifact, many presentations

import {
  createAnalyticsAdapterRegistry,
  createAnalyticsLinkedSession,
  createDefaultAnalyticsPresentation,
} from "@honua/sdk-js/analytics";

const registry = createAnalyticsAdapterRegistry({
  adapters: [createDefaultAnalyticsPresentation()],
});

const session = createAnalyticsLinkedSession({ view, artifact, registry });
await session.present({ target: panel });                 // visual
await session.present({ id: "a11y", headlessOnly: true }); // text equivalent

session.accept(nextArtifact); // patch / invalidate / ignore, decided by identity
session.undo();               // exact inverse of the last committed interaction
session.dispose();            // every presentation, listener, and peer released

Both presentations receive the session's own artifact reference. There is no second copy of the numbers to drift.

Presentation peers are treated as disposable views, not authorities. If a peer throws while accepting a newer artifact or linked state, the session releases that presentation, keeps the accepted artifact and healthy peers, and calls onWarning with the adapter, presentation, artifact, and error. This prevents a failed optional chart from leaving a realtime session split between old and new artifacts; hosts can use the warning to remount a fallback or surface their own diagnostic.

The default presentation

createDefaultAnalyticsPresentation() renders the shared table model as a real <table> whose value cells carry a proportional bar, so one DOM tree serves both the visual and the assistive-technology reading. Rows are focusable buttons (keyboard mark selection) and brushing uses a native <input type="range"> pair rather than a drag surface. Null measures render as no data, never as 0.

The brush inputs are initialized from the current linked state (via analyticsBrushIndices()), not from the artifact's extent, so the range the user just brushed — or one a peer presentation published — survives the rerender instead of snapping back to full width.

analyticsTableModel() is the renderer-neutral truth behind it — a caption, a status banner, columns, rows, and a full text description. Chart adapters use it for accessibleDescription, and the registry's fallback adapter renders it when no chart adapter can honestly present an artifact. That is how an unsupported visualization request degrades to a truthful table instead of an empty box:

const resolution = registry.resolve(artifact);
resolution.fallback; // true when every chart adapter declined
resolution.rejected; // [{ adapterId, reason: "kind-not-supported", message }]

Writing an adapter

An adapter is four things: an id, the kinds and channels it handles, a cheap synchronous describeSupport(), and a mount() that returns a disposable handle. Use createDisposableHandle() to inherit the shared update-disposition and disposal semantics — it consults resolveAnalyticsUpdateDisposition() for you and permanently rejects update() after dispose(), so a late realtime delta cannot resurrect a torn-down chart.

Declining is a feature. An adapter that cannot honestly render an artifact should say so with a machine-readable reason and let the registry fall back.

Reference adapter: µPlot

@honua/sdk-js/analytics/uplot proves the contract against a real library. µPlot is small (~50 KiB), MIT-licensed, and widely deployed for time-series rendering.

import { createUplotAnalyticsAdapter } from "@honua/sdk-js/analytics/uplot";

registry.register(createUplotAnalyticsAdapter({ stroke: "#2563eb" }));

Install the optional peer with npm i uplot. It is declared in peerDependenciesMeta as optional and is reached only through a dynamic import with a variable specifier inside mount(), so no bundler pulls it into a Honua entrypoint. createUplotAnalyticsAdapter({ module }) injects a module directly, following the same seam as loadDeckGlPeers and loadApacheArrow.

The adapter:

projectAnalyticsArtifactToUplot() is the pure projection underneath, so the whole artifact-to-options mapping is unit-testable without a canvas.

Bundle cost

The /analytics barrel exports no adapters, and the bundle gate enforces it structurally: both /analytics and tree-shake:analytics-core declare forbiddenInputs on dist/src/analytics/adapters/ and node_modules/uplot/, so a build fails if either is ever retained. /analytics/uplot carries the same peer exclusion. Root, /honua, and browser entrypoints are unchanged — the subpaths are experimental and are not re-exported from any barrel.

See docs/bundle-sizes.md for current measurements.

Limits