From 9e92f62f2af1749c22c19b19af51f09c65ffcd30 Mon Sep 17 00:00:00 2001 From: Val Redchenko Date: Thu, 25 Jun 2026 16:27:17 +0100 Subject: [PATCH] feat(smartem): add gradient hole colouring and sequence break indicators When the path overlay is active, foilhole fills now reflect acquisition order using the same blue-to-orange gradient as the path line, giving quick-glance evaluation at the hole level. Segments where the grid-wide acquisition index jumps non-consecutively (indicating the microscope leaves the square and returns later) are rendered as dashed red lines across all three path styles. The tooltip also shows "break after" when hovering a hole that precedes a sequence break. --- .../spatial/AcquisitionPathOverlay.tsx | 80 +++++++++++-------- .../src/components/spatial/SquareMap.tsx | 52 ++++++++++-- 2 files changed, 95 insertions(+), 37 deletions(-) diff --git a/apps/smartem/src/components/spatial/AcquisitionPathOverlay.tsx b/apps/smartem/src/components/spatial/AcquisitionPathOverlay.tsx index 45f4df4..8fad6dd 100644 --- a/apps/smartem/src/components/spatial/AcquisitionPathOverlay.tsx +++ b/apps/smartem/src/components/spatial/AcquisitionPathOverlay.tsx @@ -15,6 +15,7 @@ export interface PathPoint { x: number y: number rank: number + gridIndex: number } interface AcquisitionPathOverlayProps { @@ -24,11 +25,12 @@ interface AcquisitionPathOverlayProps { vbW: number } -const START_COLOR = '#2f6feb' -const END_COLOR = '#e59344' +export const START_COLOR = '#2f6feb' +export const END_COLOR = '#e59344' const LINE_COLOR = '#2f6feb' +const BREAK_COLOR = '#cf222e' -function lerpColor(a: string, b: string, t: number): string { +export function lerpColor(a: string, b: string, t: number): string { const pa = [ Number.parseInt(a.slice(1, 3), 16), Number.parseInt(a.slice(3, 5), 16), @@ -67,7 +69,9 @@ export function AcquisitionPathOverlay({ points, total, style, vbW }: Acquisitio const first = points[0] const last = points[points.length - 1] const prevLast = points[points.length - 2] - const polyPoints = points.map((p) => `${p.x},${p.y}`).join(' ') + + const isBreak = (i: number) => + i < points.length - 1 && points[i + 1].gridIndex !== points[i].gridIndex + 1 const badge = (p: PathPoint) => ( @@ -101,34 +105,46 @@ export function AcquisitionPathOverlay({ points, total, style, vbW }: Acquisitio return ( - {style === 'gradient' ? ( - points.slice(0, -1).map((p, i) => { - const next = points[i + 1] - return ( - - ) - }) - ) : ( - - )} + {style === 'gradient' + ? points.slice(0, -1).map((p, i) => { + const next = points[i + 1] + const segColor = lerpColor(START_COLOR, END_COLOR, i / (points.length - 1)) + const brk = isBreak(i) + return ( + + ) + }) + : points.slice(0, -1).map((p, i) => { + const next = points[i + 1] + const brk = isBreak(i) + return ( + + ) + })} {/* End-of-path arrowhead for the styles that rely on it for direction. */} {(style === 'gradient' || style === 'sparse') && ( diff --git a/apps/smartem/src/components/spatial/SquareMap.tsx b/apps/smartem/src/components/spatial/SquareMap.tsx index 46f3725..b3ca347 100644 --- a/apps/smartem/src/components/spatial/SquareMap.tsx +++ b/apps/smartem/src/components/spatial/SquareMap.tsx @@ -14,8 +14,11 @@ import { useCallback, useMemo, useState } from 'react' import { PaneFrame } from '~/components/layout/PaneFrame' import { AcquisitionPathOverlay, + END_COLOR, + lerpColor, type PathPoint, type PathStyle, + START_COLOR, } from '~/components/spatial/AcquisitionPathOverlay' import type { MockFoilHole } from '~/data/mock-session-detail' import { gray, statusColors } from '~/theme' @@ -240,11 +243,34 @@ export function SquareMap({ return idx ? [{ fh, idx }] : [] }) .sort((a, b) => a.idx - b.idx) - .map(({ fh }, i) => ({ uuid: fh.uuid, x: fh.xLocation, y: fh.yLocation, rank: i + 1 })) + .map(({ fh, idx }, i) => ({ + uuid: fh.uuid, + x: fh.xLocation, + y: fh.yLocation, + rank: i + 1, + gridIndex: idx, + })) }, [foilholes, orderByFoilhole]) const hasPath = pathPoints.length > 1 - const hoveredRank = - showPath && hoveredHole ? pathPoints.find((p) => p.uuid === hoveredHole.uuid)?.rank : undefined + + const rankByUuid = useMemo(() => { + const map = new Map() + for (const p of pathPoints) map.set(p.uuid, p.rank) + return map + }, [pathPoints]) + + const hoveredPathPoint = + showPath && hoveredHole ? pathPoints.find((p) => p.uuid === hoveredHole.uuid) : undefined + const hoveredRank = hoveredPathPoint?.rank + const hoveredBreakAfter = (() => { + if (!hoveredPathPoint) return false + const idx = pathPoints.indexOf(hoveredPathPoint) + return ( + idx >= 0 && + idx < pathPoints.length - 1 && + pathPoints[idx + 1].gridIndex !== hoveredPathPoint.gridIndex + 1 + ) + })() // Foilhole coords live in the micrograph's native pixel space; match the viewBox to it (falling // back to the standard gridsquare size until the image decodes) so the overlay stays on the image. @@ -500,8 +526,23 @@ export function SquareMap({ if (isNull && hideNull) return null - const fill = isNull ? 'black' : getFill(fh) - const opacity = isNull ? 0.2 : isHovered || isSelected ? 1.0 : 0.6 + const pathRank = showPath ? rankByUuid.get(fh.uuid) : undefined + const fill = isNull + ? 'black' + : pathRank != null + ? lerpColor( + START_COLOR, + END_COLOR, + (pathRank - 1) / Math.max(pathPoints.length - 1, 1) + ) + : getFill(fh) + const opacity = isNull + ? 0.2 + : isHovered || isSelected + ? 1.0 + : pathRank != null + ? 0.85 + : 0.6 return ( )}