uPlot

Live legend selection

Status and scope

This document defines the core contract and implementation plan for live legend selection. Core implementation, typings, tests, documentation, and the demo are part of this change. Runtime and build results appear in the validation section. The full runtime suite and distribution build pass with synchronous legend updates. Browser layout and performance validation remain pending. The consumer type check encounters an existing declaration syntax error, described in the validation section.

The demo uses the core API directly with four selectable behaviors on one chart. It does not emulate selection, patch core methods, or replace the legend template. New legend UI controls remain outside this change.

Rationale

A live legend can contain hundreds of series, but a reader often needs only a small subset. A proximity filter can reduce that subset before a value sort and a page limit. Separate callbacks let applications choose these policies without new legend controls.

Selection changes the legend rows, not the chart series or their data. The existing legend markup, row bindings, markers, labels, and value cells remain unchanged. The initial scope excludes search fields, sort buttons, sort indicators, page buttons, and virtual scrolling.

API contract

The signatures below summarize the public contract.

legend: {
  filter?: (u: uPlot, si: number) => boolean;
  sort?: (u: uPlot, a: number, b: number) => number;
  limit?: (
    u: uPlot,
    filteredSortedIndices: readonly number[],
  ) => readonly [start: number, end: number];

  // Public readback, owned by core.
  order: readonly number[];
  count: number;
  range: readonly [start: number, end: number];
}

cursor: {
  trackDists?: boolean; // Defaults to false.
  dists?: readonly number[] | null;
}

Pipeline

  1. Start with all Y-series indices in original series order.
  2. Apply legend.filter(u, si) to each Y-series index.
  3. Sort the retained indices with legend.sort(u, a, b).
  4. Call legend.limit(u, filteredSortedIndices) with the complete sorted subset.
  5. Select the effective half-open range [start, end).

An omitted filter retains every Y series. An omitted sort preserves original series order. An omitted limit selects [0, count). When a comparator returns zero, original series order resolves the tie, not the previous legend order. Callback indices always refer to u.series, never to a row position.

Hidden series remain eligible unless the custom filter excludes them. For example, (u, si) => u.series[si].show explicitly excludes hidden series. A proximity filter also excludes hidden series because their distance is Infinity.

The X row never enters the pipeline and never consumes the limit. It stays first under the existing eligibility rules: mode 1, a live legend, and no multi-value legend. Mode 2 and multi-value legends keep their existing X-row behavior.

Range normalization

The limit returns two slice boundaries, not a page number or a row count. Core converts the boundaries to nonnegative integers and clamps them to the filtered count. Negative boundaries do not count backward from the end. A reversed range becomes an empty range at the effective start.

The range calculation is equivalent to:

function bound(value, count) {
  return Number.isNaN(value) ? 0 : Math.min(count, Math.max(0, Math.trunc(value)));
}

const start = bound(requestedStart, count);
const end = Math.max(start, bound(requestedEnd, count));
const range = [start, end];
const order = filteredSortedIndices.slice(start, end);

Thus, Infinity clamps to count, and -Infinity clamps to zero. The callback must return the declared numeric tuple. The readonly input is borrowed data. The callback must not mutate it or depend on its identity across updates.

Readonly arrays are a contract, not a requirement for runtime freezing. Core reuses its buffers for callback input, legend.order, legend.range, and cursor.dists. Clients must not mutate these buffers. Clients that need snapshots must copy the arrays.

Public readback

Field Meaning
legend.order Final selected Y-series indices, after all three stages.
legend.count Number of Y series after the filter, before the page limit.
legend.range Effective clamped range within the filtered, sorted indices.

An empty selection has an empty order. An empty filtered set has count: 0 and range: [0, 0]. A page beyond the end has range: [count, count] without an implicit page reset. An external pager can use count and range without access to DOM rows.

Selection does not depend on legend.show. Core updates selection before the associated setLegend and setCursor hooks observe it. The initial legend mount follows completion of selection, data, layout, and pending initial commits. Core sets its internal ready state to true and status to 1 before that mount, then calls the ready hook. Core does not render the legend during constructor setup. Cursor updates and explicit setLegend calls complete values and selection before the synchronous DOM update. After initialization, the associated setLegend and setCursor hooks observe the completed DOM. During initialization, these hooks can run before the initial mount. Mutation methods can leave selection pending until the next normal update.

