Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
10 changes: 10 additions & 0 deletions .changeset/labels-hover-highlight.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,10 @@
---
'@spatialdata/layers': minor
'@spatialdata/vis': minor
---

Highlight the labels feature under the cursor, the way shapes already do.

Nothing to configure on either canvas surface: the highlight follows the same hover pick
that feeds the tooltip, so it respects `hoverTooltipMode`. Hosts driving `LabelsLayer`
directly get `highlightedLabelId` and an optional `highlightColor`.
14 changes: 14 additions & 0 deletions docs/docs/vis/headless-viewer.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,20 @@ that the fragment shader samples. The property that matters is the same on both:
a feature-state change re-uploads only the small table, never the tiles. Picking
consults that table too, so a hidden label cannot be picked.

Hovering a label highlights it, the way `autoHighlight` highlights a shape. Nothing to
configure: both canvas surfaces — `SpatialCanvasViewer` and the full-UI `SpatialCanvas` —
drive it from the same pick that feeds the tooltip, through one shared resolver
(`resolveHoveredLabel`). It therefore follows `hoverTooltipMode`: `'off'` disables picking
and with it the highlight. It is deliberately **not** part of the Render Stack: hover
changes on every pointer move and would be meaningless in a saved view, so it never
appears in an entry's `props`. A label the filter hides is never highlighted, for the same
reason it can never be picked.

Hosts driving `LabelsLayer` directly get `highlightedLabelId` (the integer id, or `-1` for
none) and `highlightColor` — deck's own prop name, with deck's own meaning, so the tint is
set the same way on labels as on shapes. Alpha is the blend weight toward the tint, not an
opacity.

### Choosing the colour scheme

`fillColorByColumn` carries the scheme, not just the column name. Every field is
Expand Down
30 changes: 30 additions & 0 deletions docs/docs/vis/layer-prop-flow.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -328,13 +328,43 @@ only the small table, **never the tiles**.
element alone let two layers colouring one element by different columns evict
each other on every plan.

### Hover highlight: a uniform, not a table entry

The label under the cursor is drawn highlighted, matching what `autoHighlight` gives
shapes. Deck's own machinery cannot do it here: a labels tile's picking colour covers the
whole quad, so there is no per-label deck object for `picking_filterHighlightColor` to act
on, and enabling `autoHighlight` would light up the entire tile. The highlight is resolved
**per fragment** instead — the shader compares the sampled instance id against a
`highlightedLabelId` uniform, rather than a LUT entry, so a pointer move re-uploads
nothing.

- **Both canvas surfaces resolve the pick through one function.** `SpatialCanvasViewer`
and the full-UI `SpatialCanvas` each own a `handleHover`; the highlight shipped working
in the first and dead in the second precisely because of that. `resolveHoveredLabel` in
`featureTooltipHover.ts` is the single implementation both must call — when adding hover
behaviour, add it there, not in one `handleHover`.
- **Hover is runtime render state, never Render Stack config.** `useLayerData` keeps it on
a ref plus a version counter (`setHoveredLabel`) so a saved view can never carry it, and
so pointer motion *within* one label schedules no re-render. Points already carry their
highlight the same way.
- **One slot, not a per-layer map.** Only one thing is under the cursor at a time, so
"moved to a different labels layer" and "moved off" are the same transition.
- **`resolveHighlightedLabel` refuses background and hidden labels** before the value
reaches the shader, so an id that went stale between the pick and the frame cannot light
up something the filter hides.
- **The tint reuses deck's `highlightColor`**, with the same meaning (alpha is the blend
weight), so one prop name covers shapes and labels. The labels default is declared in
`defaultProps`; see the anti-patterns below for why it cannot be a use-site fallback.

### Anti-patterns (labels)

