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
180 changes: 116 additions & 64 deletions apps/cadecon/src/components/raster/RasterOverview.tsx
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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(() => {
Expand Down Expand Up @@ -112,29 +99,37 @@ 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;
canvas.height = physH;
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)))
Expand All @@ -148,34 +143,88 @@ 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];
ctx.lineWidth = isSelected ? 3 : 2;
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;
Expand All @@ -200,21 +249,21 @@ 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);
return;
}
}

// Click outside all rectangles: deselect
// Click outside all rectangles: deselect.
setSelectedSubsetIdx(null);
};

Expand All @@ -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 (
Expand Down
33 changes: 33 additions & 0 deletions packages/ui/src/__tests__/chart-math.test.ts
Original file line number Diff line number Diff line change
@@ -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]);
});
});
35 changes: 35 additions & 0 deletions packages/ui/src/chart/chart-math.ts
Original file line number Diff line number Diff line change
@@ -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;
}
52 changes: 52 additions & 0 deletions packages/ui/src/chart/colormap.ts
Original file line number Diff line number Diff line change
@@ -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})`;
}
Loading
Loading