legend.values, legend.idxs, and cursor indices retain their original series indexing. Core does not compact or reorder these arrays. Existing value and values formatters remain compatible, including formatters for series outside the selected page. Selection does not introduce lazy formatting or change existing formatting eligibility.

Explicit refresh

Callbacks can read application state through closures. Core cannot detect a change to that state. An explicit u.setLegend({}) refreshes the callbacks despite unchanged indices and formatted values. Explicit idx or idxs overrides in setLegend update legend indices but do not recalculate physical cursor distances. cursor.dists continues to describe the physical cursor state, not those legend overrides.

let offset = 0;
const pageSize = 8;
const legend = {
  limit: () => [offset, offset + pageSize],
};

// After the application creates u with this legend configuration:
offset = 8;
u.setLegend({});

Callbacks must not mutate core selection inputs or chart state. With selection callbacks configured, each cursor update or explicit refresh evaluates selection once. Without another state change, the subsequent commit does not repeat that selection. A cursor operation that changes focus and values renders the legend once, after both changes.

Selection changes emit setLegend without unnecessary value formatting. The setCursor hook also observes current selection after every cursor update.

Standalone addSeries() and delSeries() only mark selection and rendering dirty, for both visible and hidden legends. Neither method schedules a legend frame, a selection microtask, or a new canvas commit. Selection and DOM rows remain unchanged until an explicit refresh or a normal chart commit. This intentionally changes the previous behavior for visible legends.

The next cursor update, explicit setLegend({}), or normal commit consumes this work. A normal commit can follow setData(data) or redraw(). The refresh completes selection and updates the DOM synchronously before the associated setLegend hook. The addSeries and delSeries hooks can observe the previous selection. Structural changes inside a setLegend hook also remain pending until another refresh.

Before the next refresh, supply data columns that match the changed series array. For a legend-only refresh, use this sequence:

u.addSeries(newSeries, index);
u.setData(newData, false);
u.setLegend({});

This sequence keeps selection callbacks away from unaligned data and does not force a canvas commit. Deletion requires the same data alignment before refresh.

Visibility and focus mutations also leave selection pending for the normal commit. Several mutations in one synchronous sequence share one selection pass at the existing normal commit. That commit updates the DOM synchronously, without a later legend frame. The setSeries hook reports the new series state, but its selection readback can still reflect the previous update.

setData(data, false) preserves pending distance work for the next commit, such as redraw(). It does not force an immediate cursor update. With distance tracking or selection callbacks, active-cursor resize uses a normal cursor and legend-value refresh. This can rerun formatters and emit setLegend even when indices are unchanged. Cursor movement still avoids formatting when only selection changes.

Legend layout and position

legend: {
  mode: 'table',
  side: 1,
}

legend.mode accepts 'inline', 'table', or null. The default is null, which preserves the existing inference from multi-value formatting. Scalar legends use inline layout by default. Multi-value legends use table layout by default. Explicit layout does not change value formatting, column keys, X-row eligibility, or selection. A scalar table uses series.value and has no column header for the internal _ key. Multi-value legends retain their named column headers. With live: false, both layouts show labels without value cells or the X row.

legend.side uses the same order as axes and padding:

Value Position Legend class
0 Top, below the title .u-top
1 Right .u-rgt
2 (default) Bottom .u-btm
3 Left .u-lft

Mode and side are initialization options, not mutable layout controls. Neither option implies the other. A right-side table requires both options. The title spans the layout. Left and right legends align with the top of the chart wrapper, including axes. Their default horizontal separation is 16px.

Every chart root uses one fixed grid, including the default bottom layout:

title  title  title
  .     top     .
left   plot   right
  .    bottom   .

The side columns use max-content. The center column uses auto, so top and bottom inline legends can wrap. Position classes belong to the legend table, not the root. The default legend also receives .u-btm for external CSS targeting. The root no longer has a data-legend-side attribute. A direct canvas occupies the plot area when the chart has no separate wrapper.

