diff --git a/apps/cadecon/src/components/raster/RasterOverview.tsx b/apps/cadecon/src/components/raster/RasterOverview.tsx index 580cd493..217a63e9 100644 --- a/apps/cadecon/src/components/raster/RasterOverview.tsx +++ b/apps/cadecon/src/components/raster/RasterOverview.tsx @@ -1,46 +1,26 @@ import { onMount, onCleanup, createEffect, createMemo, on, type JSX } from 'solid-js'; -import { parsedData, effectiveShape, swapped } from '../../lib/data-store.ts'; +import { parsedData, effectiveShape, swapped, durationSeconds } from '../../lib/data-store.ts'; import { subsetRectangles, selectedSubsetIdx, setSelectedSubsetIdx, } from '../../lib/subset-store.ts'; +import { VIRIDIS_LUT, niceTicks, AXIS_TEXT } from '@calab/ui/chart'; import '../../styles/raster.css'; -const VIRIDIS_LUT = buildViridisLUT(); - -function buildViridisLUT(): Uint8Array { - // Key stops from viridis: dark purple → teal → yellow - const stops = [ - [68, 1, 84], - [72, 35, 116], - [64, 67, 135], - [52, 94, 141], - [41, 120, 142], - [32, 144, 140], - [34, 167, 132], - [68, 190, 112], - [121, 209, 81], - [189, 222, 38], - [253, 231, 37], - ]; - - const lut = new Uint8Array(256 * 3); - for (let i = 0; i < 256; i++) { - const t = (i / 255) * (stops.length - 1); - const idx = Math.floor(t); - const frac = t - idx; - const a = stops[Math.min(idx, stops.length - 1)]; - const b = stops[Math.min(idx + 1, stops.length - 1)]; - lut[i * 3] = Math.round(a[0] + (b[0] - a[0]) * frac); - lut[i * 3 + 1] = Math.round(a[1] + (b[1] - a[1]) * frac); - lut[i * 3 + 2] = Math.round(a[2] + (b[2] - a[2]) * frac); - } - return lut; -} +// Plot margins (CSS px) reserved for the cell axis (left) and time axis +// (bottom); the heatmap fills the inner plot rect. No intensity colorbar — +// viridis runs low→high activity and the absolute values aren't meaningful. +// Right margin is just enough to keep the last time-axis label from clipping. +const MARGIN_LEFT = 42; +const MARGIN_RIGHT = 14; +const MARGIN_TOP = 10; +const MARGIN_BOTTOM = 30; // High-contrast colors chosen to stand out against viridis (purple-teal-yellow): -// warm reds, oranges, and pinks that don't appear in the viridis palette +// warm reds, oranges, and pinks that don't appear in the viridis palette. This +// is deliberately NOT the categorical Okabe-Ito series palette — the goal here +// is contrast against the colormap, not distinctness among many series. const SUBSET_STROKE = [ '#ff3333', // red '#ff8800', // orange @@ -63,14 +43,21 @@ const SUBSET_FILL = [ 'rgba(204, 204, 204, 0.12)', ]; +/** Format an axis value: integers plain, otherwise one decimal. */ +function formatTick(v: number): string { + return Number.isInteger(v) ? String(v) : v.toFixed(1); +} + export function RasterOverview(): JSX.Element { let canvasRef: HTMLCanvasElement | undefined; let containerRef: HTMLDivElement | undefined; let resizeObserver: ResizeObserver | undefined; - // Cache pixel dimensions for click detection - let lastWidth = 0; - let lastHeight = 0; + // Cached inner plot rect (CSS px) for click hit-testing against subset boxes. + let plotX = MARGIN_LEFT; + let plotY = MARGIN_TOP; + let plotW = 0; + let plotH = 0; /** Memoized 1st/99th percentile bounds -- recomputed only when the underlying data changes. */ const percentileBounds = createMemo(() => { @@ -112,7 +99,7 @@ export function RasterOverview(): JSX.Element { const displayHeight = rect?.height ?? Math.min(Math.max(200, N * 3), 500); const dpr = window.devicePixelRatio || 1; - // Size canvas at physical resolution + // Size canvas at physical resolution (also resets ctx transform to identity) const physW = Math.round(displayWidth * dpr); const physH = Math.round(displayHeight * dpr); canvas.width = physW; @@ -120,21 +107,29 @@ export function RasterOverview(): JSX.Element { canvas.style.width = `${displayWidth}px`; canvas.style.height = `${displayHeight}px`; - lastWidth = displayWidth; - lastHeight = displayHeight; + // Inner plot rect (CSS px) — cached for click hit-testing. + plotX = MARGIN_LEFT; + plotY = MARGIN_TOP; + plotW = Math.max(1, displayWidth - MARGIN_LEFT - MARGIN_RIGHT); + plotH = Math.max(1, displayHeight - MARGIN_TOP - MARGIN_BOTTOM); const { p1, range } = percentileBounds(); - // Draw heatmap at physical pixel resolution (putImageData ignores canvas transforms) - const imageData = ctx.createImageData(physW, physH); + // Draw heatmap into the plot rect at physical resolution (putImageData + // ignores canvas transforms, so position/size in physical pixels). + const ppX = Math.round(plotX * dpr); + const ppY = Math.round(plotY * dpr); + const ppW = Math.max(1, Math.round(plotW * dpr)); + const ppH = Math.max(1, Math.round(plotH * dpr)); + const imageData = ctx.createImageData(ppW, ppH); const pixels = imageData.data; - for (let py = 0; py < physH; py++) { - const cell = Math.floor((py / physH) * N); + for (let py = 0; py < ppH; py++) { + const cell = Math.floor((py / ppH) * N); const rowBase = isSwapped ? cell : cell * rawCols; - const rowPixelBase = py * physW; - for (let px = 0; px < physW; px++) { - const t = Math.floor((px / physW) * T); + const rowPixelBase = py * ppW; + for (let px = 0; px < ppW; px++) { + const t = Math.floor((px / ppW) * T); const v = typedData[isSwapped ? t * rawCols + rowBase : rowBase + t]; const normalized = Number.isFinite(v) ? Math.max(0, Math.min(255, Math.round(((v - p1) / range) * 255))) @@ -148,26 +143,81 @@ export function RasterOverview(): JSX.Element { } } - ctx.putImageData(imageData, 0, 0); + ctx.putImageData(imageData, ppX, ppY); + + // All subsequent vector drawing is in CSS px. + ctx.setTransform(dpr, 0, 0, dpr, 0, 0); + + drawAxes(ctx, N, T); + drawSubsets(ctx, N, T); + }; - // Draw subset rectangles (scale to CSS pixels since we skipped ctx.scale for ImageData) + /** Time axis (bottom) + cell axis (left). */ + const drawAxes = (ctx: CanvasRenderingContext2D, N: number, T: number) => { + ctx.fillStyle = AXIS_TEXT; + ctx.strokeStyle = AXIS_TEXT; + ctx.lineWidth = 1; + ctx.font = '10px system-ui, sans-serif'; + + // Time axis: seconds when the sampling rate is known, else frame index. + const dur = durationSeconds(); + const axisMax = dur && dur > 0 ? dur : T; + const axisUnit = dur && dur > 0 ? 's' : 'frame'; + ctx.textAlign = 'center'; + ctx.textBaseline = 'top'; + for (const tv of niceTicks(0, axisMax, 6)) { + if (tv > axisMax) continue; + const xPx = plotX + (tv / axisMax) * plotW; + ctx.beginPath(); + ctx.moveTo(xPx, plotY + plotH); + ctx.lineTo(xPx, plotY + plotH + 4); + ctx.stroke(); + ctx.fillText(formatTick(tv), xPx, plotY + plotH + 6); + } + ctx.fillText(`Time (${axisUnit})`, plotX + plotW / 2, plotY + plotH + 17); + + // Cell axis: index increasing downward. + ctx.textAlign = 'right'; + ctx.textBaseline = 'middle'; + for (const cv of niceTicks(0, N, 6)) { + if (cv > N) continue; + const yPx = plotY + (cv / N) * plotH; + ctx.beginPath(); + ctx.moveTo(plotX - 4, yPx); + ctx.lineTo(plotX, yPx); + ctx.stroke(); + ctx.fillText(String(cv), plotX - 6, yPx); + } + ctx.save(); + ctx.translate(11, plotY + plotH / 2); + ctx.rotate(-Math.PI / 2); + ctx.textAlign = 'center'; + ctx.textBaseline = 'middle'; + ctx.fillText('Cell', 0, 0); + ctx.restore(); + }; + + /** Clickable subset overlay boxes (K1..Kn), positioned within the plot rect. */ + const drawSubsets = (ctx: CanvasRenderingContext2D, N: number, T: number) => { const rects = subsetRectangles(); const selected = selectedSubsetIdx(); - ctx.scale(dpr, dpr); + + // Reset text alignment — drawAxes leaves it right/middle, which would + // misplace the label inside its shaded background box. + ctx.textAlign = 'left'; + ctx.textBaseline = 'alphabetic'; for (const r of rects) { - const x = (r.tStart / T) * displayWidth; - const w = ((r.tEnd - r.tStart) / T) * displayWidth; - const y = (r.cellStart / N) * displayHeight; - const h = ((r.cellEnd - r.cellStart) / N) * displayHeight; + const x = plotX + (r.tStart / T) * plotW; + const w = ((r.tEnd - r.tStart) / T) * plotW; + const y = plotY + (r.cellStart / N) * plotH; + const h = ((r.cellEnd - r.cellStart) / N) * plotH; const colorIdx = r.idx % SUBSET_STROKE.length; const isSelected = r.idx === selected; - // Semi-transparent fill ctx.fillStyle = SUBSET_FILL[colorIdx]; ctx.fillRect(x, y, w, h); - // Dark shadow outline for contrast against any background ctx.shadowColor = 'rgba(0, 0, 0, 0.6)'; ctx.shadowBlur = 3; ctx.strokeStyle = SUBSET_STROKE[colorIdx]; @@ -175,7 +225,6 @@ export function RasterOverview(): JSX.Element { ctx.strokeRect(x, y, w, h); ctx.shadowBlur = 0; - // Label with dark background for readability const label = `K${r.idx + 1}`; ctx.font = 'bold 11px system-ui, sans-serif'; const textW = ctx.measureText(label).width; @@ -200,13 +249,13 @@ export function RasterOverview(): JSX.Element { const rects = subsetRectangles(); - // Check if click is inside any rectangle (reverse order for z-order) + // Check if click is inside any rectangle (reverse order for z-order). for (let i = rects.length - 1; i >= 0; i--) { const r = rects[i]; - const x = (r.tStart / T) * lastWidth; - const w = ((r.tEnd - r.tStart) / T) * lastWidth; - const y = (r.cellStart / N) * lastHeight; - const h = ((r.cellEnd - r.cellStart) / N) * lastHeight; + const x = plotX + (r.tStart / T) * plotW; + const w = ((r.tEnd - r.tStart) / T) * plotW; + const y = plotY + (r.cellStart / N) * plotH; + const h = ((r.cellEnd - r.cellStart) / N) * plotH; if (mx >= x && mx <= x + w && my >= y && my <= y + h) { setSelectedSubsetIdx(selectedSubsetIdx() === r.idx ? null : r.idx); @@ -214,7 +263,7 @@ export function RasterOverview(): JSX.Element { } } - // Click outside all rectangles: deselect + // Click outside all rectangles: deselect. setSelectedSubsetIdx(null); }; @@ -230,9 +279,12 @@ export function RasterOverview(): JSX.Element { resizeObserver?.disconnect(); }); - // Redraw when data, shape, subsets, or selection changes + // Redraw when data, shape, timebase, subsets, or selection changes. createEffect( - on([parsedData, effectiveShape, swapped, subsetRectangles, selectedSubsetIdx], drawRaster), + on( + [parsedData, effectiveShape, swapped, durationSeconds, subsetRectangles, selectedSubsetIdx], + drawRaster, + ), ); return ( diff --git a/packages/ui/src/__tests__/chart-math.test.ts b/packages/ui/src/__tests__/chart-math.test.ts new file mode 100644 index 00000000..56fd2683 --- /dev/null +++ b/packages/ui/src/__tests__/chart-math.test.ts @@ -0,0 +1,33 @@ +import { describe, it, expect } from 'vitest'; +import { niceTicks } from '../chart/chart-math.ts'; + +describe('niceTicks', () => { + it('produces round steps within the range', () => { + const ticks = niceTicks(0, 300, 6); + expect(ticks[0]).toBe(0); + expect(ticks).toEqual([0, 50, 100, 150, 200, 250, 300]); + }); + + it('stays within [min, max]', () => { + const ticks = niceTicks(0, 37, 5); + expect(ticks[0]).toBeGreaterThanOrEqual(0); + expect(ticks[ticks.length - 1]).toBeLessThanOrEqual(37); + // 1/2/5 x 10^k snapping → step of 10 for this range. + expect(ticks).toEqual([0, 10, 20, 30]); + }); + + it('handles fractional ranges and snaps fp residue to 0', () => { + const ticks = niceTicks(0, 1, 5); + expect(ticks).toContain(0); + // No tiny fp residue like 0.30000000000000004. + for (const t of ticks) { + expect(Number(t.toFixed(10))).toBe(t); + } + }); + + it('degenerates safely on invalid input', () => { + expect(niceTicks(5, 5)).toEqual([5]); + expect(niceTicks(10, 0)).toEqual([10]); + expect(niceTicks(NaN, 1)).toEqual([NaN]); + }); +}); diff --git a/packages/ui/src/chart/chart-math.ts b/packages/ui/src/chart/chart-math.ts new file mode 100644 index 00000000..dbf8d462 --- /dev/null +++ b/packages/ui/src/chart/chart-math.ts @@ -0,0 +1,35 @@ +// Small chart math helpers shared across apps. + +/** + * Generate "nice" round tick values covering [min, max] with roughly + * `targetCount` ticks, snapped to 1/2/5 × 10^k steps. Returns ascending values + * within the range (inclusive of nice endpoints that fall inside it). + */ +export function niceTicks(min: number, max: number, targetCount = 5): number[] { + if (!isFinite(min) || !isFinite(max) || max <= min || targetCount < 1) { + return [min]; + } + const rawStep = (max - min) / targetCount; + const mag = Math.pow(10, Math.floor(Math.log10(rawStep))); + const norm = rawStep / mag; + let step: number; + if (norm < 1.5) step = 1; + else if (norm < 3) step = 2; + else if (norm < 7) step = 5; + else step = 10; + step *= mag; + + const start = Math.ceil(min / step) * step; + // Round each tick to the step's decimal precision so labels read cleanly + // (avoids fp residue like 0.30000000000000004). Steps are 1/2/5 × 10^k. + const decimals = Math.max(0, -Math.floor(Math.log10(step))); + const pow = Math.pow(10, decimals); + const round = (x: number) => Math.round(x * pow) / pow; + + const count = Math.floor((max - start) / step + 1e-6); + const ticks: number[] = []; + for (let i = 0; i <= count && i < 1000; i++) { + ticks.push(round(start + i * step)); + } + return ticks; +} diff --git a/packages/ui/src/chart/colormap.ts b/packages/ui/src/chart/colormap.ts new file mode 100644 index 00000000..51dc0db9 --- /dev/null +++ b/packages/ui/src/chart/colormap.ts @@ -0,0 +1,52 @@ +// Shared perceptual colormaps for heatmaps (raster overviews, spatial maps, ...). +// Kept in @calab/ui so every app renders intensity data with the same LUT. + +/** + * Build a 256-entry RGB lookup table by linearly interpolating a set of + * anchor stops. Returns a Uint8Array of length 256*3 (r,g,b per entry). + */ +function buildLUT(stops: readonly (readonly [number, number, number])[]): Uint8Array { + const lut = new Uint8Array(256 * 3); + for (let i = 0; i < 256; i++) { + const t = (i / 255) * (stops.length - 1); + const idx = Math.floor(t); + const frac = t - idx; + const a = stops[Math.min(idx, stops.length - 1)]; + const b = stops[Math.min(idx + 1, stops.length - 1)]; + lut[i * 3] = Math.round(a[0] + (b[0] - a[0]) * frac); + lut[i * 3 + 1] = Math.round(a[1] + (b[1] - a[1]) * frac); + lut[i * 3 + 2] = Math.round(a[2] + (b[2] - a[2]) * frac); + } + return lut; +} + +// Viridis anchor stops (dark purple → teal → yellow): perceptually uniform and +// colorblind-friendly, the standard scientific sequential colormap. +const VIRIDIS_STOPS = [ + [68, 1, 84], + [72, 35, 116], + [64, 67, 135], + [52, 94, 141], + [41, 120, 142], + [32, 144, 140], + [34, 167, 132], + [68, 190, 112], + [121, 209, 81], + [189, 222, 38], + [253, 231, 37], +] as const; + +/** 256-entry viridis RGB lookup table (length 256*3). */ +export const VIRIDIS_LUT = buildLUT(VIRIDIS_STOPS); + +/** Map a normalized value in [0,1] to a viridis [r,g,b] triple (clamped). */ +export function viridisRGB(t: number): [number, number, number] { + const i = Math.max(0, Math.min(255, Math.round(t * 255))); + return [VIRIDIS_LUT[i * 3], VIRIDIS_LUT[i * 3 + 1], VIRIDIS_LUT[i * 3 + 2]]; +} + +/** Map a normalized value in [0,1] to a viridis CSS `rgb(...)` string. */ +export function viridisCss(t: number): string { + const [r, g, b] = viridisRGB(t); + return `rgb(${r}, ${g}, ${b})`; +} diff --git a/packages/ui/src/chart/index.ts b/packages/ui/src/chart/index.ts index 3b769458..3f62e797 100644 --- a/packages/ui/src/chart/index.ts +++ b/packages/ui/src/chart/index.ts @@ -3,6 +3,8 @@ export type { KernelAnnotations } from './kernel-annotations-plugin.ts'; export { wheelZoomPlugin } from './wheel-zoom-plugin.ts'; export { transientZonePlugin } from './transient-zone-plugin.ts'; export { AXIS_TEXT, AXIS_GRID, AXIS_TICK, getThemeColors } from './theme-colors.ts'; +export { VIRIDIS_LUT, viridisRGB, viridisCss } from './colormap.ts'; +export { niceTicks } from './chart-math.ts'; export { OKABE_ITO, OKABE_ITO_CYCLE, diff --git a/packages/ui/src/index.ts b/packages/ui/src/index.ts index fc964ca9..8e73e57c 100644 --- a/packages/ui/src/index.ts +++ b/packages/ui/src/index.ts @@ -52,6 +52,10 @@ export { AXIS_GRID, AXIS_TICK, getThemeColors, + VIRIDIS_LUT, + viridisRGB, + viridisCss, + niceTicks, OKABE_ITO, OKABE_ITO_CYCLE, NEUTRAL,