| Anti-pattern | Why it's wrong |
|---|---|
| Building the LUT per tile sublayer | Duplicates a multi-megabyte table across every tile |
| Returning a fresh `FeatureColorBuffer` wrapper each render | Re-uploads the texture every frame; keep the `colors` identity stable |
| Keying a colour resource by element alone | Two layers, two columns, one element → eviction ping-pong that never settles |
| Carrying the hovered label in the colour LUT | Re-uploads a multi-megabyte texture on every pointer move; use the uniform |
| Defaulting a prop deck also defines with `props.x ?? MY_DEFAULT` | deck fills its own default in, so the prop is never absent and your fallback never runs. This drew every labels hover in deck's navy `highlightColor`. Redefine it in the layer's `defaultProps`, which does override deck's |

## Shape/table annotation controls

Expand Down
4 changes: 4 additions & 0 deletions docs/docs/vis/mdv-release-checklist.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,10 @@ Labels take the same API, so MDV drives them the same way — no separate code p
- [x] Colour resolves through a label-id-indexed LUT the shader samples, so a
selection change re-uploads a small table rather than the tiles; picking
consults the same table, so a hidden label cannot be picked.
- [x] Hovering a label highlights it, as `autoHighlight` does for shapes. MDV
configures nothing: it follows `hoverTooltipMode` and is driven from the same
pick as the tooltip. It is runtime render state, so it never appears in a
saved Render Stack entry — do not try to persist or restore it.

The one thing MDV must get right is the id: a labels feature id is the label's
**integer instance id as a string** — the raster's own pixel value, and the same
Expand Down
22 changes: 22 additions & 0 deletions packages/layers/src/LabelsBitmaskTileLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,9 +3,11 @@ import { picking, project32 } from '@deck.gl/core';
import { XRLayer } from '@hms-dbmi/viv';
import { Matrix4 } from '@math.gl/core';
import {
DEFAULT_LABEL_HIGHLIGHT_COLOR,
isLabelVisibleInLut,
LABEL_COLOR_LUT_WIDTH,
type LabelColorLut,
resolveHighlightedLabel,
} from './labelColorEncoding';
import { fs, labelsBitmaskUniforms, vs } from './labelsBitmaskLayerShaders';

Expand Down Expand Up @@ -91,6 +93,14 @@ export class LabelsBitmaskTileLayer extends UntypedXRLayer {
// already megabytes for a large segmentation.
featureColorLut: { type: 'object', value: null, compare: false },
featureColorTexture: { type: 'object', value: null, compare: false },
// Hover state. A scalar prop rather than anything in the LUT, so a pointer move
// changes a uniform and nothing is re-uploaded. `-1` means "nothing hovered";
// label 0 is background and is discarded before the highlight runs anyway.
highlightedLabelId: { type: 'number', value: -1, compare: true },
// Deck's own `Layer` prop, redefaulted from its navy `[0, 0, 128, 128]`. Declaring
// it here is what makes the labels default win: deck fills its base default in, so
// the prop is never absent and a `?? DEFAULT` at the use site would never run.
highlightColor: { type: 'array', value: DEFAULT_LABEL_HIGHLIGHT_COLOR, compare: true },
};

// biome-ignore lint/complexity/noUselessConstructor: widens the base UntypedXRLayer constructor so `new LabelsBitmaskTileLayer(props)` typechecks.
Expand Down Expand Up @@ -259,6 +269,8 @@ export class LabelsBitmaskTileLayer extends UntypedXRLayer {
channelStrokeWidths,
featureColorLut,
featureColorTexture,
highlightedLabelId,
highlightColor,
maxZoom,
opacity = 1,
zoom,
Expand All @@ -274,8 +286,18 @@ export class LabelsBitmaskTileLayer extends UntypedXRLayer {
const lut = featureColorLut as LabelColorLut | undefined;
const useFeatureColors = lut && featureColorTexture ? 1 : 0;

const highlighted = resolveHighlightedLabel(highlightedLabelId as number | null, lut);
const highlightRgba = (highlightColor as readonly number[] | undefined) ?? [];
const highlightNormalized = getNormalizedColor(
highlightRgba.length >= 3 ? highlightRgba : DEFAULT_LABEL_HIGHLIGHT_COLOR
);
const highlightWeight =
(highlightRgba.length >= 4 ? highlightRgba[3] : DEFAULT_LABEL_HIGHLIGHT_COLOR[3]) / 255;

const labelsBitmask = {
color0: [...color, 1] as const,
highlightColor: [...highlightNormalized, highlightWeight] as const,
highlightedLabelId: highlighted,
channelFilled0: (channelsFilled?.[0] ?? true) ? 1 : 0,
channelOpacity0: channelOpacities?.[0] ?? 0.18,
channelOutlineOpacity0: channelOutlineOpacities?.[0] ?? 0.95,
Expand Down
37 changes: 37 additions & 0 deletions packages/layers/src/LabelsLayer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,10 +12,13 @@ import {
import { LabelsBitmaskTileLayer } from './LabelsBitmaskTileLayer';
import {
buildLabelColorLut,
DEFAULT_LABEL_HIGHLIGHT_COLOR,
LABEL_COLOR_LUT_WIDTH,
type LabelColorLut,
type LabelFeatureStateInput,
type LabelRgbaColor,
type LabelRgbColor,
NO_HIGHLIGHTED_LABEL,
} from './labelColorEncoding';

/** One instance-ID raster per labels element (see `LabelsBitmaskTileLayer`). */
Expand Down Expand Up @@ -68,6 +71,27 @@ export interface LabelsLayerProps {
* that {@link featureState} would otherwise do inside the layer.
*/
featureColorLut?: LabelColorLut;
/**
* The label id under the cursor, or `-1` / omitted for none.
*
* Runtime render state, not config: it changes on every pointer move and must
* never be serialized into a saved view. It reaches the shader as a uniform, so
* hovering re-uploads nothing — the LUT and the tiles are both untouched.
*/
highlightedLabelId?: number | null;
/**
* Hover tint, RGBA 0–255; defaults to {@link DEFAULT_LABEL_HIGHLIGHT_COLOR}.
*
* Deliberately deck's own `Layer` prop name, and deliberately the same meaning:
* deck blends its `highlightColor` over a highlighted fragment weighted by that
* colour's alpha, which is exactly what the labels shader does. One name across
* shapes (via `autoHighlight`) and labels. The labels default is declared in
* `defaultProps`, which is what overrides deck's navy base default.
*
* Unlike deck's, this one must be an array — the accessor/function form has no
* meaning here, since there is no per-label deck object to call it with.
*/
highlightColor?: LabelRgbaColor;
onClick?: (info: unknown) => void;
onHover?: (info: unknown) => void;
_subLayerProps?: CompositeLayerProps['_subLayerProps'];
Expand Down Expand Up @@ -172,6 +196,8 @@ class SingleScaleLabelsLayer extends CompositeLayer<any> {
selections: selectionsProp,
featureColorLut,
featureColorTexture,
highlightedLabelId,
highlightColor,
} = this.props;
const selections = [firstSelection(selectionsProp)];
const channelColors = stylePlane(channelColorsProp, [255, 255, 255]);
Expand Down Expand Up @@ -210,6 +236,8 @@ class SingleScaleLabelsLayer extends CompositeLayer<any> {
selections,
featureColorLut,
featureColorTexture,
highlightedLabelId: highlightedLabelId ?? NO_HIGHLIGHTED_LABEL,
highlightColor,
bounds,
id: `image-sub-layer-${bounds}-${id}`,
interpolation: 'nearest',
Expand Down Expand Up @@ -340,6 +368,11 @@ export class LabelsLayer extends CompositeLayer<LabelsLayerProps> {
channelOutlineOpacities: [0.95],
channelsFilled: [true],
channelStrokeWidths: [1.5],
// Overrides deck's own `Layer` default for this prop (navy `[0, 0, 128, 128]`).
// The default MUST be declared here rather than applied with `?? DEFAULT` at the
// use site: deck fills its base default in, so the prop is never absent and a
// use-site fallback can never run.
highlightColor: DEFAULT_LABEL_HIGHLIGHT_COLOR,
} satisfies Partial<LabelsLayerProps>;

/**
Expand Down Expand Up @@ -424,6 +457,8 @@ export class LabelsLayer extends CompositeLayer<LabelsLayerProps> {
channelOutlineOpacities = [0.95],
channelsFilled = [true],
channelStrokeWidths = [1.5],
highlightedLabelId,
highlightColor,
onClick,
onHover,
} = this.props;
Expand All @@ -447,6 +482,8 @@ export class LabelsLayer extends CompositeLayer<LabelsLayerProps> {
opacity,
featureColorLut,
featureColorTexture: this.state?.featureColorTexture ?? null,
highlightedLabelId: highlightedLabelId ?? NO_HIGHLIGHTED_LABEL,
highlightColor,
channelsVisible: stylePlane(channelsVisible, true),
channelColors: stylePlane(channelColors, [255, 255, 255]),
channelOpacities: stylePlane(channelOpacities, 0.18),
Expand Down
3 changes: 3 additions & 0 deletions packages/layers/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,13 +76,16 @@ export {
buildLabelFeatureStateRuntime,
buildLabelFillColorByFeatureId,
DEFAULT_LABEL_FILTERED_OPACITY_MULTIPLIER,
DEFAULT_LABEL_HIGHLIGHT_COLOR,
EMPTY_LABEL_FEATURE_STATE_RUNTIME,
isLabelFeatureStateRuntime,
isLabelVisibleInLut,
LABEL_COLOR_LUT_MAX_LABELS,
LABEL_COLOR_LUT_WIDTH,
NO_HIGHLIGHTED_LABEL,
normalizeLabelFeatureState,
parseLabelId,
resolveHighlightedLabel,
} from './labelColorEncoding';
export type { PointsLayerProps } from './PointsLayer';
export { PointsLayer } from './PointsLayer';
Expand Down
46 changes: 46 additions & 0 deletions packages/layers/src/labelColorEncoding.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,52 @@ export type LabelFeatureStateInput = LabelFeatureState | LabelFeatureStateRuntim

export const DEFAULT_LABEL_FILTERED_OPACITY_MULTIPLIER = 0.35;

/**
* Hover tint, matching the `highlightColor` shapes pass to deck's `autoHighlight`
* so the same pointer gesture reads the same way on either kind.
*
* Alpha is the MIX WEIGHT toward this colour, not an opacity — the shader also
* lifts the hovered label's fill, which a plain alpha blend over a 0.18-opacity
* fill would not make visible.
*/
export const DEFAULT_LABEL_HIGHLIGHT_COLOR: LabelRgbaColor = [255, 255, 0, 128];

/** `highlightedLabelId` when nothing is hovered. Label ids are non-negative. */
export const NO_HIGHLIGHTED_LABEL = -1;

/**
* The label the shader should actually draw as hovered, given the table in force.
*
* Separate from the layer because the interesting part is the refusals, and they
* are all cheap to get wrong:
*
* - Label `0` is background. It is discarded before the highlight runs, but a
* caller that maps "nothing picked" to `0` rather than `-1` would otherwise be
* asking to highlight the background.
* - A label the filter HIDES must not light up. Picking already refuses to return
* one, so this only catches an id that went stale between the pick and the
* frame — but the shader has no view of the hidden set beyond the LUT alpha it
* is about to discard on, so the check belongs on this side.
* - A non-finite or fractional id would reach the shader as a `float` and compare
* against sampled ids by proximity; rounding here keeps that comparison exact.
*/
export function resolveHighlightedLabel(
highlightedLabelId: number | null | undefined,
lut?: LabelColorLut
): number {
if (highlightedLabelId == null || !Number.isFinite(highlightedLabelId)) {
return NO_HIGHLIGHTED_LABEL;
}
const labelId = Math.round(highlightedLabelId);
if (labelId <= 0) {
return NO_HIGHLIGHTED_LABEL;
}
if (lut && !isLabelVisibleInLut(lut, labelId)) {
return NO_HIGHLIGHTED_LABEL;
}
return labelId;
}

/** Singleton for the common case of no feature-state at all. */
export const EMPTY_LABEL_FEATURE_STATE_RUNTIME = Object.freeze({
fillColorByFeatureId: new Map(),
Expand Down
37 changes: 37 additions & 0 deletions packages/layers/src/labelsBitmaskLayerShaders.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@
const labelsUniformBlock = `\
uniform labelsBitmaskUniforms {
vec4 color0;
vec4 highlightColor;
float channelOpacity0;
float channelOutlineOpacity0;
float channelStrokeWidth0;
Expand All @@ -16,6 +17,7 @@ uniform labelsBitmaskUniforms {
float useFeatureColors;
float featureTexWidth;
float featureCount;
float highlightedLabelId;
} labelsBitmask;
`;

Expand All @@ -24,6 +26,13 @@ export const labelsBitmaskUniforms = {
fs: labelsUniformBlock,
uniformTypes: {
color0: 'vec4<f32>',
/**
* Hover highlight tint, RGB 0–1 with alpha as the MIX WEIGHT (not an opacity).
* Kept next to `color0` because both are `vec4`: std140 aligns a `vec4` to 16
* bytes, so grouping them ahead of the scalars keeps the block free of padding
* holes.
*/
highlightColor: 'vec4<f32>',
channelOpacity0: 'f32',
channelOutlineOpacity0: 'f32',
channelStrokeWidth0: 'f32',
Expand All @@ -37,6 +46,14 @@ export const labelsBitmaskUniforms = {
featureTexWidth: 'f32',
/** Number of addressable label ids; ids at or beyond this are unannotated. */
featureCount: 'f32',
/**
* The label id under the cursor, or `-1` for none.
*
* A uniform rather than a bit in the LUT: hover changes on every pointer move,
* and re-uploading a table that is megabytes for a large segmentation to carry
* one changed entry is the thing this whole design avoids.
*/
highlightedLabelId: 'f32',
},
} as const;

Expand Down Expand Up @@ -206,6 +223,26 @@ void main() {
labelsBitmask.channelOutlineOpacity0 * featureStyle.a,
labelsBitmask.channelFilled0
);

// Hover highlight — the labels analogue of deck's \`autoHighlight\` on shapes.
//
// Resolved per FRAGMENT from the sampled instance id, because a tile's deck
// picking colour covers the whole quad: there is no per-label deck object for
// \`picking_filterHighlightColor\` to act on, so enabling deck's own autoHighlight
// here would light up the entire tile. Placed after the hidden-label discard, so
// a filtered-out label cannot highlight even if a stale id points at it.
if (labelMatch(dat0.y, labelsBitmask.highlightedLabelId) > 0.5) {
vec4 highlight = labelsBitmask.highlightColor;
fragColor.rgb = mix(fragColor.rgb, highlight.rgb, highlight.a);
// Tinting alone is nearly invisible at the default fill opacity (0.18), so the
// hovered label's fill is lifted to at least the highlight's own weight. The
// coverage factor keeps the boundary anti-aliased, and \`channelFilled0\` gates
// it so outline-only mode highlights the outline instead of growing a fill the
// display mode says should not be there.
float fillBoost = highlight.a * dat0.z * step(0.5, labelsBitmask.channelFilled0);
fragColor.a = max(fragColor.a, fillBoost);
}

fragColor.a = fragColor.a * labelsBitmask.labelOpacity;

fragColor = picking_filterHighlightColor(fragColor);
Expand Down
Loading
Loading