Chart width and height exclude the legend and title. A side legend increases the component’s outer size instead of reducing the chart dimensions. Applications control available space, overflow, and maximum legend dimensions through CSS. There is no automatic legend measurement or canvas resize. A custom legend.mount callback can move the legend outside this layout. Placement styles apply only to the root’s direct children.

Every legend render invalidates the cached overlay rectangle because grid dimensions can move the plot, including with bottom placement. The next pointer event or u.rect access refreshes it. Rendering does not read geometry eagerly.

Cursor distances

cursor.trackDists: true opts into a stable, series-indexed cursor.dists array. Core updates this array in place across cursor updates and adjusts its length after series changes. The opt-in does not require focus dimming or an enabled focus proximity threshold.

For an available Y value, the distance is:

Math.abs(focus.dist(u, si, dataIdx, seriesValuePos, cursorValuePos))

This reuses the existing focus distance callback and its arguments. The default measures CSS-pixel distance along the value axis, not Euclidean distance to the line. A custom callback can define different units. The 5px demo assumes the default callback.

Distances precede focus bias and focus threshold decisions. The X entry, hidden series, missing values, unavailable distances, and inactive cursor positions use Infinity. Core clears stale distances on cursor exit and series visibility changes. Distance calculation respects the series scale, axis orientation, and selected data index.

Core must calculate requested distances even without hover point markers or focus dimming. When both focus and distance tracking need a distance, core can reuse one callback result. When tracking is off, cursor.dists is null and the feature adds no distance buffer or tracking-only scan.

Invalidation and performance

Value invalidation and selection invalidation are separate concerns. A cursor move along the value axis can change proximity without a new data index or formatted value. A raw value change can alter sort order despite unchanged formatted text.

Change Required selection work
Active cursor move Refresh distances and selection, including moves at the same data index.
Data or cursor-selected indices Refresh relevant values, distances, and selection.
Explicit setLegend index overrides Refresh legend values and selection without recalculation of physical cursor distances.
Scale, size, or orientation Refresh distances and dependent selection.
Series visibility or focus Mark selection dirty for the normal commit.
Series addition or removal Align index buffers and mark selection and rendering dirty. Wait for an explicit refresh or normal commit.
Explicit setLegend({}) Refresh callbacks for external closure state.
Cursor exit Clear distances and refresh selection.

Arbitrary callbacks are opaque. Core cannot infer their dependencies from their source or result. A selection-dirty flag records pending work from data, layout, and series mutations. With selection callbacks configured, cursor updates and explicit refreshes evaluate selection regardless of that flag. The normal commit evaluates selection only when work remains pending. The render helper consumes a separate render-dirty flag and updates only the DOM. It does not evaluate selection, format values, or schedule work. This separation avoids duplicate callback passes without tying selection to formatted value changes. There is no legend animation-frame scheduler, replacement microtask scheduler, or legend.rAF option. An unrelated redraw does not refresh external closure state. That state requires setLegend({}).

For N Y series and M filter matches, filtering costs O(N) callback calls. Sorting then costs O(M log M) comparisons, rather than a full O(N log N) sort. These bounds assume constant-time callbacks. Applications control callback costs. The final slice costs up to the selected page size.

The page limit bounds the number of Y rows in the DOM, not the formatting work or the distance scan. Core reuses selection buffers and compares the final order before it requests structural DOM updates. The existing keyed renderer can retain row identity without template changes.

Without selection callbacks, core fills legend.order directly with the Y-series indices in one pass. It does not allocate a candidate buffer for this path. Repeated setLegend({idx}), setLegend({idxs}), and setLegend({}) calls reuse the existing order unless selection is dirty. Value formatting and hook delivery remain unchanged. Series insertion and removal still mark selection dirty.

Core allocates the candidate buffer only when selection callbacks require it. After callers remove all callbacks, setLegend({}) restores identity order once and releases the buffer. These changes remove unnecessary selection passes. The sort comparator wrapper is created once per chart and reads the current legend.sort callback. Hook dispatch and explicit legend-index copies retain forEach for readability. Native sorting, value formatters, and DOM membership changes can still allocate memory. Wall-clock performance measurements remain pending. Removal of animation-frame scheduling does not guarantee a performance gain. Synchronous DOM updates remove that delay but do not combine separate operations into one frame. Callback costs, formatting, DOM changes, layout, and paint still determine runtime cost.

