Skip to content

perf: selection is part of the depth sort key, so the first click re-sorts the whole dataset #461

Description

@tsenoner

Split out of #456 as an explicitly accepted regression — see the "Known follow-ups" section of PR #459.

The mechanism

baseOpacityOf (styling/visibility-model.ts) returns opacities.faded for every unselected point once hasSelection is true, and opacities.base otherwise:

const baseOpacityOf = (point: PlotDataPoint): number => {
  const isSelected = selectedIdsSet.has(point.id);
  const isHighlighted = highlightedIdsSet.has(point.id);
  if (isSelected || isHighlighted) return opacities.selected;
  if (hasSelection && !isSelected) return opacities.faded;   // 0.15
  return opacities.base;                                      // 0.9
};

getDepth derives from getBaseOpacity (styling/style-getters.ts), and composePaintDepth folds that into the within-tier term (webgl/renderer/point-staging.ts):

const withinTier = Math.min(1, Math.max(0, baseDepth)) * 0.24;

So selecting a single protein moves the base opacity of every other point from 0.9 to 0.15, which moves its composed depth. The 100-slot depth probe in populateBuffers therefore trips, needsReorder is set, and the renderer takes the full-rebuild branch: an O(N log N) comparator sort over every point, a stagePoint per point (~8 style-getter calls, two d3 scale calls), and a full set of buffer uploads.

The comment on baseOpacityOf says it "ignores hidden entirely (it feeds depth sorting)" — the intent was for depth to be stable across hide/show. It is not stable across selection, and that is what costs.

Impact

A click costs a full re-stage. At Swiss-Prot scale (573K) that is a few hundred ms; at 2,000,000 points it is roughly 1.5 s of blocked main thread.

Before #456 this was masked above 1,000,000 because the renderer only ever staged the first million points. It is not a new cost — it is a pre-existing cost that is now paid in full, on the correct number of points.

Direction

Take selection out of the sort key entirely. Sketch:

  • Add a per-point a_selected attribute (1 byte) staged alongside the existing channels.
  • Have the shader apply the faded/selected treatment from that attribute rather than from a depth tier.
  • Replace the selectedStartIndex draw-range split in drawPoints (webgl/renderer/render-target.ts) with two full-buffer passes filtered on the attribute.

The constraint to respect is the EAT knockout contract: composePaintDepth currently encodes four tiers (selected/unselected x predicted/curated), and eat-annotation-overlay binds the filled-vs-hollow encoding those tiers produce. Any redesign has to keep that ordering guarantee.

Recolouring the legend has the same shape and is tracked separately.

Acceptance

A click on a 573K dataset issues no re-sort and no position upload — only the style channels the selection actually changed. Assertable with the uploadedBytesTotal counter added in #459.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions