diff --git a/src/layer/segmentation/index.ts b/src/layer/segmentation/index.ts index f135e34d0b..5ef75d3f13 100644 --- a/src/layer/segmentation/index.ts +++ b/src/layer/segmentation/index.ts @@ -799,7 +799,7 @@ export class SegmentationUserLayer extends Base { readonly selectedSpatialSkeletonNodeInfo = new WatchableValue< SelectedSpatialSkeletonNodeInfo | undefined >(undefined); - readonly hoveredSpatialSkeletonNodeId = this.registerDisposer( + readonly hoveredSpatialSkeletonNodeInfo = this.registerDisposer( new SpatialSkeletonHoverState(), ); readonly spatialSkeletonVisibleChunksNeeded = new WatchableValue(0); @@ -1118,7 +1118,7 @@ export class SegmentationUserLayer extends Base { ), ); syncSelectedSpatialSkeletonNodeIdFromGlobalSelection(); - this.hoveredSpatialSkeletonNodeId.bindTo( + this.hoveredSpatialSkeletonNodeInfo.bindTo( this.manager.layerSelectedValues, this, ); @@ -1603,6 +1603,7 @@ export class SegmentationUserLayer extends Base { { sources2d: slicePanelSources, selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => @@ -1636,6 +1637,7 @@ export class SegmentationUserLayer extends Base { displayState, { selectedNodeInfo: this.selectedSpatialSkeletonNodeInfo, + hoveredNodeInfo: this.hoveredSpatialSkeletonNodeInfo, pendingNodePositionVersion: this.spatialSkeletonState.pendingNodePositionVersion, getPendingNodePosition: (nodeId) => diff --git a/src/layer/segmentation/selection.spec.ts b/src/layer/segmentation/selection.spec.ts index 34f7228aa9..4d28fc209f 100644 --- a/src/layer/segmentation/selection.spec.ts +++ b/src/layer/segmentation/selection.spec.ts @@ -144,7 +144,7 @@ describe("layer/segmentation/selection", () => { let mouseState: { active: boolean; pickedRenderLayer: unknown; - pickedSpatialSkeleton?: { nodeId?: unknown }; + pickedSpatialSkeleton?: { nodeId?: unknown; segmentId?: unknown }; } = { active: false, pickedRenderLayer: null, @@ -175,7 +175,15 @@ describe("layer/segmentation/selection", () => { pickedSpatialSkeleton: { nodeId: 31 }, }; trigger(); - expect(hoverState.value).toBe(31); + expect(hoverState.value).toEqual({ nodeId: 31 }); + + mouseState = { + active: true, + pickedRenderLayer: renderLayerA, + pickedSpatialSkeleton: { nodeId: 31, segmentId: 7 }, + }; + trigger(); + expect(hoverState.value).toEqual({ nodeId: 31, segmentId: 7 }); mouseState = { active: true, diff --git a/src/layer/segmentation/selection.ts b/src/layer/segmentation/selection.ts index 2d15b10751..c843b0635d 100644 --- a/src/layer/segmentation/selection.ts +++ b/src/layer/segmentation/selection.ts @@ -14,7 +14,10 @@ * limitations under the License. */ -import type { LayerSelectedValues } from "#src/layer/index.js"; +import type { + LayerSelectedValues, + PickedSpatialSkeletonState, +} from "#src/layer/index.js"; import type { SegmentationUserLayer } from "#src/layer/segmentation/index.js"; import { RefCounted } from "#src/util/disposable.js"; import { parseUint64 } from "#src/util/json.js"; @@ -23,11 +26,12 @@ import { NullarySignal } from "#src/util/signal.js"; interface SpatialSkeletonViewerHoverMouseStateLike { active: boolean; pickedRenderLayer: TRenderLayer | null | undefined; - pickedSpatialSkeleton?: - | { - nodeId?: unknown; - } - | undefined; + pickedSpatialSkeleton?: PickedSpatialSkeletonState; +} + +export interface SpatialSkeletonHoverInfo { + readonly nodeId: number; + readonly segmentId?: number; } interface SpatialSkeletonViewerHoverLayerLike { @@ -88,12 +92,6 @@ function getSelectionValueIdString(value: unknown) { } } -function normalizeSpatialSkeletonViewerHoverNodeId(value: unknown) { - return typeof value === "number" && Number.isSafeInteger(value) && value > 0 - ? value - : undefined; -} - export function getNodeIdFromLayerSelectionState( state: { nodeId?: unknown; value?: unknown } | undefined, ) { @@ -175,10 +173,10 @@ export function getNodeIdFromViewerSelection( ); } -function getSpatialSkeletonNodeIdFromViewerHover( +function getSpatialSkeletonHoverInfoFromViewerHover( mouseState: SpatialSkeletonViewerHoverMouseStateLike, layer: SpatialSkeletonViewerHoverLayerLike, -) { +): SpatialSkeletonHoverInfo | undefined { if (!mouseState.active) return undefined; const pickedRenderLayer = mouseState.pickedRenderLayer; if (pickedRenderLayer !== null) { @@ -189,18 +187,29 @@ function getSpatialSkeletonNodeIdFromViewerHover( return undefined; } } - // TODO (SKM): I think we can inline this function - return normalizeSpatialSkeletonViewerHoverNodeId( - mouseState.pickedSpatialSkeleton?.nodeId, - ); + const pickedSpatialSkeleton = mouseState.pickedSpatialSkeleton; + const nodeId = pickedSpatialSkeleton?.nodeId; + if (nodeId === undefined) return undefined; + const segmentId = pickedSpatialSkeleton?.segmentId; + if (segmentId === undefined) return undefined; + return segmentId === undefined ? { nodeId } : { nodeId, segmentId }; +} + +function spatialSkeletonHoverInfoEqual( + a: SpatialSkeletonHoverInfo | undefined, + b: SpatialSkeletonHoverInfo | undefined, +) { + if (a === b) return true; + if (a === undefined || b === undefined) return false; + return a.nodeId === b.nodeId && a.segmentId === b.segmentId; } export class SpatialSkeletonHoverState extends RefCounted { - value: number | undefined = undefined; + value: SpatialSkeletonHoverInfo | undefined = undefined; readonly changed = new NullarySignal(); - setValue(value: number | undefined) { - if (this.value !== value) { + setValue(value: SpatialSkeletonHoverInfo | undefined) { + if (!spatialSkeletonHoverInfoEqual(this.value, value)) { this.value = value; this.changed.dispatch(); } @@ -213,7 +222,7 @@ export class SpatialSkeletonHoverState extends RefCounted { this.registerDisposer( layerSelectedValues.changed.add(() => { this.setValue( - getSpatialSkeletonNodeIdFromViewerHover( + getSpatialSkeletonHoverInfoFromViewerHover( layerSelectedValues.mouseState, layer, ), diff --git a/src/skeleton/frontend.spec.ts b/src/skeleton/frontend.spec.ts index b19d500be3..9d9c26f623 100644 --- a/src/skeleton/frontend.spec.ts +++ b/src/skeleton/frontend.spec.ts @@ -134,22 +134,23 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { const layer = Object.assign( Object.create(SpatiallyIndexedSkeletonLayer.prototype), { - selectedNodeId: { value: 101 }, + selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - const outlineColor = (layer as any).getSelectedNodeOutlineColor(); - const cachedOutlineColor = (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + const outlineColor = (layer as any).selectedNodeOutlineColor; + (layer as any).updateNodeOutlineColorPair(); + const cachedOutlineColor = (layer as any).selectedNodeOutlineColor; expect(isSelected).not.toHaveBeenCalled(); expect(cachedOutlineColor).toBe(outlineColor); - expect(outlineColor[0]).toBeCloseTo(1); - expect(outlineColor[1]).toBeCloseTo(0.95); - expect(outlineColor[2]).toBeCloseTo(0.35); + // The outline color is chosen for high contrast against the segment color. expect(getContrastRatio(outlineColor, sourceColor)).toBeGreaterThanOrEqual( 3, ); @@ -183,16 +184,17 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); selectedNodeId.value = 202; - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); @@ -224,18 +226,104 @@ describe("SpatiallyIndexedSkeletonLayer selected node outline color", () => { { selectedNodeInfo: { value: { nodeId: 101 } }, selectedNodeOutlineColor: vec3.create(), - selectedNodeOutlineColorGeneration: 0, - cachedSelectedNodeOutlineColorGeneration: -1, + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, displayState, }, ); - (layer as any).getSelectedNodeOutlineColor(); - ++(layer as any).selectedNodeOutlineColorGeneration; - (layer as any).getSelectedNodeOutlineColor(); + (layer as any).updateNodeOutlineColorPair(); + ++(layer as any).nodeOutlineColorGeneration; + (layer as any).updateNodeOutlineColorPair(); expect(computeSegmentColor).toHaveBeenCalledTimes(2); }); + + it("derives the hovered-node outline color from the hovered segment when nothing is selected", () => { + const sourceColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map(), + segmentDefaultColor: { value: sourceColor }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: undefined }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // The hovered outline is chosen for high contrast against its own (white) + // segment color. + expect( + getContrastRatio(highlightedColor, sourceColor), + ).toBeGreaterThanOrEqual(3); + }); + + it("derives each outline from its own segment when selected and hovered nodes belong to different segments", () => { + // Selected node on a dark segment, hovered node on a bright segment, as + // happens when hovering a merge target on a differently colored skeleton. + const selectedSegmentColor = vec3.fromValues(0, 0, 0); + const hoveredSegmentColor = vec3.fromValues(1, 1, 1); + const displayState = { + segmentationColorGroupState: { + value: { + segmentStatedColors: new Map([ + [101n, 0x000000n], + [202n, 0xffffffn], + ]), + segmentDefaultColor: { value: undefined }, + segmentColorHash: { compute: vi.fn() }, + }, + }, + saturation: { value: 0 }, + hoverHighlight: { value: true }, + segmentSelectionState: { isSelected: vi.fn(() => false), baseValue: 0n }, + }; + const layer = Object.assign( + Object.create(SpatiallyIndexedSkeletonLayer.prototype), + { + selectedNodeInfo: { value: { nodeId: 101, segmentId: 101 } }, + hoveredNodeInfo: { value: { nodeId: 303, segmentId: 202 } }, + selectedNodeOutlineColor: vec3.create(), + highlightedNodeOutlineColor: vec3.create(), + nodeOutlineColorGeneration: 0, + cachedNodeOutlineColorGeneration: -1, + displayState, + }, + ); + + (layer as any).updateNodeOutlineColorPair(); + const selectedColor = (layer as any).selectedNodeOutlineColor; + const highlightedColor = (layer as any).highlightedNodeOutlineColor; + + // Each outline contrasts against its own segment color... + expect( + getContrastRatio(selectedColor, selectedSegmentColor), + ).toBeGreaterThanOrEqual(3); + expect( + getContrastRatio(highlightedColor, hoveredSegmentColor), + ).toBeGreaterThanOrEqual(3); + // ...and the two outlines are different colors. + expect([...selectedColor]).not.toEqual([...highlightedColor]); + }); }); describe("SpatiallyIndexedSkeletonLayer targeted source invalidation", () => { diff --git a/src/skeleton/frontend.ts b/src/skeleton/frontend.ts index 4008459ab1..6d606d95d7 100644 --- a/src/skeleton/frontend.ts +++ b/src/skeleton/frontend.ts @@ -117,7 +117,11 @@ import { } from "#src/trackable_value.js"; import { Uint64Set } from "#src/uint64_set.js"; import { gatherUpdate } from "#src/util/array.js"; -import { computeHighVisibilityContrastColor } from "#src/util/color.js"; +import { + getSaturation, + pickHighestContrastColor, + saturateColor, +} from "#src/util/color.js"; import { hsvToRgb } from "#src/util/colorspace.js"; import { DataType } from "#src/util/data_type.js"; import { RefCounted } from "#src/util/disposable.js"; @@ -186,20 +190,40 @@ import type { RPC } from "#src/worker_rpc.js"; const DEBUG_SPATIAL_SKELETON_OVERLAY = false; const DEBUG_EXCLUDED_SEGMENTS = false; const DEBUG_SPATIAL_SKELETON_CHUNKS = false; -// Used for debugging chunks via a different color for each chunk -const tempChunkKeyToColorMap = new Map(); -const tempMat4 = mat4.create(); const DEFAULT_FRAGMENT_MAIN = `void main() { emitDefault(); } `; +// If use values like 8.0, need to ensure JS keeps the decimal place for GLSL +const ACTIVE_NODE_BORDER_MIN_WIDTH = 3.5; +const ACTIVE_NODE_BORDER_MAX_WIDTH = 8.5; +const ACTIVE_NODE_BORDER_DIAMETER_FRACTION = 0.5; +const ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION = 0.25; + +// Saturation adjustment factor and threshold for the highlighted (hovered) node border: each +// moves the segment's color away from (>1) or towards (<1) the perceptual-grey +// axis by this multiplier, clamped to [0, 1]. A segment color that is already +// very saturated has little room left to move further from grey, so boosting it +// further is barely visible; in that case the color is desaturated instead, which +// remains a visible change in either direction. Mirrors the saturation-flip +// logic in getObjectColor (segmentation_display_state/frontend.ts). +const HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR = 0.5; +const HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD = 0.5; + +const SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "1.0, 1.0, 1.0"; +const HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR = "0.0, 0.0, 0.0"; +const ACTIVE_NODE_BORDER_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); +// Muted colors for the selected (pinned) node +const SELECTED_NODE_HIGHLIGHT_COLORS: readonly vec3[] = [ + vec3.fromValues(0.1, 0.1, 0.1), // near-black + vec3.fromValues(0.7, 0.67, 0.6), // stone (light warm gray) + vec3.fromValues(0.5, 0.45, 0.15), // olive +]; -const SELECTED_NODE_OUTLINE_FALLBACK_COLOR = vec3.fromValues(1.0, 0.95, 0.35); -const SELECTED_NODE_OUTLINE_MIN_WIDTH_2D = "1.75"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_2D = "3.0"; -const SELECTED_NODE_OUTLINE_MIN_WIDTH_3D = "1.5"; -const SELECTED_NODE_OUTLINE_MAX_WIDTH_3D = "2.5"; +// Used for debugging chunks via a different color for each chunk +const tempChunkKeyToColorMap = new Map(); +const tempMat4 = mat4.create(); interface VertexAttributeRenderInfo extends VertexAttributeInfo { name: string; @@ -760,17 +784,22 @@ void emitDefault() { ); builder.addUniform("highp float", "uNodeDiameter"); let selectedOutlineWidthExpression = "0.0"; + let borderOutlineWidthExpression = "0.0"; if (this.nodeIdAttributeIndex !== undefined) { builder.addUniform("highp vec3", "uSelectedNodeOutlineColor"); builder.addUniform("highp int", "uSelectedNodeId"); builder.addVarying("highp float", "vSelectedNode", "flat"); - const selectedOutlineMinWidth = this.targetIsSliceView - ? SELECTED_NODE_OUTLINE_MIN_WIDTH_2D - : SELECTED_NODE_OUTLINE_MIN_WIDTH_3D; - const selectedOutlineMaxWidth = this.targetIsSliceView - ? SELECTED_NODE_OUTLINE_MAX_WIDTH_2D - : SELECTED_NODE_OUTLINE_MAX_WIDTH_3D; - selectedOutlineWidthExpression = `(vSelectedNode * clamp(0.25 * uNodeDiameter, ${selectedOutlineMinWidth}, ${selectedOutlineMaxWidth}))`; + builder.addUniform("highp vec3", "uHighlightedNodeOutlineColor"); + builder.addUniform("highp int", "uHighlightedNodeId"); + builder.addVarying("highp float", "vHighlightedNode", "flat"); + selectedOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_BORDER_DIAMETER_FRACTION} * uNodeDiameter, ${ACTIVE_NODE_BORDER_MIN_WIDTH}, ${ACTIVE_NODE_BORDER_MAX_WIDTH}))`; + const borderOutlineMinWidth = + ACTIVE_NODE_BORDER_MIN_WIDTH * + ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; + const borderOutlineMaxWidth = + ACTIVE_NODE_BORDER_MAX_WIDTH * + ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION; + borderOutlineWidthExpression = `(max(vSelectedNode, vHighlightedNode) * clamp(${ACTIVE_NODE_OUTLINE_DIAMETER_FRACTION} * uNodeDiameter, ${borderOutlineMinWidth}, ${borderOutlineMaxWidth}))`; } let vertexMain = ` highp uint vertexIndex = uint(gl_InstanceID); @@ -783,6 +812,7 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); } if (this.nodeIdAttributeIndex !== undefined) { vertexMain += `vSelectedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uSelectedNodeId);\n`; + vertexMain += `vHighlightedNode = float(readAttribute${this.nodeIdAttributeIndex}(vertexIndex).value == uHighlightedNodeId);\n`; } if ( skeletonParams.dynamicSegmentAppearance && @@ -794,7 +824,8 @@ highp vec3 vertexPosition = readAttribute0(vertexIndex); emitCircle( uProjection * vec4(vertexPosition, 1.0), uNodeDiameter, - ${selectedOutlineWidthExpression} + ${selectedOutlineWidthExpression}, + ${borderOutlineWidthExpression} ); `; const segmentColorExpression = this.getSegmentColorExpression(); @@ -807,8 +838,13 @@ emitCircle( // getSegmentAppearance(). uColor is unused in this path. const segmentExpression = `vSegmentValue`; const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` + : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -820,7 +856,8 @@ void emitRGBA(vec4 color) { if (alpha <= 0.0) discard; vec4 renderColor = vec4(color.rgb, alpha); vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -853,8 +890,13 @@ void emitDefault() { // Per-vertex color attribute path: color comes from a per-vertex // attribute; alpha is taken from the attribute's alpha component. const hasNodeIdSelection = this.nodeIdAttributeIndex !== undefined; + // Apply the selected outline first, then the hovered outline, so the + // hovered color wins when a node is both selected and hovered. const borderColorExpression = hasNodeIdSelection - ? `mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode)` + ? `mix(mix(renderColor, vec4(uSelectedNodeOutlineColor, renderColor.a), vSelectedNode), vec4(uHighlightedNodeOutlineColor, renderColor.a), vHighlightedNode)` + : "renderColor"; + const borderOutlineColorExpression = hasNodeIdSelection + ? `mix(mix(renderColor, vec4(${SELECTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vSelectedNode), vec4(${HIGHLIGHTED_NODE_BORDER_OUTLINE_GLSL_COLOR}, renderColor.a), vHighlightedNode)` : "renderColor"; builder.addFragmentCode(` vec4 segmentColor() { @@ -863,7 +905,8 @@ vec4 segmentColor() { void emitRGBA(vec4 color) { vec4 renderColor = color; vec4 borderColor = ${borderColorExpression}; - vec4 circleColor = getCircleColor(renderColor, borderColor); + vec4 borderOutlineColor = ${borderOutlineColorExpression}; + vec4 circleColor = getCircleColor(renderColor, borderColor, borderOutlineColor); emit(vec4(circleColor.rgb * circleColor.a, circleColor.a), vPickID); } void emitRGB(vec3 color) { @@ -960,7 +1003,6 @@ void emitDefault() { nodeShader: ShaderProgram, skeletonGpuGeometry: SkeletonGPUGeometry, projectionParameters: { width: number; height: number }, - drawNodes: boolean, ) { // Bind vertex attribute textures to be used across edge and node shaders // The edge shader and node shader share the same texture unit for each attribute @@ -1000,8 +1042,8 @@ void emitDefault() { gl.disableVertexAttribArray(aVertexIndex); } - // Draw nodes if in line and node mode - if (drawNodes) { + // Draw nodes + { nodeShader.bind(); initializeCircleShader(nodeShader, projectionParameters, { featherWidthInPixels: this.targetIsSliceView ? 1.0 : 0.0, @@ -1333,9 +1375,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { const { shaderControlState } = this.displayState.skeletonRenderingOptions; - const drawNodes = - renderOptions.mode.value === SkeletonRenderMode.LINES_AND_POINTS; - edgeShader.bind(); renderHelper.beginLayer(gl, edgeShader, renderContext, modelMatrix); renderHelper.setPickInstanceStride(gl, edgeShader, 0); @@ -1348,24 +1387,21 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth!); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); const skeletons = source.chunks; - forEachVisibleSegmentToDraw( displayState, layer, @@ -1380,21 +1416,19 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { ) { return; } + edgeShader.bind(); if (color !== undefined) { - edgeShader.bind(); renderHelper.setColor(gl, edgeShader, color); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setColor(gl, nodeShader, color); - } } if (pickIndex !== undefined) { - edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, pickIndex); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, pickIndex); - } + } + nodeShader.bind(); + if (color !== undefined) { + renderHelper.setColor(gl, nodeShader, color); + } + if (pickIndex !== undefined) { + renderHelper.setPickID(gl, nodeShader, pickIndex); } renderHelper.drawSkeletons( gl, @@ -1402,7 +1436,6 @@ export class SkeletonLayer extends RefCounted implements SkeletonShaderContext { nodeShader, skeleton, renderContext.projectionParameters, - drawNodes, ); }, ); @@ -1844,6 +1877,9 @@ interface SpatiallyIndexedSkeletonLayerOptions { selectedNodeInfo?: WatchableValueInterface< SelectedSkeletonNodeInfo | undefined >; + hoveredNodeInfo?: WatchableValueInterface< + SelectedSkeletonNodeInfo | undefined + >; pendingNodePositionVersion?: WatchableValueInterface; getPendingNodePosition?: (nodeId: number) => ArrayLike | undefined; getCachedNode?: (nodeId: number) => SpatiallyIndexedSkeletonNode | undefined; @@ -2073,6 +2109,9 @@ export class SpatiallyIndexedSkeletonLayer private selectedNodeInfo: | WatchableValueInterface | undefined; + private hoveredNodeInfo: + | WatchableValueInterface + | undefined; private pendingNodePositionVersion: | WatchableValueInterface | undefined; @@ -2094,10 +2133,15 @@ export class SpatiallyIndexedSkeletonLayer private retainedOverlaySegmentIds: number[] = []; private maxRetainedOverlaySegments: number; private readonly selectedNodeOutlineColor = vec3.clone( - SELECTED_NODE_OUTLINE_FALLBACK_COLOR, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, + ); + private readonly highlightedNodeOutlineColor = vec3.clone( + ACTIVE_NODE_BORDER_FALLBACK_COLOR, ); - private selectedNodeOutlineColorGeneration = 0; - private cachedSelectedNodeOutlineColorGeneration = -1; + // The selected and hovered outline colors are derived together from a single + // source segment color, so they share one cache generation. + private nodeOutlineColorGeneration = 0; + private cachedNodeOutlineColorGeneration = -1; private disposeOverlayChunk() { this.overlayChunk?.dispose(this.gl); @@ -2150,27 +2194,68 @@ export class SpatiallyIndexedSkeletonLayer return segmentIds; } - private getSelectedNodeOutlineColor() { - const nodeInfo = this.selectedNodeInfo?.value; - if (nodeInfo === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; - } - const currentGeneration = this.selectedNodeOutlineColorGeneration; - if (this.cachedSelectedNodeOutlineColorGeneration === currentGeneration) { - return this.selectedNodeOutlineColor; - } + // Segment fill color a node's outline should contrast against, or undefined + // when no segment can be resolved. Falls back to the currently selected + // segment when the node carries no segment id. + private getNodeSegmentColor( + nodeInfo: SelectedSkeletonNodeInfo, + ): Float32Array | undefined { const segmentId = nodeInfo.segmentId !== undefined ? BigInt(nodeInfo.segmentId) : this.displayState.segmentSelectionState.baseValue; if (segmentId === undefined) { - return SELECTED_NODE_OUTLINE_FALLBACK_COLOR; + return undefined; + } + return getBaseObjectColor(this.displayState, segmentId); + } + + private updateNodeOutlineColors() { + const currentGeneration = this.nodeOutlineColorGeneration; + if (this.cachedNodeOutlineColorGeneration === currentGeneration) { + return; + } + this.cachedNodeOutlineColorGeneration = currentGeneration; + + const selectedNodeInfo = this.selectedNodeInfo?.value; + const selectedSegmentColor = + selectedNodeInfo !== undefined + ? this.getNodeSegmentColor(selectedNodeInfo) + : undefined; + if (selectedSegmentColor !== undefined) { + this.selectedNodeOutlineColor.set( + pickHighestContrastColor( + SELECTED_NODE_HIGHLIGHT_COLORS, + selectedSegmentColor, + ), + ); + } else { + vec3.copy( + this.selectedNodeOutlineColor, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, + ); + } + + const hoveredNodeInfo = this.hoveredNodeInfo?.value; + const hoveredSegmentColor = + hoveredNodeInfo !== undefined + ? this.getNodeSegmentColor(hoveredNodeInfo) + : undefined; + if (hoveredSegmentColor !== undefined) { + const saturationFactor = + getSaturation(hoveredSegmentColor) > + HIGHLIGHTED_NODE_BORDER_SATURATION_THRESHOLD + ? 1.0 - HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR + : 1.0 + HIGHLIGHTED_NODE_BORDER_SATURATION_FACTOR; + this.highlightedNodeOutlineColor.set( + saturateColor(hoveredSegmentColor, saturationFactor), + ); + } else { + vec3.copy( + this.highlightedNodeOutlineColor, + ACTIVE_NODE_BORDER_FALLBACK_COLOR, + ); } - this.cachedSelectedNodeOutlineColorGeneration = currentGeneration; - return computeHighVisibilityContrastColor( - this.selectedNodeOutlineColor, - getBaseObjectColor(this.displayState, segmentId), - ); } getRetainedOverlaySegmentIds() { @@ -2372,6 +2457,7 @@ export class SpatiallyIndexedSkeletonLayer ), ); this.selectedNodeInfo = options.selectedNodeInfo; + this.hoveredNodeInfo = options.hoveredNodeInfo; this.pendingNodePositionVersion = options.pendingNodePositionVersion; this.getPendingNodePositionOverride = options.getPendingNodePosition; this.getCachedNodeInfo = options.getCachedNode; @@ -2384,8 +2470,8 @@ export class SpatiallyIndexedSkeletonLayer ), ); registerRedrawWhenSegmentationDisplayState3DChanged(displayState, this); - const invalidateSelectedNodeOutlineColor = () => { - ++this.selectedNodeOutlineColorGeneration; + const invalidateNodeOutlineColors = () => { + ++this.nodeOutlineColorGeneration; }; this.displayState.shaderError.value = undefined; const { skeletonRenderingOptions: renderingOptions } = displayState; @@ -2437,17 +2523,17 @@ export class SpatiallyIndexedSkeletonLayer registerNested((context, colorGroupState) => { context.registerDisposer( colorGroupState.segmentColorHash.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentStatedColors.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); context.registerDisposer( colorGroupState.segmentDefaultColor.changed.add( - invalidateSelectedNodeOutlineColor, + invalidateNodeOutlineColors, ), ); }, this.displayState.segmentationColorGroupState), @@ -2482,7 +2568,15 @@ export class SpatiallyIndexedSkeletonLayer if (this.selectedNodeInfo?.changed) { this.registerDisposer( this.selectedNodeInfo.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); + requestRedraw(); + }), + ); + } + if (this.hoveredNodeInfo?.changed) { + this.registerDisposer( + this.hoveredNodeInfo.changed.add(() => { + invalidateNodeOutlineColors(); requestRedraw(); }), ); @@ -2497,7 +2591,7 @@ export class SpatiallyIndexedSkeletonLayer if (inspectionState !== undefined) { this.registerDisposer( inspectionState.nodeDataVersion.changed.add(() => { - invalidateSelectedNodeOutlineColor(); + invalidateNodeOutlineColors(); this.redrawNeeded.dispatch(); }), ); @@ -2803,7 +2897,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, excludedGPUTable?: GPUHashTable, ): | { @@ -2829,7 +2922,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShaderResult; if (edgeShader === null || nodeShader === null) return undefined; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; const { shaderControlState } = this.displayState.skeletonRenderingOptions; edgeShader.bind(); @@ -2837,7 +2929,7 @@ export class SpatiallyIndexedSkeletonLayer gl.uniform1f(edgeShader.uniform("uLineWidth"), lineWidth); gl.uniform1f( edgeShader.uniform("uLineEndpointClipRadius"), - drawNodes ? pointDiameter / 2 : 0, + pointDiameter / 2, ); renderHelper.setPickInstanceStride(gl, edgeShader, 0); setControlsInShader( @@ -2854,25 +2946,23 @@ export class SpatiallyIndexedSkeletonLayer excludedGPUTable, ); - if (drawNodes) { - nodeShader.bind(); - renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); - gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); - renderHelper.setPickInstanceStride(gl, nodeShader, 0); - setControlsInShader( - gl, - nodeShader, - shaderControlState, - nodeShaderParameters.parseResult, - ); - renderHelper.setColor(gl, nodeShader, kOneVec4); - renderHelper.maybeEnableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - excludedGPUTable, - ); - } + nodeShader.bind(); + renderHelper.beginLayer(gl, nodeShader, renderContext, modelMatrix); + gl.uniform1f(nodeShader.uniform("uNodeDiameter"), pointDiameter); + renderHelper.setPickInstanceStride(gl, nodeShader, 0); + setControlsInShader( + gl, + nodeShader, + shaderControlState, + nodeShaderParameters.parseResult, + ); + renderHelper.setColor(gl, nodeShader, kOneVec4); + renderHelper.maybeEnableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + excludedGPUTable, + ); return { gl, @@ -2888,20 +2978,17 @@ export class SpatiallyIndexedSkeletonLayer edgeShader: ShaderProgram, nodeShader: ShaderProgram, skeletonParams: SkeletonShaderParameters, - drawNodes: boolean, ) { renderHelper.maybeDisableDynamicSegmentAppearance( gl, edgeShader, skeletonParams, ); - if (drawNodes) { - renderHelper.maybeDisableDynamicSegmentAppearance( - gl, - nodeShader, - skeletonParams, - ); - } + renderHelper.maybeDisableDynamicSegmentAppearance( + gl, + nodeShader, + skeletonParams, + ); renderHelper.endLayer(gl, edgeShader, nodeShader); } @@ -2912,7 +2999,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, visibleChunks: VisibleChunk[], ) { if (visibleChunks.length === 0) return; @@ -2924,24 +3010,29 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, hasExcludedSegments ? this.gpuBrowseExcludedSegmentsHashTable : undefined, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColors(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); const chunkOrigin = vec3.create(); const chunkBound = vec3.create(); @@ -2951,10 +3042,8 @@ export class SpatiallyIndexedSkeletonLayer vec3.add(chunkBound, chunkOrigin, chunkLayout.size); edgeShader.bind(); renderHelper.setChunkBounds(gl, edgeShader, chunkOrigin, chunkBound); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); - } + nodeShader.bind(); + renderHelper.setChunkBounds(gl, nodeShader, chunkOrigin, chunkBound); } if (renderContext.emitPickID) { let edgePickId = 0; @@ -2973,7 +3062,7 @@ export class SpatiallyIndexedSkeletonLayer ); edgePickStride = 1; } - if (chunk.numVertices > 0 && drawNodes) { + if (chunk.numVertices > 0) { nodePickId = renderContext.pickIDs.register( layer, chunk.numVertices, @@ -2988,11 +3077,9 @@ export class SpatiallyIndexedSkeletonLayer edgeShader.bind(); renderHelper.setPickID(gl, edgeShader, edgePickId); renderHelper.setPickInstanceStride(gl, edgeShader, edgePickStride); - if (drawNodes) { - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); - } + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride(gl, nodeShader, nodePickStride); } // Render each chunk with different node/edge colors for debugging @@ -3014,13 +3101,11 @@ export class SpatiallyIndexedSkeletonLayer tempChunkKeyToColorMap.set(chunkKey, randomColor); } if (skeletonParams.hasSegmentDefaultColor) { - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSegmentDefaultColor"), - randomColor, - ); - } + nodeShader.bind(); + gl.uniform3fv( + nodeShader.uniform("uSegmentDefaultColor"), + randomColor, + ); edgeShader.bind(); gl.uniform3fv( edgeShader.uniform("uSegmentDefaultColor"), @@ -3035,7 +3120,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, chunk, renderContext.projectionParameters, - drawNodes, ); } this.endSkeletonRenderPass( @@ -3044,7 +3128,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3055,7 +3138,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix: mat4, lineWidth: number, pointDiameter: number, - renderMode: SkeletonRenderMode, ) { const overlayChunk = this.resolveSourceBackedOverlayChunk(); if (overlayChunk === undefined) return; @@ -3065,23 +3147,28 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderMode, ); if (passState === undefined) return; const { gl, edgeShader, nodeShader, skeletonParams } = passState; - const drawNodes = renderMode === SkeletonRenderMode.LINES_AND_POINTS; - if (drawNodes) { - nodeShader.bind(); - gl.uniform3fv( - nodeShader.uniform("uSelectedNodeOutlineColor"), - this.getSelectedNodeOutlineColor(), - ); - gl.uniform1i( - nodeShader.uniform("uSelectedNodeId"), - this.selectedNodeInfo?.value?.nodeId ?? -1, - ); - } + nodeShader.bind(); + this.updateNodeOutlineColors(); + gl.uniform3fv( + nodeShader.uniform("uSelectedNodeOutlineColor"), + this.selectedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uSelectedNodeId"), + this.selectedNodeInfo?.value?.nodeId ?? -1, + ); + gl.uniform3fv( + nodeShader.uniform("uHighlightedNodeOutlineColor"), + this.highlightedNodeOutlineColor, + ); + gl.uniform1i( + nodeShader.uniform("uHighlightedNodeId"), + this.hoveredNodeInfo?.value?.nodeId ?? -1, + ); if (renderContext.emitPickID) { const edgePickId = @@ -3106,32 +3193,30 @@ export class SpatiallyIndexedSkeletonLayer edgePickId === 0 ? 0 : 1, ); - if (drawNodes) { - const nodePickId = - overlayChunk.numVertices > 0 && - overlayChunk.pickNodeIds !== undefined && - overlayChunk.pickNodePositions !== undefined && - overlayChunk.pickSegmentIds !== undefined - ? renderContext.pickIDs.register( - layer, - overlayChunk.numVertices, - 0n, - { - kind: "node", - nodeIds: overlayChunk.pickNodeIds, - nodePositions: overlayChunk.pickNodePositions, - segmentIds: overlayChunk.pickSegmentIds, - } satisfies SpatiallyIndexedSkeletonPickData, - ) - : 0; - nodeShader.bind(); - renderHelper.setPickID(gl, nodeShader, nodePickId); - renderHelper.setPickInstanceStride( - gl, - nodeShader, - nodePickId === 0 ? 0 : 1, - ); - } + const nodePickId = + overlayChunk.numVertices > 0 && + overlayChunk.pickNodeIds !== undefined && + overlayChunk.pickNodePositions !== undefined && + overlayChunk.pickSegmentIds !== undefined + ? renderContext.pickIDs.register( + layer, + overlayChunk.numVertices, + 0n, + { + kind: "node", + nodeIds: overlayChunk.pickNodeIds, + nodePositions: overlayChunk.pickNodePositions, + segmentIds: overlayChunk.pickSegmentIds, + } satisfies SpatiallyIndexedSkeletonPickData, + ) + : 0; + nodeShader.bind(); + renderHelper.setPickID(gl, nodeShader, nodePickId); + renderHelper.setPickInstanceStride( + gl, + nodeShader, + nodePickId === 0 ? 0 : 1, + ); } renderHelper.drawSkeletons( @@ -3140,7 +3225,6 @@ export class SpatiallyIndexedSkeletonLayer nodeShader, overlayChunk, renderContext.projectionParameters, - drawNodes, ); this.endSkeletonRenderPass( renderHelper, @@ -3148,7 +3232,6 @@ export class SpatiallyIndexedSkeletonLayer edgeShader, nodeShader, skeletonParams, - drawNodes, ); } @@ -3182,7 +3265,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, visibleChunks, ); this.drawInspectionOverlayPass( @@ -3192,7 +3274,6 @@ export class SpatiallyIndexedSkeletonLayer modelMatrix, lineWidth, pointDiameter, - renderOptions.mode.value, ); } diff --git a/src/ui/skeleton_tab.ts b/src/ui/skeleton_tab.ts index 005b0291fb..7a49f32033 100644 --- a/src/ui/skeleton_tab.ts +++ b/src/ui/skeleton_tab.ts @@ -511,7 +511,7 @@ export class SpatialSkeletonEditTab extends Tab { layer.getSpatialSkeletonNodeDisplayDescription(node); const getHoveredNodeIdFromViewer = () => { - return layer.hoveredSpatialSkeletonNodeId.value; + return layer.hoveredSpatialSkeletonNodeInfo.value?.nodeId; }; const getSelectedSegmentId = () => { @@ -1723,7 +1723,7 @@ export class SpatialSkeletonEditTab extends Tab { }), ); this.registerDisposer( - layer.hoveredSpatialSkeletonNodeId.changed.add(() => { + layer.hoveredSpatialSkeletonNodeInfo.changed.add(() => { updateHoveredViewerNode(); }), ); diff --git a/src/util/color.browser_test.ts b/src/util/color.browser_test.ts index 0f1c32d9f2..90b60f53fb 100644 --- a/src/util/color.browser_test.ts +++ b/src/util/color.browser_test.ts @@ -16,13 +16,10 @@ import { describe, it, expect } from "vitest"; import { - computeHighVisibilityContrastColor, - getContrastRatio, parseColorSerialization, parseRGBColorSpecification, packColor, serializeColor, - useWhiteBackground, } from "#src/util/color.js"; import { vec3, vec4 } from "#src/util/geom.js"; @@ -83,120 +80,4 @@ describe("color", () => { expect(packColor(vec4.fromValues(0, 0.2, 2, 1))).toEqual(0xffff3300); expect(packColor(vec4.fromValues(0.4, 4.4, -0.4, 4))).toEqual(0xff00ff66); }); -}); - -function expectColorClose(actual: Float32Array, expected: readonly number[]) { - for (let i = 0; i < 3; ++i) { - expect(actual[i]).toBeCloseTo(expected[i]); - } -} - -describe("useWhiteBackground", () => { - it("works for simple cases", () => { - expect(useWhiteBackground(vec3.fromValues(0, 0, 0))).toBe(true); - expect(useWhiteBackground(vec3.fromValues(1, 1, 1))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(1, 0, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 1, 0))).toBe(false); - expect(useWhiteBackground(vec3.fromValues(0, 0, 1))).toBe(true); - }); -}); - -describe("getContrastRatio", () => { - it("matches WCAG contrast-ratio reference values", () => { - expect( - getContrastRatio(vec3.fromValues(0, 0, 0), vec3.fromValues(1, 1, 1)), - ).toBeCloseTo(21); - expect( - getContrastRatio( - vec3.fromValues(0.5, 0.5, 0.5), - vec3.fromValues(0.5, 0.5, 0.5), - ), - ).toBeCloseTo(1); - }); -}); - -describe("computeHighVisibilityContrastColor", () => { - it("prefers yellow for dark colors", () => { - const sourceColor = vec3.fromValues(0, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses red for bright colors", () => { - const sourceColor = vec3.fromValues(1, 1, 1); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for red segment colors", () => { - const sourceColor = vec3.fromValues(1, 0, 0); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for low-saturation midtone colors", () => { - const sourceColor = vec3.fromValues(0.5, 0.5, 0.5); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - expect(getContrastRatio(color, sourceColor)).toBeGreaterThanOrEqual(3); - }); - - it("uses yellow for near-black colors", () => { - const sourceColor = vec3.fromValues(0.05, 0.05, 0.05); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0.95, 0.35]); - }); - - it("uses red for near-white colors", () => { - const sourceColor = vec3.fromValues(0.95, 0.95, 0.95); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); - - it("uses red for yellow-like segment colors", () => { - const sourceColor = vec3.fromValues(1, 0.95, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); - - it("uses red when yellow would be close to the segment color", () => { - const sourceColor = vec3.fromValues(0.35, 1, 0.35); - const color = computeHighVisibilityContrastColor( - vec3.create(), - sourceColor, - ); - - expectColorClose(color, [1, 0, 0]); - }); -}); +}); \ No newline at end of file diff --git a/src/util/color.ts b/src/util/color.ts index 45d8058b81..f89d2d392c 100644 --- a/src/util/color.ts +++ b/src/util/color.ts @@ -142,9 +142,15 @@ export function serializeColor(x: vec3 | vec4) { return result; } -// Converts an sRGB color component to the gamma-expanded ("linear") value. -export function srgbGammaExpand(value: number) { - return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; +// Determines whether a white background would provide higher contrast than a black background for +// the given foreground color. +// +// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: +// https://www.w3.org/TR/WCAG20/#contrast-ratiodef +// +// https://stackoverflow.com/a/3943023 +export function useWhiteBackground(foregroundColor: vec3 | vec4) { + return getRelativeLuminance(foregroundColor) <= 0.179; } // Computes the relative luminance according to Web Content Accessibility Guidelines (WCAG) 2.0 @@ -153,6 +159,11 @@ export function srgbGammaExpand(value: number) { // // @param color sRGB color export function getRelativeLuminance(color: ArrayLike) { + // Converts an sRGB color component to the gamma-expanded ("linear") value. + function srgbGammaExpand(value: number) { + return value <= 0.03928 ? value / 12.92 : ((value + 0.055) / 1.055) ** 2.4; + } + return ( 0.2126 * srgbGammaExpand(color[0]) + 0.7152 * srgbGammaExpand(color[1]) + @@ -171,35 +182,41 @@ export function getContrastRatio( return (lighter + 0.05) / (darker + 0.05); } -// Determines whether a white background would provide higher contrast than a black background for -// the given foreground color. -// -// This is determined according to the Web Content Accessibility Guidelines (WCAG) 2.0: -// https://www.w3.org/TR/WCAG20/#contrast-ratiodef -// -// https://stackoverflow.com/a/3943023 -export function useWhiteBackground(foregroundColor: vec3 | vec4) { - return getRelativeLuminance(foregroundColor) <= 0.179; +// Returns the HSV saturation of `color`: the fraction by which its most intense +// channel exceeds its least intense channel. 0 for greys, 1 for fully saturated colors. +export function getSaturation(color: ArrayLike): number { + const max = Math.max(color[0], color[1], color[2]); + if (max <= 0) return 0; + const min = Math.min(color[0], color[1], color[2]); + return (max - min) / max; } -const yellowHighlight = vec3.fromValues(1, 0.95, 0.35); -const redHighlight = vec3.fromValues(1, 0, 0); -const YELLOW_HIGHLIGHT_CONTRAST_BIAS = 1.2; +// Returns a copy of `color` with saturation boosted by `factor` (moves each channel +// away from the perceptual-grey axis by the given multiplier, clamped to [0, 1]). +export function saturateColor(color: ArrayLike, factor: number): vec3 { + const lum = getRelativeLuminance(color); + return vec3.fromValues( + Math.min(1.0, Math.max(0.0, lum + (color[0] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[1] - lum) * factor)), + Math.min(1.0, Math.max(0.0, lum + (color[2] - lum) * factor)), + ); +} -export function computeHighVisibilityContrastColor( - out: T, +// Returns the palette color with the highest contrast against `sourceColor`. +export function pickHighestContrastColor( + palette: readonly vec3[], sourceColor: ArrayLike, -) { - const yellowContrast = getContrastRatio(yellowHighlight, sourceColor); - const redContrast = getContrastRatio(redHighlight, sourceColor); - const color = - redContrast > yellowContrast * YELLOW_HIGHLIGHT_CONTRAST_BIAS - ? redHighlight - : yellowHighlight; - out[0] = color[0]; - out[1] = color[1]; - out[2] = color[2]; - return out; +): vec3 { + let bestColor = palette[0]; + let bestContrast = -1; + for (const candidate of palette) { + const contrast = getContrastRatio(candidate, sourceColor); + if (contrast > bestContrast) { + bestContrast = contrast; + bestColor = candidate; + } + } + return bestColor; } export class TrackableRGB extends WatchableValue { diff --git a/src/webgl/circles.ts b/src/webgl/circles.ts index 3015542127..b98ae743e4 100644 --- a/src/webgl/circles.ts +++ b/src/webgl/circles.ts @@ -39,22 +39,29 @@ export function defineCircleShader( // 2-D position within circle quad, ranging from [-1, -1] to [1, 1]. builder.addVarying("highp vec4", "vCircleCoord"); + // Normalized radius where the first border ends and the border outline begins. + builder.addVarying("highp float", "vCircleBorderFraction"); builder.addVertexCode(` -void emitCircle(vec4 position, float diameter, float borderWidth) { +void emitCircle(vec4 position, float diameter, float borderWidth, float borderOutlineWidth) { gl_Position = position; - float totalDiameter = diameter + 2.0 * (borderWidth + uCircleParams.z); + float totalDiameter = diameter + 2.0 * (borderWidth + borderOutlineWidth + uCircleParams.z); if (diameter == 0.0) totalDiameter = 0.0; vec2 circleCornerOffset = getQuadVertexPosition(vec2(-1.0, -1.0), vec2(1.0, 1.0)); gl_Position.xy += circleCornerOffset * uCircleParams.xy * gl_Position.w * totalDiameter; vCircleCoord.xy = circleCornerOffset; - if (borderWidth == 0.0) { + if (borderWidth == 0.0 && borderOutlineWidth == 0.0) { vCircleCoord.z = totalDiameter; vCircleCoord.w = 1e-6; + vCircleBorderFraction = totalDiameter; } else { vCircleCoord.z = diameter / totalDiameter; + vCircleBorderFraction = (diameter + 2.0 * borderWidth) / totalDiameter; vCircleCoord.w = uCircleParams.z / totalDiameter; } } +void emitCircle(vec4 position, float diameter, float borderWidth) { + emitCircle(position, diameter, borderWidth, 0.0); +} `); if (crossSectionFade) { builder.addFragmentCode(` @@ -70,18 +77,23 @@ float getCircleAlphaMultiplier() { `); } builder.addFragmentCode(` -vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor, vec4 borderOutlineColor) { float radius = length(vCircleCoord.xy); if (radius > 1.0) { discard; } float borderColorFraction = clamp((radius - vCircleCoord.z) / vCircleCoord.w, 0.0, 1.0); + float outlineColorFraction = clamp((radius - vCircleBorderFraction) / vCircleCoord.w, 0.0, 1.0); float feather = clamp((1.0 - radius) / vCircleCoord.w, 0.0, 1.0); vec4 color = mix(interiorColor, borderColor, borderColorFraction); + color = mix(color, borderOutlineColor, outlineColorFraction); return vec4(color.rgb, color.a * feather * getCircleAlphaMultiplier()); } +vec4 getCircleColor(vec4 interiorColor, vec4 borderColor) { + return getCircleColor(interiorColor, borderColor, borderColor); +} `); }