Experiment: hover points follow legend membership

Status: implemented as an uncommitted experiment after checkpoint 8ba7e46 (Add live legend selection and layout controls). The demo now restricts hover points to the final legend membership.

Scope and default behavior

Hover points always follow the final legend membership. There is no followLegend option or escape hatch. This changes point visibility only, not which series the chart plots. All configured per-series point elements remain initialized in the DOM. Core retains its existing point-position and style calculations. Legend membership adds one visibility condition, without lazy point creation, pooling, or another scheduler.

Legends without membership restrictions retain the existing hover-point behavior. The unrestricted path must remain near-free, without a membership scan, buffer allocation, or DOM writes.

Lifecycle and visibility

The current cursor loop calculates active indices, distances, and point presentation before it resolves legend selection. Point callbacks can therefore observe the previous legend.order or an incomplete distance array. cursor.points.show is an element factory, not a per-update visibility predicate.

The internal visibility step runs after the final legend.order is available. It first determines whether the final order excludes any Y series. When membership is unrestricted and no previous restriction needs cleanup, it returns immediately.

For restricted membership, the step uses this sequence:

  1. Mark the selected Y-series indices in a reusable membership buffer.
  2. Visit the existing point elements.
  3. Apply a separate visibility class to points outside the selected set.

The effective rule is:

point visible = normal cursor-point visibility AND legend membership

The membership class, .u-excl, remains separate from .u-off. Core already uses .u-off for missing values, hidden series, cursor exit, and points outside the plot. Membership must not override those conditions or disappear during ordinary point positioning.

Core updates membership before public hooks, not in a public setLegend hook. Suppressed hook delivery does not suppress point visibility updates. The source of membership is legend.order, not legend DOM rows. Selection completes before the synchronous DOM update.

Required update paths include new X indices, Y-only proximity changes, explicit setLegend({}), and data, layout, visibility, or structural changes. The implementation refreshes the membership gate whenever selection evaluates, without another dirty flag. This includes structural changes even when the selected numeric indices remain unchanged.

Semantics

The single-point gate also updates inside the existing point-position block when the chosen series changes. This handles updates that do not evaluate legend selection. Series insertion and deletion adjust the shared marker’s existing owner index. Deletion of its owner hides the marker until a cursor update. Index changes also set the existing cursor-refresh flag, so the next cursor update refreshes index-dependent point styles. Choosing the closest point among legend members remains outside scope. Markers for idle fallback values also remain outside scope because those formatters do not change physical cursor indices.

Complexity and performance

Restriction depends on actual selected membership, not merely the presence of callbacks. Core produces unique Y-series indices, so this constant-time check identifies a restricted order:

const restricted = legend.order.length < series.length - 1;

The unrestricted fast path covers:

With no legend feature, hover points also retain their existing behavior. The implementation performs the check in the selection path, not inside the ordinary per-series point-update loop. No restriction means no membership scan, buffer allocation, or DOM writes, except when a previous restriction needs cleanup. This is near-free behavior, not a claim of zero additional instructions.

When membership changes from restricted to unrestricted, core removes the membership-hiding class once. This restores normal point visibility without changing .u-off or overriding other visibility conditions. Examples include removing callbacks, selecting Per page: 0, or leaving the plot in the Nearby demo. Subsequent unrestricted updates return immediately. A chart that never restricts membership needs no cleanup.

For N series and K selected entries, a restricted membership pass costs O(N + K). Core allocates membership storage only when restriction first requires it and reuses that storage for later restricted updates. Class changes occur only when membership visibility changes. Repeated legend.order.includes(si) calls inside the series loop can cost O(N × K) and are unnecessary.

This approach reduces visual clutter, not point-element count or existing position/style calculations. It requires no new microtask, animation frame, or selection cache. These are structural cost estimates, not benchmark results.

The production diff adds 41 net lines across src/uPlot.js, src/domClasses.js, and src/uPlot.css. It adds one helper, one visibility class, and one lazy array, cursorPtMembers. A null buffer means that membership was never restricted. An empty buffer means that restriction ended and cleanup completed. An active buffer has one entry per series. Its length also indicates whether the single-point gate needs an update. There is no additional dirty flag, scheduler, or cached copy of legend.order. No public option or type declaration changes. Existing point-position and style calculations remain in place.

