-
Notifications
You must be signed in to change notification settings - Fork 0
Make a fill-colour column switch apply, and make its colours a property of the column #142
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from all commits
Commits
Show all changes
8 commits
Select commit
Hold shift + click to select a range
17338db
Apply a fill-colour column switch for a host that edits configs in place
xinaesthete 0dcba62
Drop the redundant tooltip-fields behavioural test
xinaesthete 84a4532
Make a column's colours a property of the column, not of the view
xinaesthete dfe71e9
Let a continuous column carry a real ramp, not just two endpoints
xinaesthete c2d9f84
Ship sourcemaps, and stop a malformed scheme crashing three frames away
xinaesthete 20abefc
Reject a null categorical palette from the named-palette guard
xinaesthete 8cd6779
Correct two comments the colour and store changes left behind
xinaesthete 9675132
Say what "falls back to the default scheme" actually means
xinaesthete File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,40 @@ | ||
| --- | ||
| '@spatialdata/core': patch | ||
| '@spatialdata/vis': patch | ||
| --- | ||
|
|
||
| Make a fill-colour column (or tooltip field) switch actually apply for a host that | ||
| edits its layer configs in place. | ||
|
|
||
| Two independent breaks sat between "the user picked a different column" and "the | ||
| canvas shows it", and a host only hit them together. #119 fixed the third thing in | ||
| that chain — the load-window blank — which is why the remaining two read as "the | ||
| colours just never change". | ||
|
|
||
| **The change never reached the resolver.** `useLayerData`'s reconcile effect is the | ||
| one place a config change turns into a request, and it was keyed on the identity of | ||
| `layers` and the configs inside it. That assumes the caller allocates a fresh config | ||
| per edit; MDV's render-stack adapter deliberately does the opposite, keeping one | ||
| `LayerConfig` per Stack Entry so a cosmetic edit does not look structural and | ||
| re-enter geometry loads. Under that caller the effect never re-ran: the new column | ||
| was never requested, `getShapeFillColorEntry` / `getLabelFillColorEntry` went on | ||
| correctly serving last-good rows, and last-good was all there would ever be. The | ||
| effect now also depends on `describeResolveInputs` — a value key over exactly the | ||
| config fields each resolver's `plan()` reads, recomputed per render because a | ||
| mutation is invisible to any memo. It holds scalars and short id lists only; a | ||
| palette swap or an opacity drag does not move it, so nothing replans on a slider. | ||
|
|
||
| **The settle never reached React.** `SpatialEntryStore` subscribed to its resolvers | ||
| in its constructor and tore that bridge down in `dispose()` — which `useLayerData` | ||
| calls from an effect cleanup. An effect cleanup is not "the end": StrictMode's dev | ||
| double-mount runs cleanup and then re-runs the effect against the same memoised | ||
| store, after which the store was permanently deaf to its own resolvers. Every async | ||
| settle from then on was dropped, so rows that landed after a switch did not repaint | ||
| until an unrelated re-render (a pan) came along. The bridge is now attached on the | ||
| first listener and detached on the last, so it is exactly as long-lived as someone | ||
| caring about it and survives any number of remounts. `getVersion()` became a derived | ||
| sum of the resolvers' versions rather than a counter the bridge maintained, so it | ||
| stays true whether or not anything is subscribed. | ||
|
|
||
| No public API change. Verified against MDV driving only `fillColorByColumn` on a | ||
| labels layer: switching to a column that has to be fetched now repaints on its own. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,48 @@ | ||
| --- | ||
| '@spatialdata/layers': minor | ||
| '@spatialdata/vis': minor | ||
| --- | ||
|
|
||
| Make a column's colours a property of the column, not of the features that loaded. | ||
|
|
||
| Three things decided the encoding from whatever happened to be in view, so two | ||
| layers over one annotation could disagree about what a colour means — which reads | ||
| as a data difference rather than as a bug: | ||
|
|
||
| - Category indices were assigned in **first-seen feature order**. A shapes layer | ||
| walks the loader's geometry order and a labels layer walks the raster's ids, so | ||
| the same `cell_type` column rendered in two different schemes on the two kinds. | ||
| (`labelColorEncoding.spec.ts` claimed to cover this, but only pinned the indices | ||
| on one kind; it now actually builds the column through both.) Categories are now | ||
| ordered by value, with numeric-looking values ordered numerically so cluster 10 | ||
| follows cluster 9 rather than cluster 1. | ||
| - Positional palettes cannot survive a category being **absent from a view** at | ||
| all: `tumour` genuinely is the second category present when `stroma` is not. | ||
| `categoricalPalette` therefore also accepts `{ byValue: { Tumour: [200, 30, 30] } }`, | ||
| with an optional `fallback` for values it does not name (`'oklab'` by default, so | ||
| an unnamed category keeps its own hue instead of merging into one bucket). This | ||
| is the form to prefer in a saved stack, and the only form an embedding | ||
| application can use to make a layer agree with its own charts. | ||
| - The continuous ramp measured its extent from the loaded features. `numericDomain` | ||
| pins it to the column's own range; values outside clamp rather than extrapolate. | ||
|
|
||
| `numericRamp` also takes more than two stops now, spaced evenly across the domain, | ||
| because the ramps people actually use are not two-stop — viridis, a diverging | ||
| red/white/blue, or whatever a host has already chosen for the same column in its | ||
| own UI. Approximating one by its endpoints loses the midpoint that made it | ||
| meaningful. `numericScale: 'symlog'` goes with it: a counts or expression column | ||
| whose mass sits near zero with a long tail collapses into the first stop under a | ||
| linear position. Symmetric log rather than plain log, because these columns reach | ||
| zero and below. | ||
|
|
||
| `featureColorSchemeSignature` now takes the scheme as one object | ||
| (`featureColorSchemeSignature(config.fillColorByColumn)`) rather than three | ||
| positional arguments, so adding a term to the encoding cannot leave a call site | ||
| silently keying on the old set — the failure mode there being a layer that keeps | ||
| serving the previous colours after the scheme changed. Named palettes are | ||
| serialised in sorted key order, since object key order is insertion order and a | ||
| host rebuilding its palette each render need not insert in a stable one. | ||
|
|
||
| **Colours will change** for existing categorical configs that relied on the | ||
| implicit first-seen order. Pass `categoricalPalette: { byValue }` to fix a scheme | ||
| in place. | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,21 @@ | ||
| --- | ||
| '@spatialdata/layers': patch | ||
| '@spatialdata/vis': patch | ||
| '@spatialdata/avivatorish': patch | ||
| '@spatialdata/react': patch | ||
| --- | ||
|
|
||
| Publish sourcemaps, and survive a colour scheme that does not match its own type. | ||
|
|
||
| `core` shipped `index.js.map`; `layers`, `vis`, `avivatorish` and `react` did not. | ||
| A crash inside one of them reached a consumer as | ||
| `Le (…/.vite/deps/@spatialdata_layers.js:396)` — an esbuild-minified name with | ||
| nothing to map it back to. An embedding application has only the built artifact to | ||
| debug against, so it has to carry a map. | ||
|
|
||
| `resolveCategoricalPalette` and the ramp sampler now always return a colour. A | ||
| scheme arrives from a saved Render Stack, so its type is a claim about JSON rather | ||
| than a guarantee: a palette object with no `byValue`, a list with a hole in it, or | ||
| a ramp with fewer than two stops all used to return `undefined` and fail several | ||
| frames later in the arithmetic that reads `rgb[0]`. Wrong colours can be seen and | ||
| reported; that `TypeError` cannot. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
134 changes: 134 additions & 0 deletions
134
packages/core/tests/spatialEntryStoreSubscription.spec.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,134 @@ | ||
| import { describe, expect, it, vi } from 'vitest'; | ||
| import { | ||
| type EntryResources, | ||
| Resolution, | ||
| type ResolveTask, | ||
| SpatialEntryStore, | ||
| } from '../src/engine/index.js'; | ||
|
|
||
| /** | ||
| * The store's bridge to its resolvers, under the lifecycle React actually gives it. | ||
| * | ||
| * `SpatialEntryStore` is memoised by `useLayerData` and disposed from an effect | ||
| * cleanup — and an effect cleanup is not "the end". StrictMode's dev double-mount | ||
| * runs cleanup and then re-runs the effect against the same store instance. When the | ||
| * bridge was built in the constructor and torn down in `dispose`, that sequence left | ||
| * the store permanently deaf: resolvers went on loading and settling, and nothing | ||
| * downstream ever heard about it. The symptom was a fill-colour column that loaded | ||
| * and then never painted until an unrelated re-render came along. | ||
| * | ||
| * These tests drive that sequence directly, with a resolver stub whose only job is to | ||
| * emit one settle. | ||
| */ | ||
|
|
||
| /** A resolver that does nothing but let a test fire its settle notification. */ | ||
| function notifyingResolver() { | ||
| const listeners = new Set<() => void>(); | ||
| return { | ||
| resolver: { | ||
| kind: 'labels' as const, | ||
| blockingResources: [] as const, | ||
| plan: (): readonly ResolveTask[] => [], | ||
| load: async () => {}, | ||
| snapshot: (): EntryResources => ({ | ||
| entryId: 'e', | ||
| elementKey: 'k', | ||
| resources: {}, | ||
| notices: [], | ||
| bounds: null, | ||
| revision: 0, | ||
| }), | ||
| evict: () => {}, | ||
| dispose: () => { | ||
| listeners.clear(); | ||
| }, | ||
| subscribe: (listener: () => void) => { | ||
| listeners.add(listener); | ||
| return () => { | ||
| listeners.delete(listener); | ||
| }; | ||
| }, | ||
| getVersion: () => 0, | ||
| }, | ||
| /** Stand in for a load settling — what `finally { this.notify() }` does. */ | ||
| settle: () => { | ||
| for (const listener of listeners) listener(); | ||
| }, | ||
| listenerCount: () => listeners.size, | ||
| }; | ||
| } | ||
|
|
||
| function storeWith(labels: ReturnType<typeof notifyingResolver>['resolver']) { | ||
| const inert = { ...labels, subscribe: () => () => {} }; | ||
| return new SpatialEntryStore({ | ||
| points: inert, | ||
| shapes: inert, | ||
| images: inert, | ||
| labels, | ||
| }); | ||
| } | ||
|
|
||
| describe('SpatialEntryStore — the resolver notification bridge', () => { | ||
| it('forwards a resolver settle to its listeners', () => { | ||
| const labels = notifyingResolver(); | ||
| const store = storeWith(labels.resolver); | ||
| const onChange = vi.fn(); | ||
| store.subscribe(onChange); | ||
|
|
||
| labels.settle(); | ||
|
|
||
| expect(onChange).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('still forwards a settle after dispose + resubscribe (the StrictMode remount)', () => { | ||
| // THE regression. React runs cleanup then re-runs the effect against the same | ||
| // memoised store; nothing about that says the store is finished. | ||
| const labels = notifyingResolver(); | ||
| const store = storeWith(labels.resolver); | ||
| const onChange = vi.fn(); | ||
|
|
||
| const unsubscribe = store.subscribe(onChange); | ||
| unsubscribe(); | ||
| store.dispose(); | ||
| store.subscribe(onChange); | ||
|
|
||
| labels.settle(); | ||
|
|
||
| expect(onChange).toHaveBeenCalledTimes(1); | ||
| }); | ||
|
|
||
| it('holds no resolver subscription while nobody is listening', () => { | ||
| // The bridge exists for listeners. With none, it must not pin the resolver — | ||
| // otherwise a discarded store keeps a live edge into a resolver it no longer owns. | ||
| const labels = notifyingResolver(); | ||
| const store = storeWith(labels.resolver); | ||
|
|
||
| expect(labels.listenerCount()).toBe(0); | ||
|
|
||
| const unsubscribe = store.subscribe(vi.fn()); | ||
| expect(labels.listenerCount()).toBe(1); | ||
|
|
||
| unsubscribe(); | ||
| expect(labels.listenerCount()).toBe(0); | ||
| }); | ||
|
|
||
| it('attaches once for many listeners, and detaches only when the last one goes', () => { | ||
| const labels = notifyingResolver(); | ||
| const store = storeWith(labels.resolver); | ||
| const first = vi.fn(); | ||
| const second = vi.fn(); | ||
|
|
||
| const unsubscribeFirst = store.subscribe(first); | ||
| const unsubscribeSecond = store.subscribe(second); | ||
| expect(labels.listenerCount()).toBe(1); | ||
|
|
||
| unsubscribeFirst(); | ||
| expect(labels.listenerCount()).toBe(1); | ||
|
|
||
| labels.settle(); | ||
| expect(second).toHaveBeenCalledTimes(1); | ||
|
|
||
| unsubscribeSecond(); | ||
| expect(labels.listenerCount()).toBe(0); | ||
| }); | ||
| }); |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.