Skip to content
Merged
Show file tree
Hide file tree
Changes from 48 commits
Commits
Show all changes
57 commits
Select commit Hold shift + click to select a range
cc42ba1
Add RequestSlot<K,V> primitive (Track A, step A1)
xinaesthete Jul 16, 2026
02cf3ab
Slotify points preload + rowCodes; close races R1, R5 (Track A, step A2)
xinaesthete Jul 16, 2026
d4d2200
Slotify the points matching scan; close races R2, R3 (Track A, step A3)
xinaesthete Jul 16, 2026
81fb2bd
Slotify the points catalog; make failures retryable + add retry() (Tr…
xinaesthete Jul 16, 2026
c43a0fb
Thread AbortSignal through the points scan; cancel superseded work (T…
xinaesthete Jul 16, 2026
57fdba5
Plan points row-codes + feature scan from the reconcile effect (Track…
xinaesthete Jul 16, 2026
352090f
Run the Effect-vs-plain matching-slot spike; plain wins (Track A, ste…
xinaesthete Jul 16, 2026
36cb627
Kill the streaming-overlay flash: stable partial resource + revision …
xinaesthete Jul 16, 2026
913501c
docs: mark Step 2 Track A (points state model) landed in the handoff
xinaesthete Jul 16, 2026
5a2073a
Coverage-gate the points base layer: never show the wrong gene (rende…
xinaesthete Jul 17, 2026
3f6ab04
Unify the points base into one stable resource; kill the base flicker…
xinaesthete Jul 17, 2026
d72f464
Fix points show/hide + colour policy (three render-time bugs)
xinaesthete Jul 17, 2026
5c03587
Colour points from a palette texture (LUT) instead of a shader formul…
xinaesthete Jul 17, 2026
60a5372
Per-feature colour overrides via the palette LUT + panel picker (stag…
xinaesthete Jul 17, 2026
4b9ca83
Fix the feature colour-swatch layout
xinaesthete Jul 17, 2026
a135291
Hover a feature in the list to highlight its points (feature highlight)
xinaesthete Jul 17, 2026
c9a0ae2
Merge remote-tracking branch 'origin/main' into claude/points-impleme…
xinaesthete Jul 20, 2026
0ee6784
Stream the points preload by row group (P3 / D3)
xinaesthete Jul 20, 2026
e72e636
Fix streamed preload dropping per-row feature codes (P3 regression)
xinaesthete Jul 20, 2026
cd14b96
Never publish short per-row codes from the streamed preload
xinaesthete Jul 20, 2026
91437ae
Stop the colour palette depending on the feature catalog
xinaesthete Jul 20, 2026
0528078
Show the feature list before the counts scan finishes
xinaesthete Jul 20, 2026
62b814d
Accumulate per-feature stats while the preload streams
xinaesthete Jul 20, 2026
bc6eba9
Stream the feature catalog instead of decoding the whole file
xinaesthete Jul 20, 2026
c912423
Never let a stalled feature stream strand the catalog
xinaesthete Jul 20, 2026
b6f6075
Probe range support before handing a URL to the streaming reader
xinaesthete Jul 20, 2026
5a38092
Probe the server, not the HTTP cache, for range support
xinaesthete Jul 20, 2026
71a5375
Map row feature codes through the dictionary, not per row
xinaesthete Jul 20, 2026
ea8a04f
Stream geometry and colour together in the points preload
xinaesthete Jul 21, 2026
9f671cc
Don't scan for features the resident batch already holds
xinaesthete Jul 21, 2026
42992ca
Stop fetching whole parquet parts the worker will never read
xinaesthete Jul 21, 2026
ac66ee1
move point size control into PointsLayerPanel
xinaesthete Jul 22, 2026
179631e
allow finer point size control
xinaesthete Jul 22, 2026
b94e900
Keep deck's points `data` object stable across renders
xinaesthete Jul 22, 2026
054b9a4
Size points in the element's own coordinate units
xinaesthete Jul 22, 2026
46d5b89
Stop the preload's preview catalog from killing the full scan
xinaesthete Jul 22, 2026
028327e
Traverse part.N.parquet when a server 500s the directory path
xinaesthete Jul 27, 2026
15f84f6
Resolve parquet part layout once per path instead of per call
xinaesthete Jul 27, 2026
8b63d12
Give row-group chunks their own footer bytes (fixes DataCloneError)
xinaesthete Jul 27, 2026
e5c9033
Hoist Arrow column reads out of the points scan loops
xinaesthete Jul 27, 2026
1aa9fd3
Hoist table.numRows out of the points scan loop conditions
xinaesthete Jul 27, 2026
1e0cdca
Add the alt dev-server entry, and note the scan's output allocation
xinaesthete Jul 27, 2026
32cc192
Accumulate scan output into reserved typed buffers
xinaesthete Jul 27, 2026
be64b65
Route the feature scan through ParquetFile.stream
xinaesthete Jul 27, 2026
478e568
Persist points feature selections as names, not codes
xinaesthete Jul 27, 2026
102c9e9
Document the points feature API for headless/MDV consumers
xinaesthete Jul 27, 2026
b91caf0
Stream the feature scan in the worker, off the main thread
xinaesthete Jul 27, 2026
afd1299
Add changeset and record the feature-counts settle bug
xinaesthete Jul 27, 2026
e2da032
Reframe the changeset: the feature scan now works, rather than being …
xinaesthete Jul 27, 2026
8d1a875
Stop transient failures from sticking in three caches
xinaesthete Jul 27, 2026
384931f
Make an unhonourable feature filter match nothing, not everything
xinaesthete Jul 27, 2026
9a46cf3
Record the probe-cache mechanism against the counts punchlist item
xinaesthete Jul 27, 2026
615c926
Do not hand PointsLayer a feature filter it will decline to apply
xinaesthete Jul 27, 2026
57f77fd
Guard the points batch reads against stale resolutions
xinaesthete Jul 27, 2026
4616ec7
Gate the row-codes plan on the cap, not on readiness
xinaesthete Jul 27, 2026
4614d5f
Clear the remaining review findings
xinaesthete Jul 27, 2026
dfa670b
Coalesce the colour write without a ref read during render
xinaesthete Jul 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions .changeset/points-feature-scan-and-name-selection.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,31 @@
---
"@spatialdata/core": patch
"@spatialdata/layers": patch
"@spatialdata/vis": patch
---

Points: name-based feature selection, and a much faster feature scan.

**Selections persist as feature names.** `PointsLayerConfig.featureNames` is the
durable, serializable form and what the UI writes. Codes are app-assigned for a
dictionary-only element (a Xenium `transcripts` has `feature_name` and no code
column), so a stored code could silently come back meaning a different feature.
`featureCodes` still works and still takes effect at runtime, but names win when
both are present. `resolveFeatureSelectionCodes` / `featureNamesForCodes` are
exported from `@spatialdata/core` for converting between the two.

**Feature scan is 3-4x faster.** It now reads through
`ParquetFile.stream({ columns, rowGroups })`, which fetches per column chunk, so
the projection reaches the network instead of pulling whole row groups — all 12
columns of a Xenium `transcripts` to use three. The scan runs in the points
worker, keeping the parquet decode off the main thread. Selecting one gene from a
12.1M-row element went from ~3.2-7.8s to ~1.0s, with main-thread time roughly a
third of what the pre-streaming path cost.

Also fixed along the way: a full-dataset catalog scan being silently cancelled by
the resident preview settling underneath it (leaving counts stuck and colours
mismatched); row-group chunks handing out the cached footer buffer, which the
worker transfer detached (`DataCloneError`, dropping the element onto whole-file
reads); parquet part layout being re-probed on every call; a server that answers
a directory path with 500 rather than 404 wedging part traversal; and point size
not accounting for an element's transform scale.
6 changes: 6 additions & 0 deletions .claude/launch.json
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,12 @@
"autoPort": true,
"port": 5173
},
{
"name": "vis-demo-alt",
"runtimeExecutable": "pnpm",
"runtimeArgs": ["--filter", "@spatialdata/vis", "dev:demo", "--port", "5180", "--strictPort"],
"port": 5180
},
{
"name": "docs",
"runtimeExecutable": "pnpm",
Expand Down
95 changes: 95 additions & 0 deletions docs/docs/vis/headless-viewer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,100 @@ Row alignment comes from `@spatialdata/core` (`createFeatureTableAlignment`);
colour encoding from `@spatialdata/layers` (`buildShapeFillColorByFeatureId`).
`SpatialCanvasViewer` wires these when `fillColorByColumn` is set.

## Points: feature selection and colour

Points styling is driven the same way as shapes — by updating the stack entry
props, not by reaching into vis internals. The serializable fields on a points
entry:

```ts
entry.props = {
...entry.props,
// Which features are drawn. Omit for "all features".
featureNames: ['EPCAM', 'MALL'],
// Per-feature colour, keyed by feature name. Absent features keep the default
// categorical colour.
featureColorOverrides: { EPCAM: [220, 30, 30] },
// Categorical colour-by-feature. ON by default; pass false for a flat colour.
colorByFeature: true,
// Radius in the ELEMENT's own coordinate units (the layer folds in the
// element's transform scale, so the same value means the same apparent size
// across elements whose transforms differ).
pointSize: 0.1,
// Max rows retained in memory for the resident window. Raising it draws more
// points at the cost of memory and decode time.
pointsMemoryCap: 4_000_000,
};
```

### Selections persist as names, not codes

`featureNames` is the durable form and the one to serialize. There is also a
`featureCodes: number[]`, which is retained for runtime use and for configs
written before names existed — **do not persist it**.

The reason is that a points element often has no feature-code column in the file.
A Xenium `transcripts` carries `feature_name` and no codes; the same is true of a
merfish `cell_type`. For those, codes are *assigned by the application* as a
first-seen index while building the feature catalog, so the same gene is not
guaranteed the same number between the instant resident-subset catalog and the
full one, between the two catalog-building paths (which are chosen by row count),
or between servers that differ in HTTP range support. A persisted code can
therefore come back meaning a different feature, with nothing to signal it.

Names are resolved to whatever codes the current catalog uses, at render time.
Names the element does not have are dropped rather than coerced, so a config
saved against one dataset can be applied to another without inventing features.
`featureNames` takes precedence when both fields are present.

If you need to convert in either direction yourself, `@spatialdata/core` exports
`resolveFeatureSelectionCodes(selection, catalog)` and `featureNamesForCodes`.

### Reading feature state headlessly

To build your own feature UI, read the engine directly. `pointsEngine` and
`resolvePointsTarget` come off the renderer-hook result; wrap a subtree in the
provider and consume the hook:

```tsx
import { PointsFeatureStateProvider, usePointsFeatureState } from '@spatialdata/vis';

const { pointsEngine, resolvePointsTarget } = useSpatialCanvasRenderer(/* … */);

<PointsFeatureStateProvider engine={pointsEngine} target={resolvePointsTarget(layerId)}>
<MyFeatureList config={pointsConfig} />
</PointsFeatureStateProvider>;

function MyFeatureList({ config }) {
const {
catalog, // { featureKey, entries: [{ code, name, count? }] } | null
catalogLoading, // no catalog yet, one is on its way
catalogRefining, // full scan running behind an instant preview
residentCodes, // features present in the resident window
loadedMatchingCodes, // features currently on screen via the last scan
supportsOnDemandLoad,// a whole-dataset scan can reach beyond the window
matchingLoadState, // progress of the scan for this selection
residentFeatureCounts,
requestCatalog, // idempotent; upgrades the preview to the full list
setHighlightedFeature,
} = usePointsFeatureState(config);
}
```

Pass the layer config (the hook resolves `featureNames` internally against the
catalog it is already reading). An array of already-resolved codes is also
accepted.

Two things worth designing around:

- The catalog arrives in two stages. An instant preview covering just the
resident window is published first, then the authoritative full-dataset list
supersedes it; `catalogRefining` distinguishes them. Counts may lag the names.
- Selecting a feature whose points are outside the resident window triggers a
whole-dataset scan when `supportsOnDemandLoad` is true. `matchingLoadState`
reports its progress so the UI can show that points are still arriving rather
than appearing to be complete.

## Auto-fit and view state

- Pass `viewState={null}` on first render to let the viewer compute an initial
Expand Down Expand Up @@ -314,6 +408,7 @@ Before treating the API as stable for MDV:
- [x] Controlled `coordinateSystem`, `renderStack`, `viewState`
- [x] Host overlay descriptors with `hostLayerResolver`; `deckLayers` / `deckProps` passthrough remains for compatibility
- [x] `renderTooltip={false}` for external tooltip ownership
- [x] Points feature selection persisted by NAME (`featureNames`), with `featureColorOverrides` keyed by name
- [x] `demo/headless` route with local `blobs.zarr` fixture
- [ ] Additional `demo/headless-*` variants (Leva controls, custom deck layers)
- [ ] Tooltip/pick row resolution fully on shared `FeatureTableAlignment` (in progress)
Expand Down
33 changes: 32 additions & 1 deletion docs/docs/vis/mdv-release-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ geometry/images and maps **MDV’s render stack** to Viv/deck output.
| Images | Yes (Viv) |
| Shapes | Yes — MDV drives style/filter via `layers` state |
| Labels | Render when configured; segmentation vs shapes experiment is not a release gate |
| Points | v1.1 |
| Points | v1.1 — feature selection and colour are config-driven and serializable; tiling/index strategy still in progress |

### Custom deck layers (additive, not a replacement for state)

Expand Down Expand Up @@ -95,6 +95,37 @@ Current local state:

Optional later: MDV-only `PolygonLayer` built from exported geometry helpers; not required if config-driven styling is sufficient.

### Points v1.1: feature selection and colour via `layers` state

Points support the same config-driven model as shapes — MDV updates the layer
config, vis owns loading and rendering:

- [x] `PointsLayerConfig` accepts a serializable selection (`featureNames`), per-feature colour (`featureColorOverrides`), `colorByFeature`, `pointSize`, `pointsMemoryCap`
- [x] Selections persist by feature NAME, not code, so a saved config survives catalog renumbering ([Headless viewer guide](./headless-viewer))
- [x] Colour overrides keyed by feature name for the same reason
- [x] Headless feature UI supported without vis internals: `PointsFeatureStateProvider` + `usePointsFeatureState`
- [ ] Tiling / index strategy for very large elements (follow-up: benchmarking index strategies, Python writer utilities)

The serialization detail that matters for saved MDV charts: a points element
frequently has **no feature-code column in the file** — a Xenium `transcripts`
carries `feature_name` only — so codes are assigned by the application while
building the feature catalog and are not stable across catalog upgrades, catalog
paths, or servers. `featureNames` is therefore the durable field; `featureCodes`
exists for runtime and backwards compatibility and should not be persisted.

Current local state:

- `@spatialdata/core` exposes `resolveFeatureSelectionCodes()` and
`featureNamesForCodes()` for converting between the durable and runtime forms
against a given catalog.
- The feature catalog is two-stage: an instant resident-subset preview, then the
authoritative full-dataset list. A selection made against either resolves to
the same features, which is what the name form buys.
- Remaining release risk: a selection whose points fall outside the resident
window needs a whole-dataset scan, whose cost scales with element size — this
is what the tiling/index follow-up addresses. `matchingLoadState` exposes the
progress meanwhile.
Comment thread
coderabbitai[bot] marked this conversation as resolved.

### Bespoke layer extension path

- Primary extension for **state-owned** SpatialData layers: MDV-controlled `renderStack.entries`
Expand Down
36 changes: 36 additions & 0 deletions docs/plans/points-redesign-punchlist.md
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,42 @@ state model, not by patching mutations here.

---

## Known open — feature counts can settle permanently absent

Observed intermittently on a 12.1M-row Xenium `transcripts`: the feature panel
sticks on "sorted by count so far" and never reaches authoritative counts.
**Remounting the panel does not clear it**, which distinguishes it from the
preview-vs-full supersession bug fixed in `46d5b89`.

Mechanism (read from code; not yet reproduced deterministically):

1. `listPointsFeatures` tries `listPointsFeaturesByStreamingScan` first, which
tallies counts as it scans, and falls back on any failure.
2. The fallback does not tally. `listPointsFeaturesWithCounts` then calls
`loadFeatureCounts`, which needs an integer code column — so for a
**dictionary-only** element (Xenium `transcripts`, merfish `cell_type`) it
returns an empty map and the catalog settles with no counts.
3. `PointsResolver.ensureFeatureCatalog` short-circuits on
`slot.settledKey === 'full'`, so that countless catalog is never re-requested.
The failure is therefore permanent for the session, and remount-immune.

The non-determinism plausibly comes from `serverSupportsStreamingRanges`, a live
two-request probe memoised per ORIGIN in a static map: if it loses a race or is
throttled once, the whole origin is marked unservable for the session and the
countless path is taken. Suspected to have become more likely when `be64b65` put
the feature scan on that same probe, so it now runs earlier and more often —
**unverified**.

Two independent fixes, either of which removes the permanence:

- Do not settle `'full'` for a catalog missing counts it should have — settle it
under an upgradable phase so a retry is possible. Makes it self-healing.
- Make the fallback path tally counts for dict-only elements, so falling back is
a performance difference rather than a correctness one.

Deliberately not fixed blind: forcing `serverSupportsStreamingRanges` to false
should give a deterministic reproduction to fix against first.

## Defer-to-redesign

Each notes *why* it's coupled to the state-model / decode rework.
Expand Down
50 changes: 44 additions & 6 deletions docs/plans/resource-resolver-handoff.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
# Resource Resolver — implementation handoff

**Status:** Step 0 + Step 1 **landed** — see [Progress](#progress-2026-07-15). Step 2
(Tracks A / B / C) and Step 3 (Renderer Adapter cleanup) remain.
**Status:** Step 0 + Step 1 **landed**; **Step 2 Track A (points state model) landed**
— see [Progress](#progress-2026-07-15). Step 2 Tracks B / C and Step 3 (Renderer
Adapter cleanup) remain.
**Decisions:** [ADR 0004 — Resource Resolver Owned By Core](../adr/0004-resource-resolver-owned-by-core.md), [ADR 0005 — Memory Accounting Before Management](../adr/0005-memory-accounting-before-management.md)
**Supersedes:** [layer-data-engine-decomposition.md](layer-data-engine-decomposition.md)
**Vocabulary:** [CONTEXT.md](../../CONTEXT.md) — *Resource Resolver, Renderer Adapter, Spatial Entry, Resolution, Spatial Entry Error, Entry Notice, Encoded/Decoded Tier, Resource Ceiling*
Expand Down Expand Up @@ -29,10 +30,26 @@ Read the two ADRs first. This document is sequencing, not rationale.
`pointsEngine.ensureMatchingFeaturesLoaded` / `ensureRowFeatureCodes` calls in
`getLayers` stay put (they migrate into `plan()` under Track A), so the points
reconcile context carries only the memory cap.
- **Remaining:** Step 2 Tracks A (points state model / `RequestSlot` / races R1–R5),
B (shapes loader seam + tooltip ping-pong), C (memory, ADR 0005 rungs 1–3); Step 3
(Renderer Adapter `project()`/`render()`, `'use no memo'` removal, dead-surface
cleanup). None started.
- **Step 2 Track A — points state model: landed** (branch
`claude/points-implementation-stages`, commits A1–A8). All four points resources —
`preload`, `rowCodes`, `catalog`, `matching` — are now `RequestSlot`s (one tested
dedup/supersede/settle primitive, keyed so everything a request depends on is in the
key). **Races R1/R2/R3/R5 closed** with fail-before/pass-after tests. Failures are
structured, **retryable** `SpatialEntryError`s with a `retry()` API (the stuck
full-catalog-scan fix). Cancellation is threaded to the scan generator (D8;
supersede/evict abort it between chunks). The render-phase engine kicks are
**migrated into `plan()`** — `getLayers` is now pure reads, driven by the reconcile
effect. The streaming-overlay **flash is fixed** (D10) via a scan-stable partial
resource + `resourceRevision`. The Effect-vs-plain spike ran and **plain won**
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Outdated
(recorded above). `PointsResolver`'s public surface is unchanged; the 855-line
`pointsDataEngine` regression net stays green (a few failure/cap-alignment cases
flipped to the new behaviour). 573 tests green repo-wide.
- **Pending browser verification:** the D10 no-flash behaviour is proven headlessly
(adapter resource-identity + revision) but its *visual* confirmation needs a
running app with a large streaming Xenium `transcripts` scan.
- **Remaining:** Step 2 Tracks B (shapes loader seam + tooltip ping-pong), C (memory,
ADR 0005 rungs 1–3); Step 3 (Renderer Adapter `project()`/`render()`, `'use no memo'`
removal, dead-surface cleanup; retire the `PointsDataEngine` facade). Not started.

---

Expand Down Expand Up @@ -176,6 +193,27 @@ unless it wins on *all three* of — supersession correctness under two concurre
scans; interruption that actually reaches the worker; fewer lines to set up a race in
a test. A tie means the plain slot wins.

> **Spike outcome (Track A step A8): plain wins. Effect not adopted.** Run
> empirically — `effect@3.22` added as a `core` devDependency, both slots implemented
> and driven through the same two-concurrent-scan supersession race
> (`packages/core/tests/matchingSlotEffectSpike.spec.ts`), then both removed per
> "delete the loser". Against the three criteria:
> 1. **Supersession correctness — tie.** Both drop the superseded scan's result; the
> plain slot by record identity (`this.current !== record`), Effect by
> `Fiber.interrupt`.
> 2. **Interruption reaches the worker — tie.** Both abort the scan's `AbortSignal`
> (the seam A5 threads to the generator). The plain slot aborts its own
> `AbortController` *synchronously*; Effect runs the `Effect.async` canceler via
> `runFork(Fiber.interrupt(...))`, needing a runtime tick to propagate.
> 3. **Lines to set up a race — plain wins.** Plain: two synchronous `request()`
> calls, assert immediately. Effect: an `Effect.async` + canceler + `runFork` slot
> (~15 extra lines) *and* an awaited runtime tick before every assertion; plus a
> multi-second import cost on a package whose ethos is zero runtime deps.
>
> Effect ties two and loses one, so by the agreed rule the plain `RequestSlot` wins.
> The `RequestSlot` seam is deliberately shaped so a future reconsideration (e.g. if
> `tgpu-htj2k`'s dependency-free stance is renegotiated) is a swap, not a rewrite.

---

#### Track B — Shapes
Expand Down
Loading
Loading