Bundle measurements before the fixed-grid layout change

These figures isolate the hover-point experiment and predate the position-class and fixed-grid changes. Both measurements use npm run build and Python gzip.compress(data, compresslevel=9, mtime=0). The baseline is the checkpoint build, not the older generated distributions stored in Git.

Artifact Baseline bytes Experiment bytes Change Baseline gzip-9 Experiment gzip-9 Change
dist/uPlot.esm.js 210,195 211,363 +1,168 60,493 60,793 +300
dist/uPlot.iife.min.js 75,187 75,569 +382 31,629 31,792 +163
dist/uPlot.min.css 2,794 2,815 +21 979 982 +3

Minified JavaScript and CSS together add 403 bytes before compression, or 166 bytes with separate gzip compression. These measurements include the shared-marker index adjustment for series insertion and deletion. These bundle measurements do not measure runtime cost or browser layout.

Validation coverage

Compatibility and implementation plan

With all stages omitted, the legend keeps its current membership and order. Series toggles, isolation, focus, custom labels, markers, and destruction retain their existing behavior. The initial mount occurs after pending initial commits finish, before the ready hook. Standalone structural changes require an explicit refresh or a normal commit, including for visible legends. Rows continue to bind events to their original series, not their selected position.

The implementation plan for this change is:

  1. Trace initialization, cursor updates, setLegend, and series lifecycle paths in src/uPlot.js.
  2. Add distance tracking without an additional focus callback call for the same calculation.
  3. Add selection state and separate selection invalidation from value invalidation.
  4. Update readback before hooks, including with legend.show: false.
  5. Pass the selected indices to src/legend-dom.js without changes to src/legend-dom-template.js.
  6. Preserve full series-indexed values and existing formatter behavior.
  7. Add public declarations and contract tests.

Demo configurations

The demo shares the log-scale demo’s historical dataset through demos/data/log-scales.js. It contains 24 hours of player counts for 12 named Minecraft servers, from July 17–18, 2020. The logarithmic Y axis separates clusters of smaller servers from outliers such as Hypixel. Zero counts use 1, as in the log-scale demo.

One chart starts with a scalar table legend through legend.mode: 'table' and a right-side position through legend.side: 1. A mode selector offers inline and table layouts. A position selector offers top, right, bottom, and left. Mode and position changes recreate the chart because legend.mode and legend.side are initialization options. The demo preserves behavior, control values, cursor position, scale bounds, and series visibility. An external behavior selector starts with a simple five-server limit, without filtering or sorting. The other behaviors show top servers, select nearby servers, or rank all servers. Behavior changes replace all three callbacks and call setLegend({}), without chart recreation or cursor movement.

A Per page selector appears for Limit only and Top servers. It offers 0 and 5–50 in steps of five, with a default of five. Zero shows all eligible series. Values above the available count also show all eligible series. Limit changes call setLegend({}) without chart recreation. The selected limit persists across behavior and position changes. A proximity dropdown appears only for the nearby behavior: 5px, 10px, 15px, 20px, or Infinity. The default is 20px. The selected proximity persists across behavior changes. Proximity changes update callback state and call setLegend({}) without cursor movement.

Configuration Pipeline
Limit only (default) No filter or sort → first N Y series, with N from Per page (0 selects all). All series remain plotted.
Top servers: latest when idle No filter → descending player count → first N matches. Idle values use the final sample.
Nearby servers All visible series while idle. During hover: finite distance within the threshold → descending player count, without a page limit.
All servers No filter → descending player count, without a page limit.

The comparator reads raw data, not formatted legend text. Active hover uses u.data[si][u.legend.idxs[si]]. Top servers uses each series’ final sample when u.cursor.idx is null. Its value formatters use the same fallback, including the final timestamp for the Time row. The demo preserves the default time formatter and does not override cursor indices or distances. A missing or nonfinite final sample displays -- and sorts last. The demo does not search backward for a valid sample. Other behaviors retain their existing idle placeholders. Missing and nonfinite values sort last. Equal values retain original series order. During hover, Infinity removes the distance threshold but still excludes hidden series and missing hover values. Without a hover, Nearby servers shows all visible series in original series order, with placeholder values, regardless of the proximity threshold. Cursor exit restores these rows. Active hover in an empty gap can still produce no matches. Pagination remains covered by core regression tests, rather than a fixed-page demo. External text shows count, range, and order. The setLegend hook refreshes this text, including selection-only moves along the Y axis without a new data index. The demo does not repeat the same string construction and DOM write in a setCursor hook.

Nearest N series followed by a value sort is outside the initial scope. That operation needs a distance rank before the value sort, unlike the distance-threshold filter in this demo.

Future UI ownership

A future UI owns declarative state for search, filters, sort columns, direction, and pagination. It compiles that state into the core callbacks. Core does not infer a sort indicator from an arbitrary comparator. Multiple sort rules compile into one comparator that evaluates each rule until a comparison is nonzero. The UI derives sort direction and priority from its own state, not from callback inspection.

A generic UI can label an opaque callback as “Custom filter” or “Custom order.” Application metadata can supply a description, such as “Within 5px of cursor.” A description does not make the callback editable through generic controls. Declarative state and callback descriptions remain separate contracts.

UI filters and application filters compose with logical AND. Sort precedence between UI state and an application comparator remains unresolved. A later design must choose override rules or an explicit comparator chain. An external pager owns its offset and uses legend.count and legend.range for its display. One component must own the effective range. A later demo can add Previous and Next buttons, name search, sort indicators, and a multi-sort control outside the legend.

Validation

Current validation after removal of legend animation-frame scheduling:

Historical validation results before removal of legend animation-frame scheduling:

These results do not validate the synchronous legend changes.

The new membership fixture contains 18 cases, including visible and hidden legends and selection-only structural updates. An earlier full-suite run timed out after coverage output. The latest run completed within the same 120-second limit.

The runtime fixtures include test/legend-selection.mjs, test/legend-selection-demo.mjs, test/legend-layout.mjs, and test/legend-cursor-points.mjs. They use Happy DOM and the existing canvas instrumentation. Demo checks cover cluster selection, isolated outliers, empty gaps, and changing server rankings across the day. They also cover every proximity setting, stationary-cursor refresh, unavailable distances at Infinity, and control cleanup. Top-server tests cover idle fallback, hover transitions, timestamps, limits, missing samples, and unchanged cursor indices. Behavior-switch tests cover the initial limit, callback replacement, preserved cursor position, and chart reuse. Control tests cover limit changes, both layouts, all four positions, state preservation during chart recreation, and listener cleanup. The extracted history exactly matches the original log-scale dataset. Layout tests cover explicit modes, all four sides, label-only legends, custom mounting, resize, and deferred overlay-rectangle refresh. These tests use mocked geometry, not browser layout. The full suite also covers existing legend contracts, cursor behavior, layout, and keyed row interactions.

The consumer type fixture is test/legend-selection-types.ts. This command reaches an existing syntax error in dist/uPlot.d.ts:

npm exec --offline --package=typescript@5.9.3 -- tsc --strict --noEmit --skipLibCheck --target es2020 --module commonjs test/legend-selection-types.ts

PathBuilderDiscreteValues uses the reserved word enum as a tuple element name. TypeScript reports six parser errors, which --skipLibCheck cannot suppress. This change leaves that unrelated declaration unchanged. The consumer type check remains blocked.

The validation checklist covers these contracts:

Measurements must separate callback costs, full formatting, DOM reconciliation, and browser layout. The existing legend benchmark provides a starting point, not evidence for this implementation. A temporary Firefox 157.0.1 headless harness checked physical grid placement, dimensions, wrapping, custom mounting, and the actual demo. The final run passed 911 assertions across 89 current geometry snapshots and 32 checkpoint comparisons. Left/right legend dimensions matched checkpoint CSS. Top/bottom inline legends wrapped across resize, and unused tracks collapsed without spacing. The direct canvas matched the root’s size and offset when the root also served as the chart wrapper. The harness and its isolated browser profile were removed after validation. Long unbreakable top/bottom values can exceed the plot width and lose plot-centered alignment. The same behavior occurred with checkpoint CSS. This validation does not cover Chromium, WebKit, or browser performance.