From 42565e1f32249204a7521c15bce5cba9481350fe Mon Sep 17 00:00:00 2001 From: dsk-dev-ai Date: Thu, 13 Aug 2026 10:43:56 +0530 Subject: [PATCH 1/2] feat(visualization): add scientific charts --- .../src/app/visualization/ScientificDemo.tsx | 24 ++ apps/web/src/app/visualization/page.tsx | 13 +- .../src/components/scientific/ChartAxes.tsx | 138 +++++++ .../src/components/scientific/ChartLegend.tsx | 37 ++ .../components/scientific/ChartTooltip.tsx | 41 ++ .../scientific/ExpressionChart.test.tsx | 211 +++++++++++ .../components/scientific/ExpressionChart.tsx | 358 ++++++++++++++++++ apps/web/src/lib/scientific/api.test.ts | 159 ++++++++ apps/web/src/lib/scientific/api.ts | 155 ++++++++ .../src/lib/scientific/expression.fixtures.ts | 112 ++++++ .../web/src/lib/scientific/expression.test.ts | 249 ++++++++++++ apps/web/src/lib/scientific/expression.ts | 273 +++++++++++++ apps/web/src/lib/scientific/geometry.test.ts | 35 ++ apps/web/src/lib/scientific/geometry.ts | 73 ++++ apps/web/src/lib/scientific/scale.test.ts | 124 ++++++ apps/web/src/lib/scientific/scale.ts | 200 ++++++++++ apps/web/src/lib/scientific/tooltip.test.ts | 86 +++++ apps/web/src/lib/scientific/tooltip.ts | 64 ++++ apps/web/src/lib/scientific/types.ts | 92 +++++ apps/web/src/lib/scientific/useChartSize.ts | 52 +++ .../scientific/useExpressionChart.test.tsx | 138 +++++++ .../src/lib/scientific/useExpressionChart.ts | 130 +++++++ .../lib/visualization/visualizationModules.ts | 3 +- docs/visualization/README.md | 33 +- docs/visualization/roadmap.md | 50 ++- docs/visualization/scientific-charts.md | 243 ++++++++++++ 26 files changed, 3077 insertions(+), 16 deletions(-) create mode 100644 apps/web/src/app/visualization/ScientificDemo.tsx create mode 100644 apps/web/src/components/scientific/ChartAxes.tsx create mode 100644 apps/web/src/components/scientific/ChartLegend.tsx create mode 100644 apps/web/src/components/scientific/ChartTooltip.tsx create mode 100644 apps/web/src/components/scientific/ExpressionChart.test.tsx create mode 100644 apps/web/src/components/scientific/ExpressionChart.tsx create mode 100644 apps/web/src/lib/scientific/api.test.ts create mode 100644 apps/web/src/lib/scientific/api.ts create mode 100644 apps/web/src/lib/scientific/expression.fixtures.ts create mode 100644 apps/web/src/lib/scientific/expression.test.ts create mode 100644 apps/web/src/lib/scientific/expression.ts create mode 100644 apps/web/src/lib/scientific/geometry.test.ts create mode 100644 apps/web/src/lib/scientific/geometry.ts create mode 100644 apps/web/src/lib/scientific/scale.test.ts create mode 100644 apps/web/src/lib/scientific/scale.ts create mode 100644 apps/web/src/lib/scientific/tooltip.test.ts create mode 100644 apps/web/src/lib/scientific/tooltip.ts create mode 100644 apps/web/src/lib/scientific/types.ts create mode 100644 apps/web/src/lib/scientific/useChartSize.ts create mode 100644 apps/web/src/lib/scientific/useExpressionChart.test.tsx create mode 100644 apps/web/src/lib/scientific/useExpressionChart.ts create mode 100644 docs/visualization/scientific-charts.md diff --git a/apps/web/src/app/visualization/ScientificDemo.tsx b/apps/web/src/app/visualization/ScientificDemo.tsx new file mode 100644 index 0000000..c2edc1c --- /dev/null +++ b/apps/web/src/app/visualization/ScientificDemo.tsx @@ -0,0 +1,24 @@ +'use client' + +import { ExpressionChart } from '@/components/scientific/ExpressionChart' +import { TP53_PATHWAY_EXPRESSION_FIXTURE } from '@/lib/scientific/expression.fixtures' +import { useExpressionChart } from '@/lib/scientific/useExpressionChart' + +/** + * Client-side demonstration of the Phase 6.7 Scientific Charts. + * + * Loads a deterministic development fixture (see + * `lib/scientific/expression.fixtures.ts`) through the same data lifecycle as + * a production dataset. The loader flips to `fetchExpressionDataset` once the + * backend exposes an expression endpoint. + */ +export function ScientificDemo() { + const result = useExpressionChart({ + loader: (signal) => { + if (signal.aborted) return Promise.reject(new DOMException('Aborted', 'AbortError')) + return Promise.resolve(TP53_PATHWAY_EXPRESSION_FIXTURE) + }, + }) + + return +} diff --git a/apps/web/src/app/visualization/page.tsx b/apps/web/src/app/visualization/page.tsx index 7e8e445..59d64a6 100644 --- a/apps/web/src/app/visualization/page.tsx +++ b/apps/web/src/app/visualization/page.tsx @@ -4,12 +4,13 @@ import { GeneTranscriptDemo } from './GeneTranscriptDemo' import { GenomeBrowserDemo } from './GenomeBrowserDemo' import { NetworkDemo } from './NetworkDemo' import { ProteinDemo } from './ProteinDemo' +import { ScientificDemo } from './ScientificDemo' import { VisualizationDemo } from './VisualizationDemo' export const metadata: Metadata = { title: 'Visualization — GenomeAI', description: - 'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, Protein Viewer, and Biological Network Viewer (Phase 6.1–6.6) for GenomeAI.', + 'Visualization foundation, Genome Browser, Gene / Transcript viewer, Variant track, Protein Viewer, Biological Network Viewer, and Scientific Charts (Phase 6.1–6.7) for GenomeAI.', } export default function VisualizationPage() { @@ -19,16 +20,18 @@ export default function VisualizationPage() {

Visualization

Phase 6.1 foundation, the Phase 6.2 Genome Browser, the Phase 6.3 Gene / Transcript - viewer, the Phase 6.4 Variant track, the Phase 6.5 Protein Viewer, and the Phase 6.6 - Biological Network Viewer — region parsing, viewport navigation, track rendering, - gene/transcript structure, point variants, protein sequence + annotation windows, and - deterministic relationship networks over the GenomeAI API and development fixtures. + viewer, the Phase 6.4 Variant track, the Phase 6.5 Protein Viewer, the Phase 6.6 + Biological Network Viewer, and the Phase 6.7 Scientific Charts — region parsing, viewport + navigation, track rendering, gene/transcript structure, point variants, protein sequence + + annotation windows, deterministic relationship networks, and expression charts over the + GenomeAI API and development fixtures.

+ ) diff --git a/apps/web/src/components/scientific/ChartAxes.tsx b/apps/web/src/components/scientific/ChartAxes.tsx new file mode 100644 index 0000000..0360e5a --- /dev/null +++ b/apps/web/src/components/scientific/ChartAxes.tsx @@ -0,0 +1,138 @@ +'use client' + +import type { PlotArea } from '@/lib/scientific/geometry' +import { categoryLabelTicks } from '@/lib/scientific/scale' +import type { CategoryScale, ContinuousScale } from '@/lib/scientific/scale' + +export interface ChartAxesProps { + /** Data plot area (see `lib/scientific/geometry.ts`). */ + plot: PlotArea + /** Sample categories on the x-axis. */ + xScale: CategoryScale + /** Value scale on the y-axis. */ + yScale: ContinuousScale + /** Ascending tick values to render gridlines + labels for. */ + yTicks: number[] + /** Optional x-axis caption (e.g. "Sample"). */ + xLabel?: string + /** Optional y-axis caption (e.g. "Expression value"). */ + yLabel?: string + /** Renders a tick value as a label (defaults to scientific formatting). */ + formatValue?: (value: number) => string +} + +const AXIS_COLOR = '#cbd5e1' +const GRID_COLOR = '#e2e8f0' +const TICK_COLOR = '#475569' +const CAPTION_COLOR = '#94a3b8' + +/** + * Reusable SVG axes for scientific charts: gridlines, y tick labels, the + * sample labels along the x-axis, and optional axis captions. Pure layout is + * computed from the supplied scales so the component stays presentation-only. + */ +export function ChartAxes({ + plot, + xScale, + yScale, + yTicks, + xLabel, + yLabel, + formatValue = (value) => String(value), +}: ChartAxesProps) { + const baselineY = plot.y0 + plot.height + const sampleTicks = categoryLabelTicks(xScale, plot.width) + + return ( + + + {yTicks.map((tick) => { + const y = yScale.toPixel(tick) + return ( + + ) + })} + + + {yTicks.map((tick) => ( + + {formatValue(tick)} + + ))} + + {yLabel !== undefined ? ( + + {yLabel} + + ) : null} + + {sampleTicks.map((tick) => + tick.visible ? ( + + {tick.sample} + + ) : null, + )} + + {xLabel !== undefined ? ( + + {xLabel} + + ) : null} + + + + ) +} diff --git a/apps/web/src/components/scientific/ChartLegend.tsx b/apps/web/src/components/scientific/ChartLegend.tsx new file mode 100644 index 0000000..082420d --- /dev/null +++ b/apps/web/src/components/scientific/ChartLegend.tsx @@ -0,0 +1,37 @@ +'use client' + +export interface ChartLegendItem { + id: string + label: string + color: string +} + +export interface ChartLegendProps { + items: ChartLegendItem[] +} + +/** + * Reusable series legend: a labelled list of color swatches. Rendered as a + * semantic list so screen readers announce each series. + */ +export function ChartLegend({ items }: ChartLegendProps) { + if (items.length === 0) return null + return ( +
    + {items.map((item) => ( +
  • +
  • + ))} +
+ ) +} diff --git a/apps/web/src/components/scientific/ChartTooltip.tsx b/apps/web/src/components/scientific/ChartTooltip.tsx new file mode 100644 index 0000000..8972ebb --- /dev/null +++ b/apps/web/src/components/scientific/ChartTooltip.tsx @@ -0,0 +1,41 @@ +'use client' + +import type { PointTooltip } from '@/lib/scientific/tooltip' + +export interface ChartTooltipProps { + tooltip: PointTooltip + /** Pixel position of the hovered point (SVG coordinates). */ + x: number + y: number + /** Chart canvas width, used to keep the tooltip on-screen. */ + width: number +} + +/** + * Reusable hover tooltip for scientific charts. Rendered as an absolutely + * positioned HTML element over the SVG; the same `PointTooltip` rows feed the + * accessible detail panel, so both surfaces stay in sync. + */ +export function ChartTooltip({ tooltip, x, y, width }: ChartTooltipProps) { + const left = Math.min(Math.max(x + 16, 4), Math.max(width - 240, 4)) + const top = Math.max(y - 8, 4) + return ( +
+

{tooltip.title}

+

{tooltip.subtitle}

+
+ {tooltip.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+
+ ) +} diff --git a/apps/web/src/components/scientific/ExpressionChart.test.tsx b/apps/web/src/components/scientific/ExpressionChart.test.tsx new file mode 100644 index 0000000..2f18f11 --- /dev/null +++ b/apps/web/src/components/scientific/ExpressionChart.test.tsx @@ -0,0 +1,211 @@ +import { cleanup, fireEvent, render, screen } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { + availableSamples, + expressionValueDomain, + hasNormalizedValues, +} from '@/lib/scientific/expression' +import { + TP53_PATHWAY_EXPRESSION_FIXTURE, + buildExpressionDataset, +} from '@/lib/scientific/expression.fixtures' +import { SERIES_COLORS } from '@/lib/scientific/geometry' +import type { ExpressionChartResult } from '@/lib/scientific/useExpressionChart' + +import { ExpressionChart } from './ExpressionChart' + +function result(overrides: Partial = {}): ExpressionChartResult { + const data = TP53_PATHWAY_EXPRESSION_FIXTURE + const base: ExpressionChartResult = { + status: 'success', + error: undefined, + refetch: vi.fn(), + dataset: data, + samples: availableSamples(data), + valueField: 'value', + setValueField: vi.fn(), + hasNormalizedValues: hasNormalizedValues(data), + valueDomain: expressionValueDomain(data, 'value'), + selectedKey: null, + selectPoint: vi.fn(), + clearSelection: vi.fn(), + } + return { ...base, ...overrides } +} + +afterEach(() => { + cleanup() +}) + +describe('ExpressionChart', () => { + it('renders the chart heading and summary', () => { + render() + expect(screen.getByText('Expression Chart')).toBeInTheDocument() + expect(screen.getByText(/6 samples/)).toBeInTheDocument() + expect(screen.getByText(/3 series/)).toBeInTheDocument() + }) + + it('renders the loading state with an accessible label', () => { + render( + , + ) + expect(screen.getByText('Loading expression data...')).toBeInTheDocument() + }) + + it('renders the empty state message', () => { + render( + , + ) + expect(screen.getByText('No expression data to show.')).toBeInTheDocument() + }) + + it('renders the error state and retries', () => { + const refetch = vi.fn() + render( + , + ) + expect(screen.getByText('Failed to load expression data')).toBeInTheDocument() + fireEvent.click(screen.getByRole('button', { name: /retry/i })) + expect(refetch).toHaveBeenCalledTimes(1) + }) + + it('renders x-axis sample labels', () => { + render() + for (const sample of ['Tumor-1', 'Tumor-3', 'Normal-3']) { + expect(screen.getByText(sample)).toBeInTheDocument() + } + }) + + it('renders every point as a keyboard-accessible selection control', () => { + render() + const totalPoints = TP53_PATHWAY_EXPRESSION_FIXTURE.series.reduce( + (sum, series) => sum + series.points.length, + 0, + ) + expect(screen.getAllByRole('button', { name: /^Select / })).toHaveLength(totalPoints) + }) + + it('selects a point via click', () => { + const selectPoint = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ })) + expect(selectPoint).toHaveBeenCalledWith('tp53:TP53@Tumor-1') + }) + + it('supports keyboard selection via Enter and Space', () => { + const selectPoint = vi.fn() + render() + const control = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) + fireEvent.keyDown(control, { key: 'Enter' }) + expect(selectPoint).toHaveBeenCalledWith('tp53:TP53@Tumor-1') + + cleanup() + const selectPoint2 = vi.fn() + render( + , + ) + const control2 = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) + fireEvent.keyDown(control2, { key: ' ' }) + expect(selectPoint2).toHaveBeenCalledWith(null) + }) + + it('shows a hover tooltip with the mapped rows', () => { + render() + fireEvent.mouseEnter( + screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }), + ) + const tooltip = screen.getByTestId('chart-tooltip') + expect(tooltip).toBeInTheDocument() + expect(tooltip).toHaveTextContent('TP53') + expect(tooltip).toHaveTextContent('Tumor-1') + expect(tooltip).toHaveTextContent('128.4') + expect(tooltip).toHaveTextContent('1.92') + }) + + it('renders a detail panel for a controlled selection', () => { + render() + expect(screen.getByTestId('chart-selection-detail')).toBeInTheDocument() + expect(screen.getByText('TP53', { selector: 'h3' })).toBeInTheDocument() + expect(screen.getByText('Value', { selector: 'dt' })).toBeInTheDocument() + }) + + it('clears the selection from the detail panel', () => { + const clearSelection = vi.fn() + render( + , + ) + fireEvent.click(screen.getByRole('button', { name: /clear selection/i })) + expect(clearSelection).toHaveBeenCalledTimes(1) + }) + + it('renders the series legend', () => { + render() + const legend = screen.getByTestId('chart-legend') + expect(legend).toHaveTextContent('TP53') + expect(legend).toHaveTextContent('MDM2') + expect(legend).toHaveTextContent('BRCA1') + }) + + it('assigns series colors deterministically from the palette', () => { + render() + // Fixture series are normalized to sorted order: brca1, mdm2, tp53. + const first = screen.getByTestId('series-line-brca1') + expect(first.getAttribute('stroke')).toBe(SERIES_COLORS[0]) + }) + + it('toggles the value field when normalized values are available', () => { + const setValueField = vi.fn() + render() + fireEvent.click(screen.getByRole('button', { name: 'Normalized' })) + expect(setValueField).toHaveBeenCalledWith('normalizedValue') + }) + + it('hides the value-field toggle when no normalized values exist', () => { + const single = buildExpressionDataset({ + series: [{ id: 's1', label: 'S1', points: [['Tumor-1', 1]] }], + }) + render( + , + ) + expect(screen.queryByRole('button', { name: 'Normalized' })).not.toBeInTheDocument() + }) + + it('renders a single-point dataset without crashing', () => { + const single = buildExpressionDataset({ + series: [{ id: 's1', label: 'S1', points: [['Tumor-1', 1]] }], + }) + render( + , + ) + expect(screen.getByRole('button', { name: /Select S1/ })).toBeInTheDocument() + }) + + it('respects an explicit pixel width for responsive layouts', () => { + render() + expect(screen.getByTestId('expression-chart-svg').getAttribute('width')).toBe('800') + }) +}) diff --git a/apps/web/src/components/scientific/ExpressionChart.tsx b/apps/web/src/components/scientific/ExpressionChart.tsx new file mode 100644 index 0000000..84d4dd6 --- /dev/null +++ b/apps/web/src/components/scientific/ExpressionChart.tsx @@ -0,0 +1,358 @@ +'use client' + +import { useId, useMemo, useState } from 'react' + +import { VisualizationContainer } from '@/components/visualization/VisualizationContainer' +import { + DEFAULT_CHART_HEIGHT, + DEFAULT_CHART_MARGINS, + GRIDLINE_TARGET, + plotArea, + seriesColor, +} from '@/lib/scientific/geometry' +import { createCategoryScale, createContinuousScale, formatTickValue } from '@/lib/scientific/scale' +import { formatTooltipValue, lookupPoint, pointTooltip } from '@/lib/scientific/tooltip' +import { parsePointKey, pointKeyToString } from '@/lib/scientific/types' +import type { + ExpressionDataset, + ExpressionPoint, + ExpressionSeries, + PointKey, +} from '@/lib/scientific/types' +import { useChartSize } from '@/lib/scientific/useChartSize' +import type { ExpressionChartResult } from '@/lib/scientific/useExpressionChart' + +import { ChartAxes } from './ChartAxes' +import { ChartLegend } from './ChartLegend' +import { ChartTooltip } from './ChartTooltip' + +const POINT_RADIUS = 4 +const HIT_RADIUS = 11 + +function SeriesPoint({ + x, + y, + color, + label, + selected, + pressed, + testId, + onMouseEnter, + onMouseLeave, + onSelect, +}: { + x: number + y: number + color: string + label: string + selected: boolean + pressed: boolean + testId: string + onMouseEnter: () => void + onMouseLeave: () => void + onSelect: () => void +}) { + return ( + + {label} + + { + if (event.key === 'Enter' || event.key === ' ') { + event.preventDefault() + onSelect() + } + }} + onClick={(event) => { + event.stopPropagation() + onSelect() + }} + /> + + ) +} + +function SeriesLines({ + dataset, + xScale, + yScale, + valueField, +}: { + dataset: ExpressionDataset + xScale: ReturnType + yScale: ReturnType + valueField: 'value' | 'normalizedValue' +}) { + return ( + + {dataset.series.map((series, seriesIndex) => { + const color = seriesColor(seriesIndex) + const positions = series.points + .map((point) => { + const value = point[valueField] + if (value === undefined || !Number.isFinite(value)) return null + return { x: xScale.toPixel(point.sample), y: yScale.toPixel(value) } + }) + .filter((position): position is { x: number; y: number } => position !== null) + if (positions.length < 2) return null + const pointsAttribute = positions.map((position) => `${position.x},${position.y}`).join(' ') + return ( + + ) + })} + + ) +} + +function ChartControls({ result }: { result: ExpressionChartResult }) { + if (!result.hasNormalizedValues) return null + return ( +
+ Y-axis value field + {(['value', 'normalizedValue'] as const).map((field) => ( + + ))} +
+ ) +} + +function ChartDetail({ result }: { result: ExpressionChartResult }) { + const dataset = result.dataset + const selectedKey = result.selectedKey + const headingId = useId() + if (dataset === undefined || selectedKey === null) return null + + const key = parsePointKey(selectedKey) + if (key === undefined) return null + const lookup = lookupPoint(dataset, key) + if (lookup === undefined) return null + + const tooltip = pointTooltip(lookup.series, lookup.point) + return ( +
+

+ {tooltip.title} +

+

{tooltip.subtitle}

+
+ {tooltip.rows.map((row) => ( +
+
{row.label}
+
{row.value}
+
+ ))} +
+ +
+ ) +} + +export interface ExpressionChartProps { + /** View model produced by `useExpressionChart`. */ + result: ExpressionChartResult + /** Container heading. */ + title?: string + /** Optional fixed pixel width (defaults to measured container width). */ + width?: number + /** Optional fixed pixel height (defaults to `DEFAULT_CHART_HEIGHT`). */ + height?: number +} + +function keyFor(series: ExpressionSeries, point: ExpressionPoint): string { + return pointKeyToString({ seriesId: series.id, pointId: point.identifier, sample: point.sample }) +} + +/** + * Expression Chart (Phase 6.7). + * + * Renders an `ExpressionDataset` as an interactive SVG scatter/line chart: + * samples on the x-axis, the active value field on the y-axis, one series + * (gene) per color, gridlines, axes, a legend, hover tooltips, and + * keyboard-accessible point selection with a readable detail panel. Consumes + * an `ExpressionChartResult` from `useExpressionChart`; all data + * transformation stays in `lib/scientific`. + */ +export function ExpressionChart({ + result, + title = 'Expression Chart', + width, + height = DEFAULT_CHART_HEIGHT, +}: ExpressionChartProps) { + const size = useChartSize(height) + const chartWidth = width ?? size.width + const margins = DEFAULT_CHART_MARGINS + const plot = useMemo(() => plotArea(chartWidth, height, margins), [chartWidth, height, margins]) + const [hoveredKey, setHoveredKey] = useState(null) + + const dataset = result.dataset + const field = result.valueField + + const xScale = useMemo( + () => createCategoryScale(result.samples, [plot.x0, plot.x0 + plot.width]), + [result.samples, plot], + ) + + const yScale = useMemo( + () => + createContinuousScale( + [result.valueDomain.min, result.valueDomain.max], + [plot.y0 + plot.height, plot.y0], + ), + [result.valueDomain, plot], + ) + + const yTicks = useMemo(() => yScale.ticks(GRIDLINE_TARGET), [yScale]) + + const hovered = useMemo(() => { + if (dataset === undefined || hoveredKey === null) return null + const lookup = lookupPoint(dataset, hoveredKey) + if (lookup === undefined) return null + const value = lookup.point[field] + if (value === undefined || !Number.isFinite(value)) return null + return { + tooltip: pointTooltip(lookup.series, lookup.point), + x: xScale.toPixel(lookup.point.sample), + y: yScale.toPixel(value), + } + }, [dataset, hoveredKey, field, xScale, yScale]) + + const description = dataset + ? dataset.metadata?.description !== undefined + ? String(dataset.metadata.description) + : `${result.samples.length} samples · ${dataset.series.length} series` + : undefined + + return ( + + {result.status === 'success' && dataset ? ( +
+
+ + {result.samples.length} samples · {dataset.series.length} series + + +
+ ({ + id: series.id, + label: series.label, + color: seriesColor(index), + }))} + /> +
setHoveredKey(null)}> + + + + + {dataset.series.map((series, seriesIndex) => { + const color = seriesColor(seriesIndex) + return series.points.map((point) => { + const value = point[field] + if (value === undefined || !Number.isFinite(value)) return null + const x = xScale.toPixel(point.sample) + const y = yScale.toPixel(value) + const pointKey: PointKey = { + seriesId: series.id, + pointId: point.identifier, + sample: point.sample, + } + const key = keyFor(series, point) + const selected = result.selectedKey === key + const pressed = hoveredKey !== null && pointKeyToString(hoveredKey) === key + const label = `${series.label}: ${point.identifier} in ${point.sample} = ${formatTooltipValue(value)}` + return ( + setHoveredKey(pointKey)} + onMouseLeave={() => setHoveredKey(null)} + onSelect={() => result.selectPoint(selected ? null : key)} + /> + ) + }) + })} + + + {hovered ? ( + + ) : null} +
+ +
+ ) : null} +
+ ) +} diff --git a/apps/web/src/lib/scientific/api.test.ts b/apps/web/src/lib/scientific/api.test.ts new file mode 100644 index 0000000..29912fd --- /dev/null +++ b/apps/web/src/lib/scientific/api.test.ts @@ -0,0 +1,159 @@ +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { GenomeApiError } from '@/lib/genome/api' + +import { + expressionDatasetFromRecords, + fetchExpressionDataset, + toExpressionPoint, + toExpressionSeries, +} from './api' +import { validateExpressionDataset } from './expression' +import { availableSamples } from './expression' +import { TP53_PATHWAY_EXPRESSION_FIXTURE, buildExpressionDataset } from './expression.fixtures' + +const rawFetch = globalThis.fetch + +afterEach(() => { + globalThis.fetch = rawFetch + vi.restoreAllMocks() +}) + +function jsonResponse(payload: unknown) { + return { + ok: true, + status: 200, + json: () => Promise.resolve(payload), + } as Response +} + +describe('toExpressionPoint', () => { + it('normalizes a raw point record', () => { + const point = toExpressionPoint({ + identifier: 'TP53', + sample: 'Tumor-1', + value: 128.4, + normalized_value: 1.92, + metadata: { status: 'overexpressed' }, + }) + expect(point?.identifier).toBe('TP53') + expect(point?.sample).toBe('Tumor-1') + expect(point?.value).toBe(128.4) + expect(point?.normalizedValue).toBe(1.92) + expect(point?.metadata?.status).toBe('overexpressed') + }) + + it('accepts the camelCase normalized field', () => { + expect( + toExpressionPoint({ identifier: 'A', sample: 'S', value: 1, normalizedValue: 2 }) + ?.normalizedValue, + ).toBe(2) + }) + + it('drops records missing required fields or with non-finite values', () => { + expect(toExpressionPoint({ sample: 'S', value: 1 })).toBeUndefined() + expect(toExpressionPoint({ identifier: 'A', value: 1 })).toBeUndefined() + expect(toExpressionPoint({ identifier: 'A', sample: 'S', value: Number.NaN })).toBeUndefined() + expect(toExpressionPoint(null)).toBeUndefined() + }) +}) + +describe('toExpressionSeries', () => { + it('normalizes a raw series record', () => { + const series = toExpressionSeries({ + id: 'tp53', + label: 'TP53', + points: [{ identifier: 'TP53', sample: 'Tumor-1', value: 10 }], + }) + expect(series?.id).toBe('tp53') + expect(series?.label).toBe('TP53') + expect(series?.points).toHaveLength(1) + }) + + it('drops invalid points and series without id/label', () => { + const series = toExpressionSeries({ + id: 's1', + label: 'S1', + points: [ + { identifier: 'A', sample: 'Tumor-1', value: 1 }, + { sample: 'x', value: 1 }, + ], + }) + expect(series?.points).toHaveLength(1) + expect(toExpressionSeries({ label: 'S1', points: [] })).toBeUndefined() + expect(toExpressionSeries(null)).toBeUndefined() + }) +}) + +describe('expressionDatasetFromRecords', () => { + it('builds a normalized valid dataset', () => { + const data = expressionDatasetFromRecords({ + id: 'd1', + title: 'Title', + series: [ + { id: 's1', label: 'S1', points: [{ identifier: 'A', sample: 'Tumor-1', value: 1 }] }, + ], + }) + expect(data?.id).toBe('d1') + expect(data?.title).toBe('Title') + if (data !== undefined) expect(validateExpressionDataset(data).valid).toBe(true) + }) + + it('returns undefined for invalid records', () => { + expect(expressionDatasetFromRecords(null)).toBeUndefined() + expect(expressionDatasetFromRecords({ series: [] })).toBeUndefined() + }) +}) + +describe('fetchExpressionDataset', () => { + it('GETs the expression endpoint and normalizes the response', async () => { + const fetchMock = vi.fn().mockResolvedValue( + jsonResponse({ + id: 'expression-tp53-pathway', + series: [ + { + id: 'tp53', + label: 'TP53', + points: [{ identifier: 'TP53', sample: 'Tumor-1', value: 128.4 }], + }, + ], + }), + ) + globalThis.fetch = fetchMock as unknown as typeof fetch + + const { signal } = new AbortController() + const data = await fetchExpressionDataset('expression-tp53-pathway', signal) + + const [url, init] = fetchMock.mock.calls[0] as [string, RequestInit] + expect(url).toContain('/expression/datasets/expression-tp53-pathway') + expect(init.signal).toBe(signal) + expect(data.id).toBe('expression-tp53-pathway') + expect(data.series).toHaveLength(1) + }) + + it('throws a GenomeApiError on a non-2xx response', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ ok: false, status: 404 } as unknown as Response) + await expect(fetchExpressionDataset('d1')).rejects.toBeInstanceOf(GenomeApiError) + }) + + it('throws a GenomeApiError on a malformed payload', async () => { + globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(null)) + await expect(fetchExpressionDataset('d1')).rejects.toBeInstanceOf(GenomeApiError) + }) +}) + +describe('fixture integrity', () => { + it('fixture flows through the same normalizers', () => { + expect(TP53_PATHWAY_EXPRESSION_FIXTURE.id).toBe('expression-tp53-pathway') + expect(validateExpressionDataset(TP53_PATHWAY_EXPRESSION_FIXTURE).valid).toBe(true) + expect(TP53_PATHWAY_EXPRESSION_FIXTURE.series).toHaveLength(3) + }) + + it('buildExpressionDataset produces valid datasets for edge cases', () => { + const single = buildExpressionDataset({ + series: [{ id: 's1', label: 'S1', points: [['Tumor-1', 1]] }], + }) + expect(validateExpressionDataset(single).valid).toBe(true) + expect(availableSamples(single)).toEqual(['Tumor-1']) + }) +}) diff --git a/apps/web/src/lib/scientific/api.ts b/apps/web/src/lib/scientific/api.ts new file mode 100644 index 0000000..d8cd97a --- /dev/null +++ b/apps/web/src/lib/scientific/api.ts @@ -0,0 +1,155 @@ +/** + * Expression dataset data adapter (Phase 6.7). + * + * Defines the raw record shapes a future GenomeAI expression endpoint is + * expected to return and the normalization seam (`toExpressionPoint`, + * `toExpressionSeries`, `expressionDatasetFromRecords`) the chart uses. It + * reuses the shared `API_BASE_URL`, `GenomeApiError`, and guard helpers from + * `lib/genome/api.ts`, and the deterministic normalizer from + * `lib/scientific/expression.ts`. + * + * ## API limitation + * + * The GenomeAI backend does **not** yet expose an expression endpoint. This + * module documents the expected contract and `fetchExpressionDataset` attempts + * `GET /expression/datasets/{id}` (which will 404 today, surfacing the + * limitation as a typed error). The demo therefore uses the isolated + * deterministic fixtures in `lib/scientific/expression.fixtures.ts` and flips + * to the real adapter as soon as an expression endpoint exists. See + * `docs/visualization/scientific-charts.md`. + * + * The browser never talks to external biological databases (GEO, ArrayExpress, + * TCGA, ...); those feed GenomeAI through the later connector/ingestion + * architecture. + */ + +import { API_BASE_URL, GenomeApiError, asNumber, asString } from '@/lib/genome/api' + +import { normalizeExpressionDataset, sanitizeMetadata } from './expression' +import type { ExpressionDataset, ExpressionPoint, ExpressionSeries } from './types' + +/** Raw record shape a future expression endpoint is expected to return. */ +export interface RawExpressionDatasetRecord { + id?: unknown + title?: unknown + series?: unknown + metadata?: unknown + [key: string]: unknown +} + +/** Raw series record shape. */ +export interface RawExpressionSeriesRecord { + id?: unknown + label?: unknown + points?: unknown + [key: string]: unknown +} + +/** Raw point record shape. */ +export interface RawExpressionPointRecord { + identifier?: unknown + sample?: unknown + value?: unknown + normalized_value?: unknown + normalizedValue?: unknown + metadata?: unknown + [key: string]: unknown +} + +function recordIsObject(value: unknown): value is Record { + return typeof value === 'object' && value !== null && !Array.isArray(value) +} + +/** Normalizes a raw point record into an `ExpressionPoint`, or `undefined`. */ +export function toExpressionPoint(record: unknown): ExpressionPoint | undefined { + if (!recordIsObject(record)) return undefined + const identifier = asString(record.identifier) + const sample = asString(record.sample) + const value = asNumber(record.value) + if (identifier === undefined || identifier.length === 0) return undefined + if (sample === undefined || sample.length === 0) return undefined + if (value === undefined || !Number.isFinite(value)) return undefined + const normalizedValue = asNumber(record.normalized_value) ?? asNumber(record.normalizedValue) + return { + identifier, + sample, + value, + ...(normalizedValue !== undefined && Number.isFinite(normalizedValue) + ? { normalizedValue } + : {}), + ...(sanitizeMetadata(record.metadata) !== undefined + ? { metadata: sanitizeMetadata(record.metadata) } + : {}), + } +} + +/** Normalizes a raw series record into an `ExpressionSeries`, or `undefined`. */ +export function toExpressionSeries(record: unknown): ExpressionSeries | undefined { + if (!recordIsObject(record)) return undefined + const id = asString(record.id) + const label = asString(record.label) + if (id === undefined || id.length === 0) return undefined + if (label === undefined || label.length === 0) return undefined + const rawPoints = Array.isArray(record.points) ? record.points : [] + const points = rawPoints + .map(toExpressionPoint) + .filter((point): point is ExpressionPoint => point !== undefined) + return { id, label, points } +} + +/** + * Builds a normalized, deterministic `ExpressionDataset` from raw records. + * Invalid records are dropped via `toExpressionPoint`/`toExpressionSeries`; + * `normalizeExpressionDataset` then dedupes identifiers, drops invalid + * points/series, and orders canonically. + */ +export function expressionDatasetFromRecords(record: unknown): ExpressionDataset | undefined { + if (!recordIsObject(record)) return undefined + const id = asString(record.id) + if (id === undefined || id.length === 0) return undefined + const title = asString(record.title) ?? `Expression dataset ${id}` + const rawSeries = Array.isArray(record.series) ? record.series : [] + const series = rawSeries + .map(toExpressionSeries) + .filter((candidate): candidate is ExpressionSeries => candidate !== undefined) + return normalizeExpressionDataset({ + id, + title, + series, + ...(sanitizeMetadata(record.metadata) !== undefined + ? { metadata: sanitizeMetadata(record.metadata) } + : {}), + }) +} + +/** + * Fetches an expression dataset by id from the (not-yet-existing) expression + * endpoint. Throws `GenomeApiError` on failure today so the limitation is + * explicit and typed. Fixture-based demos bypass this loader. + */ +export async function fetchExpressionDataset( + datasetId: string, + signal?: AbortSignal, +): Promise { + const response = await fetch( + `${API_BASE_URL}/expression/datasets/${encodeURIComponent(datasetId)}`, + { + headers: { 'Content-Type': 'application/json' }, + signal, + }, + ) + if (!response.ok) { + throw new GenomeApiError( + `GenomeAI API returned ${response.status} for expression dataset ${datasetId}`, + response.status, + ) + } + const payload: unknown = await response.json() + const dataset = expressionDatasetFromRecords(payload) + if (dataset === undefined) { + throw new GenomeApiError( + `GenomeAI API returned an invalid payload for expression dataset ${datasetId}`, + ) + } + return dataset +} diff --git a/apps/web/src/lib/scientific/expression.fixtures.ts b/apps/web/src/lib/scientific/expression.fixtures.ts new file mode 100644 index 0000000..40323af --- /dev/null +++ b/apps/web/src/lib/scientific/expression.fixtures.ts @@ -0,0 +1,112 @@ +/** + * Development fixtures for the Scientific Charts (Phase 6.7). + * + * The GenomeAI backend does **not** yet expose an expression endpoint, so + * this module provides small, clearly isolated, typed fixtures that mimic what + * a future expression API would return. Records flow through the same + * normalizer (`expressionDatasetFromRecords`) the real adapter uses, so the + * seam is exercised exactly as production would. + * + * ## Boundary + * + * These are **development fixtures, not a real API** and not scientific fact. + * The expression values below illustrate the viewer's generic dataset model + * (samples on the x-axis, expression values on the y-axis, one series per + * gene) and must be replaced by real GenomeAI expression data. See + * `docs/visualization/scientific-charts.md`. + */ + +import { expressionDatasetFromRecords } from './api' +import type { ExpressionDataset } from './types' + +/** Raw records for a small TP53-pathway expression demo dataset. */ +const TP53_PATHWAY_EXPRESSION_RECORD = { + id: 'expression-tp53-pathway', + title: 'TP53 pathway expression (fixture)', + metadata: { + description: + 'Illustrative RNA expression across tumor and normal samples. Values are arbitrary units (fixture, not a real dataset).', + }, + series: [ + { + id: 'tp53', + label: 'TP53', + points: [ + { + identifier: 'TP53', + sample: 'Tumor-1', + value: 128.4, + normalized_value: 1.92, + metadata: { status: 'overexpressed' }, + }, + { identifier: 'TP53', sample: 'Tumor-2', value: 142.1, normalized_value: 2.05 }, + { identifier: 'TP53', sample: 'Tumor-3', value: 119.7, normalized_value: 1.61 }, + { identifier: 'TP53', sample: 'Normal-1', value: 44.2, normalized_value: -0.31 }, + { identifier: 'TP53', sample: 'Normal-2', value: 51.8, normalized_value: -0.02 }, + { identifier: 'TP53', sample: 'Normal-3', value: 39.5, normalized_value: -0.55 }, + ], + }, + { + id: 'mdm2', + label: 'MDM2', + points: [ + { identifier: 'MDM2', sample: 'Tumor-1', value: 88.9, normalized_value: 1.22 }, + { identifier: 'MDM2', sample: 'Tumor-2', value: 96.4, normalized_value: 1.4 }, + { identifier: 'MDM2', sample: 'Tumor-3', value: 79.1, normalized_value: 0.94 }, + { identifier: 'MDM2', sample: 'Normal-1', value: 63.7, normalized_value: 0.11 }, + { identifier: 'MDM2', sample: 'Normal-2', value: 59.2, normalized_value: -0.19 }, + { identifier: 'MDM2', sample: 'Normal-3', value: 66.5, normalized_value: 0.24 }, + ], + }, + { + id: 'brca1', + label: 'BRCA1', + points: [ + { identifier: 'BRCA1', sample: 'Tumor-1', value: 32.6, normalized_value: -0.72 }, + { identifier: 'BRCA1', sample: 'Tumor-2', value: 29.4, normalized_value: -0.96 }, + { identifier: 'BRCA1', sample: 'Tumor-3', value: 35.1, normalized_value: -0.55 }, + { identifier: 'BRCA1', sample: 'Normal-1', value: 71.2, normalized_value: 0.41 }, + { identifier: 'BRCA1', sample: 'Normal-2', value: 68.9, normalized_value: 0.29 }, + { identifier: 'BRCA1', sample: 'Normal-3', value: 74.6, normalized_value: 0.52 }, + ], + }, + ], +} + +/** TP53-pathway expression demo dataset used by demos and tests. */ +export const TP53_PATHWAY_EXPRESSION_FIXTURE: ExpressionDataset = expressionDatasetFromRecords( + TP53_PATHWAY_EXPRESSION_RECORD, +) ?? { + id: 'expression-tp53-pathway', + title: 'TP53 pathway expression (fixture)', + series: [], +} + +/** + * A single-series, single-point fixture covering the degenerate rendering + * path (one sample, one series). + */ +export function buildExpressionDataset( + overrides: { + id?: string + title?: string + series?: Array<{ id: string; label: string; points: Array<[string, number]> }> + } = {}, +): ExpressionDataset { + const series = (overrides.series ?? []).map((series) => ({ + id: series.id, + label: series.label, + points: series.points.map(([sample, value], index) => ({ + identifier: `${series.id}-${index + 1}`, + sample, + value, + })), + })) + return ( + expressionDatasetFromRecords({ + id: overrides.id ?? 'expression-test', + title: overrides.title ?? 'Test expression dataset', + series, + }) ?? { id: 'expression-test', title: 'Test expression dataset', series: [] } + ) +} diff --git a/apps/web/src/lib/scientific/expression.test.ts b/apps/web/src/lib/scientific/expression.test.ts new file mode 100644 index 0000000..f0a7eb6 --- /dev/null +++ b/apps/web/src/lib/scientific/expression.test.ts @@ -0,0 +1,249 @@ +import { describe, expect, it } from 'vitest' + +import { + availableSamples, + datasetDomain, + expressionValueDomain, + hasNormalizedValues, + hasRenderablePoints, + normalizeExpressionDataset, + sanitizeMetadata, + seriesDomain, + validateExpressionDataset, + validateExpressionPoint, +} from './expression' +import type { ExpressionDataset } from './types' + +function dataset(series: ExpressionDataset['series']): ExpressionDataset { + return { id: 'd', title: 'Dataset', series } +} + +function point(identifier: string, sample: string, value: number, normalizedValue?: number) { + return { + identifier, + sample, + value, + ...(normalizedValue !== undefined ? { normalizedValue } : {}), + } +} + +describe('validateExpressionPoint', () => { + it('accepts a valid point', () => { + expect(validateExpressionPoint(point('TP53', 'Tumor-1', 128.4), 0, 'tp53')).toEqual([]) + }) + + it('rejects missing identifier, sample, and non-finite values', () => { + expect(validateExpressionPoint(point('', 'Tumor-1', 1), 0, 's')).toHaveLength(1) + expect(validateExpressionPoint(point('TP53', '', 1), 0, 's')).toHaveLength(1) + expect(validateExpressionPoint(point('TP53', 'Tumor-1', Number.NaN), 0, 's')).toHaveLength(1) + expect( + validateExpressionPoint( + { identifier: 'TP53', sample: 'Tumor-1', value: 1, normalizedValue: Number.NaN }, + 0, + 's', + ), + ).toHaveLength(1) + }) + + it('accepts zero and negative values', () => { + expect(validateExpressionPoint(point('TP53', 'Tumor-1', 0), 0, 's')).toEqual([]) + expect(validateExpressionPoint(point('TP53', 'Tumor-1', -1.5), 0, 's')).toEqual([]) + }) +}) + +describe('validateExpressionDataset', () => { + it('reports every problem and passes on clean data', () => { + const bad = dataset([ + { + id: 's1', + label: 'S1', + points: [point('TP53', 'Tumor-1', 1), point('', 'Tumor-1', Number.NaN)], + }, + { id: 's1', label: '', points: [] }, + ]) + const validation = validateExpressionDataset(bad) + expect(validation.valid).toBe(false) + expect(validation.errors.length).toBeGreaterThanOrEqual(4) + + const clean = dataset([{ id: 's1', label: 'S1', points: [point('TP53', 'Tumor-1', 1)] }]) + expect(validateExpressionDataset(clean)).toEqual({ valid: true, errors: [] }) + }) + + it('flags duplicate series ids', () => { + const duplicate = dataset([ + { id: 's1', label: 'A', points: [] }, + { id: 's1', label: 'B', points: [] }, + ]) + const validation = validateExpressionDataset(duplicate) + expect(validation.valid).toBe(false) + expect(validation.errors.some((error) => error.includes('Duplicate series id'))).toBe(true) + }) +}) + +describe('normalizeExpressionDataset', () => { + it('orders series by id and points by sample then identifier', () => { + const input = dataset([ + { + id: 'b', + label: 'B', + points: [point('X', 'Tumor-2', 1), point('X', 'Tumor-1', 2), point('A', 'Tumor-1', 3)], + }, + { id: 'a', label: 'A', points: [point('X', 'Normal-1', 4)] }, + ]) + const normalized = normalizeExpressionDataset(input) + expect(normalized.series.map((series) => series.id)).toEqual(['a', 'b']) + expect(normalized.series[1].points.map((p) => [p.sample, p.identifier])).toEqual([ + ['Tumor-1', 'A'], + ['Tumor-1', 'X'], + ['Tumor-2', 'X'], + ]) + }) + + it('drops invalid points and dedupes duplicate identifiers (first wins)', () => { + const input = dataset([ + { + id: 's1', + label: 'S1', + points: [ + point('TP53', 'Tumor-1', 10), + point('TP53', 'Tumor-1', 99), + point('', 'Tumor-1', 5), + point('X', '', 5), + point('Y', 'Tumor-1', Number.NaN), + ], + }, + ]) + const normalized = normalizeExpressionDataset(input) + expect(normalized.series[0].points).toHaveLength(1) + expect(normalized.series[0].points[0].value).toBe(10) + }) + + it('drops series without an id or label', () => { + const input = dataset([ + { id: '', label: 'S1', points: [point('TP53', 'Tumor-1', 1)] }, + { id: 'keep', label: 'S2', points: [point('TP53', 'Tumor-1', 1)] }, + ]) + expect(normalizeExpressionDataset(input).series.map((s) => s.id)).toEqual(['keep']) + }) + + it('falls back to defaults for empty ids and titles', () => { + const input = dataset([]) + const empty = normalizeExpressionDataset({ ...input, id: '', title: '' }) + expect(empty.id).toBe('unnamed-dataset') + expect(empty.title).toBe('Unnamed dataset') + }) +}) + +describe('availableSamples', () => { + it('returns the sorted unique sample names', () => { + const input = dataset([ + { + id: 's1', + label: 'S1', + points: [point('A', 'Tumor-2', 1), point('A', 'Tumor-1', 2), point('B', 'Tumor-2', 3)], + }, + ]) + expect(availableSamples(input)).toEqual(['Tumor-1', 'Tumor-2']) + }) + + it('returns an empty array for empty data', () => { + expect(availableSamples(dataset([]))).toEqual([]) + }) +}) + +describe('seriesDomain and datasetDomain', () => { + it('computes min/max across points, skipping non-finite values', () => { + const series = { + id: 's1', + label: 'S1', + points: [ + point('A', 'Tumor-1', 2), + point('B', 'Tumor-1', 8), + point('C', 'Tumor-1', Number.NaN), + point('D', 'Tumor-1', 0), + ], + } + expect(seriesDomain(series, 'value')).toEqual({ min: 0, max: 8 }) + }) + + it('aggregates across series and uses the normalized field', () => { + const input = dataset([ + { id: 's1', label: 'S1', points: [point('A', 'Tumor-1', 5, 1.2)] }, + { id: 's2', label: 'S2', points: [point('A', 'Tumor-1', 5, -0.7)] }, + ]) + expect(datasetDomain(input, 'value')).toEqual({ min: 5, max: 5 }) + expect(datasetDomain(input, 'normalizedValue')).toEqual({ min: -0.7, max: 1.2 }) + }) + + it('returns undefined when no usable points exist', () => { + expect(datasetDomain(dataset([]), 'value')).toBeUndefined() + expect(datasetDomain(dataset([{ id: 's1', label: 'S1', points: [] }]), 'value')).toBeUndefined() + }) +}) + +describe('expressionValueDomain', () => { + it('starts all-non-negative domains at zero', () => { + const input = dataset([{ id: 's1', label: 'S1', points: [point('A', 'Tumor-1', 44.2)] }]) + expect(expressionValueDomain(input, 'value')).toEqual({ min: 0, max: 44.2 }) + }) + + it('ends all-negative domains at zero', () => { + const input = dataset([{ id: 's1', label: 'S1', points: [point('A', 'Tumor-1', -2.5)] }]) + expect(expressionValueDomain(input, 'value')).toEqual({ min: -2.5, max: 0 }) + }) + + it('uses the raw span for mixed-sign data', () => { + const input = dataset([ + { + id: 's1', + label: 'S1', + points: [point('A', 'Tumor-1', -3, -3), point('B', 'Tumor-1', 2, 2)], + }, + ]) + expect(expressionValueDomain(input, 'normalizedValue')).toEqual({ min: -3, max: 2 }) + }) + + it('pads degenerate single-value datasets', () => { + const input = dataset([{ id: 's1', label: 'S1', points: [point('A', 'Tumor-1', 10)] }]) + const domain = expressionValueDomain(input, 'value') + expect(domain.max).toBeGreaterThan(domain.min) + }) + + it('returns a safe default for empty data', () => { + expect(expressionValueDomain(dataset([]), 'value')).toEqual({ min: 0, max: 1 }) + }) +}) + +describe('hasRenderablePoints and hasNormalizedValues', () => { + it('detects renderable points and normalized values', () => { + const input = dataset([ + { + id: 's1', + label: 'S1', + points: [point('A', 'Tumor-1', 1, 2), point('B', 'Tumor-2', Number.NaN)], + }, + ]) + expect(hasRenderablePoints(input)).toBe(true) + expect(hasNormalizedValues(input)).toBe(true) + }) + + it('returns false for empty or invalid data', () => { + expect(hasRenderablePoints(dataset([]))).toBe(false) + expect(hasNormalizedValues(dataset([{ id: 's1', label: 'S1', points: [] }]))).toBe(false) + }) +}) + +describe('sanitizeMetadata', () => { + it('keeps scalar entries and drops non-scalar ones', () => { + expect(sanitizeMetadata({ status: 'over', count: 2, nested: { a: 1 }, list: [1] })).toEqual({ + status: 'over', + count: 2, + }) + }) + + it('returns undefined for invalid input', () => { + expect(sanitizeMetadata(null)).toBeUndefined() + expect(sanitizeMetadata([1])).toBeUndefined() + expect(sanitizeMetadata('x')).toBeUndefined() + }) +}) diff --git a/apps/web/src/lib/scientific/expression.ts b/apps/web/src/lib/scientific/expression.ts new file mode 100644 index 0000000..2e616ad --- /dev/null +++ b/apps/web/src/lib/scientific/expression.ts @@ -0,0 +1,273 @@ +/** + * Expression dataset validation and normalization (Phase 6.7). + * + * Pure functions over `ExpressionDataset`: + * + * - `validateExpressionDataset` reports invalid points/series so callers can + * explain bad data instead of silently dropping it. + * - `normalizeExpressionDataset` builds a deterministic, render-ready dataset + * (invalid points dropped, duplicate identifiers deduped, series and points + * ordered canonically). + * - `availableSamples`, `expressionDomain`, and `hasRenderablePoints` derive + * the x-axis categories, y-axis value domain, and emptiness used by the + * chart layout. + * + * Normalization is deliberately idempotent and locale-independent so chart + * output is deterministic across runs and environments. + */ + +import type { + ExpressionDataset, + ExpressionPoint, + ExpressionSeries, + ScientificMetadata, +} from './types' + +/** Value fields that can drive the y-axis. */ +export type ExpressionValueField = 'value' | 'normalizedValue' + +export interface ExpressionValidationResult { + valid: boolean + errors: string[] +} + +function isValidIdentifier(value: string): boolean { + return value.trim().length > 0 +} + +function isFiniteMeasurement(value: number): boolean { + return Number.isFinite(value) +} + +/** Reports problems with a single point. Returns an empty array when valid. */ +export function validateExpressionPoint( + point: ExpressionPoint, + index: number, + seriesId: string, +): string[] { + const errors: string[] = [] + if (!isValidIdentifier(point.identifier)) { + errors.push(`Series ${seriesId} point ${index}: identifier must be a non-empty string.`) + } + if (!isValidIdentifier(point.sample)) { + errors.push(`Series ${seriesId} point ${index}: sample must be a non-empty string.`) + } + if (!isFiniteMeasurement(point.value)) { + errors.push(`Series ${seriesId} point ${index}: value must be a finite number.`) + } + if (point.normalizedValue !== undefined && !isFiniteMeasurement(point.normalizedValue)) { + errors.push(`Series ${seriesId} point ${index}: normalizedValue must be a finite number.`) + } + return errors +} + +/** Reports problems with a series. Returns an empty array when valid. */ +export function validateExpressionSeries(series: ExpressionSeries, index: number): string[] { + const errors: string[] = [] + if (!isValidIdentifier(series.id)) { + errors.push(`Series ${index}: id must be a non-empty string.`) + } + if (!isValidIdentifier(series.label)) { + errors.push(`Series ${index}: label must be a non-empty string.`) + } + return errors +} + +/** + * Validates a complete dataset, returning every problem found. A dataset is + * valid when every series has an id and label and every point has an + * identifier, a sample, and a finite value. + */ +export function validateExpressionDataset(dataset: ExpressionDataset): ExpressionValidationResult { + const errors: string[] = [] + if (!isValidIdentifier(dataset.id)) { + errors.push('Dataset id must be a non-empty string.') + } + if (!isValidIdentifier(dataset.title)) { + errors.push('Dataset title must be a non-empty string.') + } + dataset.series.forEach((series, seriesIndex) => { + errors.push(...validateExpressionSeries(series, seriesIndex)) + series.points.forEach((point, pointIndex) => { + errors.push(...validateExpressionPoint(point, pointIndex, series.id)) + }) + }) + const seriesIds = dataset.series.map((series) => series.id) + const duplicateSeriesIds = seriesIds.filter((id, index) => seriesIds.indexOf(id) !== index) + for (const duplicateId of [...new Set(duplicateSeriesIds)]) { + errors.push(`Duplicate series id "${duplicateId}".`) + } + return { valid: errors.length === 0, errors } +} + +function asFiniteNumber(value: number): boolean { + return Number.isFinite(value) +} + +/** + * Dedupes points within a series, keeping the first occurrence of each + * (identifier, sample) pair — one measurement per entity per sample. Stable + * and deterministic. + */ +export function dedupePoints(points: ExpressionPoint[]): ExpressionPoint[] { + const seen = new Set() + const result: ExpressionPoint[] = [] + for (const point of points) { + if (point.identifier.trim().length === 0) continue + if (point.sample.trim().length === 0) continue + const key = `${point.identifier}\u0000${point.sample}` + if (seen.has(key)) continue + seen.add(key) + result.push(point) + } + return result +} + +/** + * Builds a deterministic, render-ready dataset from raw input. + * + * - Series and points are sorted canonically (series by id, points by sample + * then identifier) so charts render identically across runs. + * - Invalid points (empty identifier/sample or non-finite value) are dropped. + * - Duplicate point identifiers within a series are deduped (first wins). + * + * The returned dataset is always structurally valid (`validateExpressionDataset` + * passes) and never shares mutable state with the input. + */ +export function normalizeExpressionDataset(dataset: ExpressionDataset): ExpressionDataset { + const series = dataset.series + .filter((series) => isValidIdentifier(series.id) && isValidIdentifier(series.label)) + .map((series) => { + const points = dedupePoints(series.points) + .filter((point) => { + if (!isValidIdentifier(point.identifier)) return false + if (!isValidIdentifier(point.sample)) return false + if (!asFiniteNumber(point.value)) return false + if (point.normalizedValue !== undefined && !asFiniteNumber(point.normalizedValue)) { + return false + } + return true + }) + .sort( + (left, right) => + left.sample.localeCompare(right.sample) || + left.identifier.localeCompare(right.identifier), + ) + return { ...series, points } + }) + .sort((left, right) => left.id.localeCompare(right.id)) + + return { + id: dataset.id.trim().length > 0 ? dataset.id : 'unnamed-dataset', + title: dataset.title.trim().length > 0 ? dataset.title : 'Unnamed dataset', + series, + ...(dataset.metadata !== undefined ? { metadata: dataset.metadata } : {}), + } +} + +/** + * The sorted, unique set of sample names across every series. Drives the + * categorical x-axis; ordering is deterministic (locale compare). + */ +export function availableSamples(dataset: ExpressionDataset): string[] { + const samples = new Set() + for (const series of dataset.series) { + for (const point of series.points) { + if (point.sample.trim().length > 0) samples.add(point.sample) + } + } + return [...samples].sort((left, right) => left.localeCompare(right)) +} + +export interface ValueDomain { + min: number + max: number +} + +/** + * The min/max of the chosen value field across all points in a series. + * Returns `undefined` when the series has no usable points for that field. + */ +export function seriesDomain( + series: ExpressionSeries, + field: ExpressionValueField, +): ValueDomain | undefined { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + for (const point of series.points) { + const value = point[field] + if (value === undefined || !Number.isFinite(value)) continue + if (value < min) min = value + if (value > max) max = value + } + return Number.isFinite(min) ? { min, max } : undefined +} + +/** + * The min/max of the chosen value field across all series. + * Returns `undefined` when the dataset has no usable points for that field. + */ +export function datasetDomain( + dataset: ExpressionDataset, + field: ExpressionValueField, +): ValueDomain | undefined { + let min = Number.POSITIVE_INFINITY + let max = Number.NEGATIVE_INFINITY + let found = false + for (const series of dataset.series) { + const domain = seriesDomain(series, field) + if (domain === undefined) continue + found = true + if (domain.min < min) min = domain.min + if (domain.max > max) max = domain.max + } + return found ? { min, max } : undefined +} + +/** True when the dataset contains at least one finite point. */ +export function hasRenderablePoints(dataset: ExpressionDataset): boolean { + return dataset.series.some((series) => + series.points.some((point) => Number.isFinite(point.value) && point.sample.trim().length > 0), + ) +} + +/** True when any point in the dataset carries a normalized value. */ +export function hasNormalizedValues(dataset: ExpressionDataset): boolean { + return dataset.series.some((series) => + series.points.some((point) => point.normalizedValue !== undefined), + ) +} + +/** + * The y-axis domain for a value field with a scientifically sensible + * default: all-non-negative measurements start at zero, all-negative + * measurements end at zero, and degenerate single-value datasets are padded. + * Returns `{ min: 0, max: 1 }` when the dataset has no usable points. + */ +export function expressionValueDomain( + dataset: ExpressionDataset, + field: ExpressionValueField, +): ValueDomain { + const raw = datasetDomain(dataset, field) + if (raw === undefined) return { min: 0, max: 1 } + let { min, max } = raw + if (min >= 0) min = 0 + if (max < 0) max = 0 + if (min === max) { + const pad = Math.max(1, Math.abs(max) * 0.5) + return { min: min - pad, max: max + pad } + } + return { min, max } +} + +/** Copy of a point's metadata with every key/entry coerced to safe scalars. */ +export function sanitizeMetadata(metadata: unknown): ScientificMetadata | undefined { + if (typeof metadata !== 'object' || metadata === null || Array.isArray(metadata)) return undefined + const entries = Object.entries(metadata).filter( + (entry): entry is [string, string | number | boolean] => { + const [, field] = entry + return typeof field === 'string' || typeof field === 'number' || typeof field === 'boolean' + }, + ) + return entries.length > 0 ? Object.fromEntries(entries) : undefined +} diff --git a/apps/web/src/lib/scientific/geometry.test.ts b/apps/web/src/lib/scientific/geometry.test.ts new file mode 100644 index 0000000..8f99926 --- /dev/null +++ b/apps/web/src/lib/scientific/geometry.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from 'vitest' + +import { DEFAULT_CHART_MARGINS, plotArea, seriesColor } from './geometry' +import { SERIES_COLORS } from './geometry' + +describe('plotArea', () => { + it('subtracts margins from the canvas size', () => { + const area = plotArea(960, 480, DEFAULT_CHART_MARGINS) + expect(area.x0).toBe(DEFAULT_CHART_MARGINS.left) + expect(area.y0).toBe(DEFAULT_CHART_MARGINS.top) + expect(area.width).toBe(960 - DEFAULT_CHART_MARGINS.left - DEFAULT_CHART_MARGINS.right) + expect(area.height).toBe(480 - DEFAULT_CHART_MARGINS.top - DEFAULT_CHART_MARGINS.bottom) + }) + + it('clamps to a non-negative size for tiny canvases', () => { + const area = plotArea(20, 20, DEFAULT_CHART_MARGINS) + expect(area.width).toBeGreaterThanOrEqual(0) + expect(area.height).toBeGreaterThanOrEqual(0) + }) + + it('uses default margins when none are given', () => { + expect(plotArea(960, 480).x0).toBe(DEFAULT_CHART_MARGINS.left) + }) +}) + +describe('seriesColor', () => { + it('returns the palette color for a series index', () => { + expect(seriesColor(0)).toBe('#2563eb') + expect(seriesColor(1)).toBe('#16a34a') + }) + + it('cycles deterministically past the palette end', () => { + expect(seriesColor(0)).toBe(seriesColor(SERIES_COLORS.length)) + }) +}) diff --git a/apps/web/src/lib/scientific/geometry.ts b/apps/web/src/lib/scientific/geometry.ts new file mode 100644 index 0000000..69bf8d7 --- /dev/null +++ b/apps/web/src/lib/scientific/geometry.ts @@ -0,0 +1,73 @@ +/** + * Scientific chart geometry (Phase 6.7). + * + * Pure layout math for chart rendering: chart/plot dimensions, margins, and + * the deterministic series color palette. Rendering components consume the + * computed `PlotArea` and never derive layout themselves, so layout stays + * testable and identical across runs. + */ + +export interface ChartMargins { + top: number + right: number + bottom: number + left: number +} + +/** Default SVG canvas size used when no width/height is measured or given. */ +export const DEFAULT_CHART_WIDTH = 960 +export const DEFAULT_CHART_HEIGHT = 480 + +/** Default margins reserved for axes and labels. */ +export const DEFAULT_CHART_MARGINS: ChartMargins = { + top: 24, + right: 24, + bottom: 48, + left: 64, +} + +/** Pixel area available for data marks inside the chart canvas. */ +export interface PlotArea { + x0: number + y0: number + width: number + height: number +} + +/** Computes the data plot area from a canvas size and margins. */ +export function plotArea( + width: number, + height: number, + margins: ChartMargins = DEFAULT_CHART_MARGINS, +): PlotArea { + return { + x0: margins.left, + y0: margins.top, + width: Math.max(0, width - margins.left - margins.right), + height: Math.max(0, height - margins.top - margins.bottom), + } +} + +/** + * Deterministic, colorblind-aware series palette. Colors are assigned by + * series index and never depend on hash order, so charts are stable across + * renders and environments. + */ +export const SERIES_COLORS: readonly string[] = [ + '#2563eb', + '#16a34a', + '#dc2626', + '#9333ea', + '#d97706', + '#0891b2', + '#db2777', + '#4d7c0f', +] + +/** Returns the palette color for a series index, cycling deterministically. */ +export function seriesColor(index: number): string { + return SERIES_COLORS[index % SERIES_COLORS.length] +} + +/** Number of horizontal gridlines rendered between the min and max ticks. */ +export const GRIDLINE_TARGET = 6 diff --git a/apps/web/src/lib/scientific/scale.test.ts b/apps/web/src/lib/scientific/scale.test.ts new file mode 100644 index 0000000..c1a8d40 --- /dev/null +++ b/apps/web/src/lib/scientific/scale.test.ts @@ -0,0 +1,124 @@ +import { describe, expect, it } from 'vitest' + +import { + categoryLabelTicks, + continuousNiceTickStep, + createCategoryScale, + createContinuousScale, + formatTickValue, + niceTicks, +} from './scale' + +describe('createContinuousScale', () => { + it('maps domain onto a pixel range in both directions', () => { + const scale = createContinuousScale([0, 100], [0, 500]) + expect(scale.toPixel(0)).toBe(0) + expect(scale.toPixel(100)).toBe(500) + expect(scale.toPixel(50)).toBe(250) + expect(scale.invert(250)).toBe(50) + }) + + it('supports inverted ranges (top-down y-axis)', () => { + const scale = createContinuousScale([0, 10], [400, 0]) + expect(scale.toPixel(0)).toBe(400) + expect(scale.toPixel(10)).toBe(0) + }) + + it('handles negative domains', () => { + const scale = createContinuousScale([-5, 5], [0, 100]) + expect(scale.toPixel(-5)).toBe(0) + expect(scale.toPixel(5)).toBe(100) + expect(scale.toPixel(0)).toBe(50) + }) + + it('degenerates safely when the domain or range has zero span', () => { + const flat = createContinuousScale([7, 7], [0, 100]) + expect(flat.toPixel(7)).toBe(0) + const flatRange = createContinuousScale([0, 10], [5, 5]) + expect(flatRange.invert(5)).toBe(0) + }) +}) + +describe('niceTicks and continuousNiceTickStep', () => { + it('produces ascending human-friendly ticks at 1/2/5 x 10^n steps', () => { + const ticks = niceTicks(0, 100, 5) + expect(ticks.length).toBeGreaterThan(0) + for (let index = 1; index < ticks.length; index += 1) { + expect(ticks[index]).toBeGreaterThan(ticks[index - 1]) + } + expect(ticks).toContain(0) + }) + + it('supports negative and fractional domains', () => { + const ticks = niceTicks(-0.7, 1.2, 6) + expect(ticks.length).toBeGreaterThan(0) + for (const tick of ticks) { + expect(tick).toBeGreaterThanOrEqual(-0.7 - 1e-9) + expect(tick).toBeLessThanOrEqual(1.2 + 1e-9) + } + expect(niceTicks(-3, -1, 5).every((tick) => tick < 0)).toBe(true) + }) + + it('returns degenerate values for empty or flat domains', () => { + expect(niceTicks(Number.NaN, 1)).toEqual([]) + expect(niceTicks(0, 0)).toEqual([0]) + expect(continuousNiceTickStep(0, 0)).toBe(1) + }) +}) + +describe('formatTickValue', () => { + it('trims floating-point noise and uses compact suffixes', () => { + expect(formatTickValue(0.30000000000000004)).toBe('0.3') + expect(formatTickValue(1500)).toBe('1.5K') + expect(formatTickValue(2500000)).toBe('2.50M') + expect(formatTickValue(-4)).toBe('-4') + }) + + it('returns an empty string for non-finite values', () => { + expect(formatTickValue(Number.NaN)).toBe('') + }) +}) + +describe('createCategoryScale', () => { + it('spaces categories evenly across a range', () => { + const scale = createCategoryScale(['A', 'B', 'C'], [0, 200]) + expect(scale.toPixel('A')).toBe(0) + expect(scale.toPixel('B')).toBe(100) + expect(scale.toPixel('C')).toBe(200) + expect(scale.step).toBe(100) + expect(scale.indexOf('B')).toBe(1) + expect(scale.indexOf('missing')).toBe(-1) + expect(scale.toPixel('missing')).toBe(0) + }) + + it('centers a single category', () => { + const scale = createCategoryScale(['A'], [0, 200]) + expect(scale.toPixel('A')).toBe(100) + expect(scale.slot('A')).toEqual([0, 200]) + }) + + it('reports slot extents for multi-category scales', () => { + const scale = createCategoryScale(['A', 'B', 'C'], [0, 200]) + expect(scale.slot('B')).toEqual([50, 150]) + expect(scale.slot('missing')).toBeUndefined() + }) +}) + +describe('categoryLabelTicks', () => { + it('labels every category when they fit', () => { + const scale = createCategoryScale(['A', 'B', 'C'], [0, 300]) + const ticks = categoryLabelTicks(scale, 300) + expect(ticks.map((tick) => tick.sample)).toEqual(['A', 'B', 'C']) + expect(ticks.every((tick) => tick.visible)).toBe(true) + }) + + it('steps labels when they do not fit, deterministically', () => { + const samples = Array.from({ length: 12 }, (_, index) => `S${index}`) + const scale = createCategoryScale(samples, [0, 300]) + const ticks = categoryLabelTicks(scale, 300, 36) + const visible = ticks.filter((tick) => tick.visible) + expect(visible.length).toBeGreaterThan(0) + expect(visible.length).toBeLessThan(ticks.length) + expect(visible[0].sample).toBe('S0') + }) +}) diff --git a/apps/web/src/lib/scientific/scale.ts b/apps/web/src/lib/scientific/scale.ts new file mode 100644 index 0000000..3eb96aa --- /dev/null +++ b/apps/web/src/lib/scientific/scale.ts @@ -0,0 +1,200 @@ +/** + * Native chart scales and tick generation (Phase 6.7). + * + * Provides the two scale families scientific charts need, implemented + * natively (no D3 dependency) so the repo keeps its zero-runtime-dependency + * convention: + * + * - `createContinuousScale`: linear mapping of a numeric domain onto a pixel + * range (used for the y-axis value scale and for computing tick positions). + * - `createCategoryScale`: evenly spaced band/point scale over category names + * (used for the x-axis samples), including the per-slot step needed to + * place series points. + * + * Tick generation (`niceTicks`) produces deterministic, human-friendly tick + * values at 1/2/5 × 10^n steps, unlike `lib/genome/geometry.ts`'s + * integer-oriented `niceTickStep` which targets base-pair coordinates. + * + * All functions are pure, allocation-free of shared mutable state, and locale + * independent so chart output is deterministic. + */ + +/** Linear scale mapping `domain` onto `range` (pixels). */ +export interface ContinuousScale { + domain: [number, number] + /** Pixel range; `range[0]` maps to `domain[0]`. */ + range: [number, number] + toPixel: (value: number) => number + /** Inverse of `toPixel`; returns the domain value at a pixel. */ + invert: (pixel: number) => number + /** Nicely rounded tick values within the domain, ascending. */ + ticks: (targetCount?: number) => number[] +} + +export function createContinuousScale( + domain: [number, number], + range: [number, number], +): ContinuousScale { + const [domainMin, domainMax] = domain + const [rangeMin, rangeMax] = range + const domainSpan = domainMax - domainMin + const rangeSpan = rangeMax - rangeMin + + const toPixel = (value: number): number => { + if (domainSpan === 0) return rangeMin + return rangeMin + ((value - domainMin) / domainSpan) * rangeSpan + } + const invert = (pixel: number): number => { + if (rangeSpan === 0) return domainMin + return domainMin + ((pixel - rangeMin) / rangeSpan) * domainSpan + } + + return { + domain, + range, + toPixel, + invert, + ticks: (targetCount = 5) => niceTicks(domainMin, domainMax, targetCount), + } +} + +export interface TickDefinition { + value: number + /** Rendered label; identical to `value` when it stays exact. */ + label: string +} + +/** + * Computes a "nice" tick step at 1/2/5 × 10^n within a max number of steps. + * + * Unlike `lib/genome/geometry.ts`'s integer-only `niceTickStep` (which targets + * whole-base genomic coordinates), this variant supports fractional and + * negative domains because expression values are arbitrary reals. + */ +export function continuousNiceTickStep(min: number, max: number, targetCount = 5): number { + const count = targetCount < 1 ? 1 : targetCount + if (!Number.isFinite(min) || !Number.isFinite(max) || max === min) return 1 + const rawStep = (max - min) / Math.max(count, 1) + if (rawStep <= 0) return 1 + const magnitude = 10 ** Math.floor(Math.log10(rawStep)) + const candidates = [1, 2, 5, 10] + let step = magnitude + for (const candidate of candidates) { + const candidateStep = candidate * magnitude + if (candidateStep >= rawStep) { + step = candidateStep + break + } + step = candidateStep + } + return step +} + +/** + * Deterministic, ascending tick values for a domain, snapped to a nice step. + * Supports negative and fractional domains. + */ +export function niceTicks(min: number, max: number, targetCount = 5): number[] { + if (!Number.isFinite(min) || !Number.isFinite(max)) return [] + if (max < min) return [] + if (max === min) return [min] + const step = continuousNiceTickStep(min, max, targetCount) + const first = Math.ceil(min / step) * step + const ticks: number[] = [] + const decimals = Math.max(0, -Math.floor(Math.log10(step))) + for (let value = first; value <= max + step / 1e9; value += step) { + ticks.push(Number(value.toFixed(decimals))) + if (ticks.length > 100) break + } + return ticks +} + +/** + * Renders a numeric value for axis labels: trims trailing zeros from + * floating-point artifacts and uses a compact suffix for very large values. + */ +export function formatTickValue(value: number): string { + if (!Number.isFinite(value)) return '' + const magnitude = Math.abs(value) + if (magnitude >= 1_000_000) return `${(value / 1_000_000).toFixed(2)}M` + if (magnitude >= 1_000) return `${(value / 1_000).toFixed(1)}K` + return Number(value.toFixed(6)).toString() +} + +/** Equal-width slots for ordered category names over a pixel range. */ +export interface CategoryScale { + domain: readonly string[] + range: [number, number] + /** Distance between slot centers. */ + step: number + /** Horizontal padding (in pixels) reserved at each end of the range. */ + padding: number + /** Pixel center of a category slot; `undefined` for unknown categories. */ + toPixel: (category: string) => number + /** Index of a category within `domain`, or `-1`. */ + indexOf: (category: string) => number + /** The pixel extent (x0, x1) of a slot; `undefined` for unknown categories. */ + slot: (category: string) => [number, number] | undefined +} + +export function createCategoryScale( + categories: readonly string[], + range: [number, number], + padding = 0.2, +): CategoryScale { + const [rangeMin, rangeMax] = range + const rangeSpan = rangeMax - rangeMin + const count = categories.length + const step = count > 1 ? rangeSpan / (count - 1) : 0 + const indexMap = new Map(categories.map((category, index) => [category, index])) + + const toPixel = (category: string): number => { + const index = indexMap.get(category) + if (index === undefined) return rangeMin + if (count === 1) return rangeMin + rangeSpan / 2 + return rangeMin + (index / (count - 1)) * rangeSpan + } + + return { + domain: categories, + range, + step, + padding, + toPixel, + indexOf: (category: string): number => { + const index = indexMap.get(category) + return index === undefined ? -1 : index + }, + slot: (category: string): [number, number] | undefined => { + const index = indexMap.get(category) + if (index === undefined) return undefined + if (count === 1) return [rangeMin, rangeMax] + const half = rangeSpan / (2 * (count - 1)) + return [toPixel(category) - half, toPixel(category) + half] + }, + } +} + +/** + * Selects which category labels fit along the x-axis without overlap. + * + * Returns `{ samples, visible }` where every entry carries its center pixel + * and a `visible` flag. Labels that do not fit are hidden (rather than + * rotated or dropped) so the axis stays readable and deterministic for any + * number of samples. + */ +export function categoryLabelTicks( + scale: CategoryScale, + plotWidth: number, + minLabelGap = 36, +): Array<{ sample: string; x: number; visible: boolean }> { + const count = scale.domain.length + if (count === 0) return [] + const step = plotWidth / Math.max(count, 1) + const everyNth = Math.max(1, Math.ceil(minLabelGap / Math.max(step, 1))) + return scale.domain.map((sample, index) => ({ + sample, + x: scale.toPixel(sample), + visible: index % everyNth === 0, + })) +} diff --git a/apps/web/src/lib/scientific/tooltip.test.ts b/apps/web/src/lib/scientific/tooltip.test.ts new file mode 100644 index 0000000..d2030d4 --- /dev/null +++ b/apps/web/src/lib/scientific/tooltip.test.ts @@ -0,0 +1,86 @@ +import { describe, expect, it } from 'vitest' + +import { formatTooltipValue, lookupPoint, pointTooltip } from './tooltip' +import type { ExpressionDataset } from './types' + +function dataset(): ExpressionDataset { + return { + id: 'd', + title: 'Dataset', + series: [ + { + id: 'tp53', + label: 'TP53', + points: [ + { + identifier: 'TP53', + sample: 'Tumor-1', + value: 128.4, + normalizedValue: 1.92, + metadata: { status: 'overexpressed' }, + }, + { identifier: 'TP53', sample: 'Normal-1', value: 44.2 }, + ], + }, + ], + } +} + +describe('formatTooltipValue', () => { + it('trims floating-point noise and handles non-finite values', () => { + expect(formatTooltipValue(128.39999999999998)).toBe('128.4') + expect(formatTooltipValue(0)).toBe('0') + expect(formatTooltipValue(Number.NaN)).toBe('\u2013') + }) +}) + +describe('pointTooltip', () => { + it('maps a point to title, subtitle, and rows', () => { + const data = dataset() + const series = data.series[0] + const tooltip = pointTooltip(series, series.points[0]) + expect(tooltip.title).toBe('TP53') + expect(tooltip.subtitle).toBe('TP53 — Tumor-1') + expect(tooltip.rows).toEqual([ + { label: 'Sample', value: 'Tumor-1' }, + { label: 'Value', value: '128.4' }, + { label: 'Normalized', value: '1.92' }, + { label: 'status', value: 'overexpressed' }, + ]) + }) + + it('omits normalized and metadata rows when absent', () => { + const data = dataset() + const series = data.series[0] + const tooltip = pointTooltip(series, series.points[1]) + expect(tooltip.rows.map((row) => row.label)).toEqual(['Sample', 'Value']) + }) +}) + +describe('lookupPoint', () => { + it('finds a point by its series, identifier, and sample', () => { + const data = dataset() + const lookup = lookupPoint(data, { seriesId: 'tp53', pointId: 'TP53', sample: 'Tumor-1' }) + expect(lookup?.series.id).toBe('tp53') + expect(lookup?.point.sample).toBe('Tumor-1') + }) + + it('resolves the correct point when identifiers repeat across samples', () => { + const data = dataset() + const lookup = lookupPoint(data, { seriesId: 'tp53', pointId: 'TP53', sample: 'Normal-1' }) + expect(lookup?.point.value).toBe(44.2) + }) + + it('returns undefined for unknown series, points, or samples', () => { + const data = dataset() + expect( + lookupPoint(data, { seriesId: 'missing', pointId: 'TP53', sample: 'Tumor-1' }), + ).toBeUndefined() + expect( + lookupPoint(data, { seriesId: 'tp53', pointId: 'missing', sample: 'Tumor-1' }), + ).toBeUndefined() + expect( + lookupPoint(data, { seriesId: 'tp53', pointId: 'TP53', sample: 'Missing' }), + ).toBeUndefined() + }) +}) diff --git a/apps/web/src/lib/scientific/tooltip.ts b/apps/web/src/lib/scientific/tooltip.ts new file mode 100644 index 0000000..a70623a --- /dev/null +++ b/apps/web/src/lib/scientific/tooltip.ts @@ -0,0 +1,64 @@ +/** + * Chart tooltip row mapping (Phase 6.7). + * + * Pure function that turns a selected point into the labelled rows shown in + * the hover tooltip and the accessible detail panel. Kept outside the + * component so the mapping is unit-testable and identical in both places. + */ + +import type { ExpressionDataset, ExpressionPoint, ExpressionSeries, PointKey } from './types' + +export interface TooltipRow { + label: string + value: string +} + +export interface PointTooltip { + title: string + subtitle: string + rows: TooltipRow[] +} + +/** Formats a finite number for tooltips, trimming floating-point noise. */ +export function formatTooltipValue(value: number): string { + if (!Number.isFinite(value)) return '–' + return Number(value.toFixed(4)).toString() +} + +/** + * Builds the tooltip content for a point. The title is the series label, the + * subtitle identifies the point (identifier / sample), and the rows carry the + * sample, value, optional normalized value, and any metadata fields. + */ +export function pointTooltip(series: ExpressionSeries, point: ExpressionPoint): PointTooltip { + const rows: TooltipRow[] = [ + { label: 'Sample', value: point.sample }, + { label: 'Value', value: formatTooltipValue(point.value) }, + ] + if (point.normalizedValue !== undefined) { + rows.push({ label: 'Normalized', value: formatTooltipValue(point.normalizedValue) }) + } + if (point.metadata !== undefined) { + for (const [key, value] of Object.entries(point.metadata)) { + rows.push({ label: key, value: String(value) }) + } + } + const subtitle = `${point.identifier} — ${point.sample}` + return { title: series.label, subtitle, rows } +} + +export interface LookupResult { + series: ExpressionSeries + point: ExpressionPoint +} + +/** Finds a point by its `PointKey`. Returns `undefined` when missing. */ +export function lookupPoint(dataset: ExpressionDataset, key: PointKey): LookupResult | undefined { + const series = dataset.series.find((candidate) => candidate.id === key.seriesId) + if (series === undefined) return undefined + const point = series.points.find( + (candidate) => candidate.identifier === key.pointId && candidate.sample === key.sample, + ) + if (point === undefined) return undefined + return { series, point } +} diff --git a/apps/web/src/lib/scientific/types.ts b/apps/web/src/lib/scientific/types.ts new file mode 100644 index 0000000..d94026e --- /dev/null +++ b/apps/web/src/lib/scientific/types.ts @@ -0,0 +1,92 @@ +/** + * Scientific chart data model (Phase 6.7). + * + * The dataset types describe quantitative measurements (starting with gene + * expression) grouped into series of points. The types are intentionally + * generic about measurement *kind*: any numeric measurement with a stable + * per-point identifier and a categorical grouping dimension maps onto them, + * so the same chart infrastructure can serve later scientific charts + * (coverage, statistical comparisons, QC metrics, ...) without rework. + * + * The chart infrastructure itself (`lib/scientific/scale.ts`, + * `lib/scientific/geometry.ts`) is measurement-agnostic — it only consumes + * numbers, categories, and labels. + */ + +/** Free-form metadata attached to a point, series, or dataset. */ +export type ScientificMetadata = Record + +/** + * A single quantitative measurement. + * + * `identifier` is the stable per-dataset id of the measured entity (e.g. a + * gene) within its series, `sample` is the categorical grouping shown on the + * chart's x-axis, and `value` is the measurement shown on the y-axis. + * `normalizedValue`, when present, is an alternative representation (e.g. + * z-score / log-transformed) rendered when the user switches views. + */ +export interface ExpressionPoint { + identifier: string + sample: string + value: number + normalizedValue?: number + metadata?: ScientificMetadata +} + +/** + * A named group of points sharing the same measured entity (e.g. one gene + * measured across several samples). Series id must be unique within a + * dataset. + */ +export interface ExpressionSeries { + id: string + label: string + points: ExpressionPoint[] +} + +/** + * A full scientific dataset: a titled collection of series. + * + * The dataset carries only measurement data plus optional metadata; all + * rendering concerns (scales, axes, tooltips, selection) are derived by the + * pure modules under `lib/scientific` and composed by the view-model hook + * `useExpressionChart`. + */ +export interface ExpressionDataset { + id: string + title: string + series: ExpressionSeries[] + metadata?: ScientificMetadata +} + +/** + * Stable identity of a single point within a dataset: the series id, the + * measured entity identifier (e.g. a gene), and the sample. Series + entity + + * sample is the unique triple for one measurement, so interactions (selection, + * keyboard navigation, tooltips) survive re-renders and re-ordering. + */ +export interface PointKey { + seriesId: string + pointId: string + sample: string +} + +/** Renders a canonical, collision-free key for a `PointKey`. */ +export function pointKeyToString(key: PointKey): string { + return `${key.seriesId}:${key.pointId}@${key.sample}` +} + +/** Parses a string produced by `pointKeyToString` back into a `PointKey`. */ +export function parsePointKey(value: string): PointKey | undefined { + const atIndex = value.indexOf('@') + if (atIndex === -1) return undefined + const seriesPart = value.slice(0, atIndex) + const sample = value.slice(atIndex + 1) + const separatorIndex = seriesPart.indexOf(':') + if (separatorIndex === -1) return undefined + return { + seriesId: seriesPart.slice(0, separatorIndex), + pointId: seriesPart.slice(separatorIndex + 1), + sample, + } +} diff --git a/apps/web/src/lib/scientific/useChartSize.ts b/apps/web/src/lib/scientific/useChartSize.ts new file mode 100644 index 0000000..4842d4e --- /dev/null +++ b/apps/web/src/lib/scientific/useChartSize.ts @@ -0,0 +1,52 @@ +/** + * Responsive chart sizing hook (Phase 6.7). + * + * Measures the containing element's width via `ResizeObserver` and falls back + * to a default width when measurement is unavailable (e.g. during SSR or in + * jsdom). Height is fixed by the caller. Charts re-render with the measured + * width so labels and marks stay crisp at any container size. + */ + +import { useEffect, useRef, useState } from 'react' + +import { DEFAULT_CHART_WIDTH } from './geometry' + +export interface ChartSizeResult { + /** Ref to attach to the chart's wrapper element. */ + ref: React.RefObject + /** Measured (or fallback) pixel width. */ + width: number + /** Fixed pixel height. */ + height: number +} + +/** + * Returns the container width (measured) and a fixed height. + * + * The initial render uses `fallbackWidth`; once the element is measured the + * hook updates. If `ResizeObserver` is unavailable the measured width is used + * once and never re-observed. + */ +export function useChartSize( + height: number, + fallbackWidth: number = DEFAULT_CHART_WIDTH, +): ChartSizeResult { + const ref = useRef(null) + const [width, setWidth] = useState(fallbackWidth) + + useEffect(() => { + const element = ref.current + if (element === null) return + const update = () => { + const measured = element.clientWidth + if (measured > 0) setWidth(measured) + } + update() + if (typeof ResizeObserver === 'undefined') return + const observer = new ResizeObserver(update) + observer.observe(element) + return () => observer.disconnect() + }, []) + + return { ref, width, height } +} diff --git a/apps/web/src/lib/scientific/useExpressionChart.test.tsx b/apps/web/src/lib/scientific/useExpressionChart.test.tsx new file mode 100644 index 0000000..6722674 --- /dev/null +++ b/apps/web/src/lib/scientific/useExpressionChart.test.tsx @@ -0,0 +1,138 @@ +import { cleanup, render, screen, waitFor } from '@testing-library/react' +import { afterEach, describe, expect, it, vi } from 'vitest' + +import { TP53_PATHWAY_EXPRESSION_FIXTURE, buildExpressionDataset } from './expression.fixtures' +import { useExpressionChart } from './useExpressionChart' + +function Harness({ + onModel, + options = {}, +}: { + onModel: (model: ReturnType) => void + options?: Parameters[0] +}) { + const model = useExpressionChart(options) + onModel(model) + return {model.status} +} + +/** Renders the hook and exposes its latest result via a safe getter. */ +function renderHook(options: Parameters[0] = {}) { + let model: ReturnType | undefined + render( + { + model = next + }} + />, + ) + return { + get model(): ReturnType { + if (model === undefined) { + throw new Error('useExpressionChart did not capture a model (expected after success)') + } + return model + }, + } +} + +afterEach(() => { + cleanup() + vi.restoreAllMocks() +}) + +describe('useExpressionChart', () => { + it('loads a dataset and reports success', async () => { + const loader = vi.fn(async () => TP53_PATHWAY_EXPRESSION_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(1) + expect(captured.model.dataset?.id).toBe('expression-tp53-pathway') + }) + + it('reports empty for an empty dataset', async () => { + const empty = buildExpressionDataset({ series: [] }) + const loader = vi.fn(async () => empty) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('empty')) + expect(captured.model.dataset).toBeDefined() + }) + + it('reports error when the loader rejects and refetch retries', async () => { + const loader = vi + .fn() + .mockRejectedValueOnce(new Error('expression down')) + .mockResolvedValueOnce(TP53_PATHWAY_EXPRESSION_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + expect(captured.model.error?.message).toBe('expression down') + + captured.model.refetch() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(loader).toHaveBeenCalledTimes(2) + }) + + it('derives sorted samples, the value domain, and normalized availability', async () => { + const loader = vi.fn(async () => TP53_PATHWAY_EXPRESSION_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(captured.model.samples).toEqual([ + 'Normal-1', + 'Normal-2', + 'Normal-3', + 'Tumor-1', + 'Tumor-2', + 'Tumor-3', + ]) + expect(captured.model.hasNormalizedValues).toBe(true) + expect(captured.model.valueField).toBe('value') + expect(captured.model.valueDomain.min).toBeGreaterThanOrEqual(0) + }) + + it('switches the value field and updates the domain', async () => { + const loader = vi.fn(async () => TP53_PATHWAY_EXPRESSION_FIXTURE) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + captured.model.setValueField('normalizedValue') + await waitFor(() => expect(captured.model.valueField).toBe('normalizedValue')) + expect(captured.model.valueDomain.min).toBeLessThan(0) + expect(captured.model.valueDomain.max).toBeGreaterThan(0) + }) + + it('falls back to raw values when no normalized values exist', async () => { + const rawOnly = buildExpressionDataset({ + series: [{ id: 's1', label: 'S1', points: [['Tumor-1', 5]] }], + }) + const loader = vi.fn(async () => rawOnly) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(captured.model.hasNormalizedValues).toBe(false) + + captured.model.setValueField('normalizedValue') + await waitFor(() => expect(captured.model.valueField).toBe('value')) + }) + + it('tracks point selection and clears it when a new dataset loads', async () => { + let current = TP53_PATHWAY_EXPRESSION_FIXTURE + const loader = vi.fn(async () => current) + const captured = renderHook({ loader }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + + captured.model.selectPoint('tp53:TP53@Tumor-1') + await waitFor(() => expect(captured.model.selectedKey).toBe('tp53:TP53@Tumor-1')) + + current = { ...buildExpressionDataset({ series: [] }), id: 'expression-other' } + captured.model.refetch() + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('empty')) + await waitFor(() => expect(captured.model.selectedKey).toBeNull()) + }) + + it('loads through the default fetchExpressionDataset loader when only datasetId is given', async () => { + const captured = renderHook({ datasetId: 'expression-tp53-pathway' }) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('error')) + // No backend during tests: fetch rejects, which is the expected lifecycle. + expect(captured.model.error).toBeDefined() + }) +}) diff --git a/apps/web/src/lib/scientific/useExpressionChart.ts b/apps/web/src/lib/scientific/useExpressionChart.ts new file mode 100644 index 0000000..69a56e7 --- /dev/null +++ b/apps/web/src/lib/scientific/useExpressionChart.ts @@ -0,0 +1,130 @@ +/** + * Expression Chart view-model hook (Phase 6.7). + * + * Composes the Phase 6.1 visualization data lifecycle + * (`useVisualizationData`) with dataset derivation (samples, active value + * field, y-domain) and point selection. The whole dataset is loaded once per + * dataset id; the value-field toggle and selection are client-side only, so + * no per-view refetch is needed. + */ + +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' + +import type { VisualizationError, VisualizationStatus } from '@/lib/visualization/types' +import { useVisualizationData } from '@/lib/visualization/useVisualizationData' + +import { fetchExpressionDataset } from './api' +import { + type ExpressionValueField, + availableSamples, + expressionValueDomain, + hasNormalizedValues, + hasRenderablePoints, +} from './expression' +import type { ExpressionDataset } from './types' + +/** Result shape of `useExpressionChart`, consumed by `ExpressionChart`. */ +export interface ExpressionChartResult { + status: VisualizationStatus + error: VisualizationError | undefined + /** Re-runs the dataset load request. */ + refetch: () => void + /** Loaded dataset, or `undefined` until success. */ + dataset: ExpressionDataset | undefined + /** Sorted sample names driving the x-axis (empty until success). */ + samples: string[] + /** The y-axis value field in use. */ + valueField: ExpressionValueField + /** Switches the y-axis between raw and normalized values. */ + setValueField: (field: ExpressionValueField) => void + /** True when at least one point has a normalized value. */ + hasNormalizedValues: boolean + /** Y-axis domain for the active field (always finite, non-empty). */ + valueDomain: { min: number; max: number } + /** Canonical key of the selected point (`"seriesId:pointId"`), or null. */ + selectedKey: string | null + /** Selects a point by key; passing null clears the selection. */ + selectPoint: (key: string | null) => void + clearSelection: () => void +} + +export interface UseExpressionChartOptions { + /** Fetches the dataset (defaults to `fetchExpressionDataset(datasetId)`). */ + loader?: (signal: AbortSignal) => Promise + /** Backend dataset id used when no custom loader is provided. */ + datasetId?: string +} + +const EMPTY_DOMAIN = { min: 0, max: 1 } + +export function useExpressionChart(options: UseExpressionChartOptions = {}): ExpressionChartResult { + const { datasetId } = options + + const loaderRef = useRef(options.loader) + const datasetIdRef = useRef(datasetId) + loaderRef.current = options.loader + datasetIdRef.current = datasetId + + const loader = useCallback((signal: AbortSignal) => { + const custom = loaderRef.current + if (custom !== undefined) return custom(signal) + if (datasetIdRef.current !== undefined) + return fetchExpressionDataset(datasetIdRef.current, signal) + return Promise.reject(new Error('No expression dataset loader provided to useExpressionChart.')) + }, []) + + const { status, data, error, refetch } = useVisualizationData(loader, { + isEmpty: (dataset) => dataset.series.length === 0 || !hasRenderablePoints(dataset), + }) + + const dataset = data + + const samples = useMemo(() => (dataset === undefined ? [] : availableSamples(dataset)), [dataset]) + + const normalizedAvailable = useMemo( + () => (dataset === undefined ? false : hasNormalizedValues(dataset)), + [dataset], + ) + + const [valueField, setValueField] = useState('value') + const [selectedKey, setSelectedKey] = useState(null) + + // Clear selection whenever a new dataset loads. + const loadedDatasetIdRef = useRef(null) + useEffect(() => { + if (dataset !== undefined && dataset.id !== loadedDatasetIdRef.current) { + loadedDatasetIdRef.current = dataset.id + setSelectedKey(null) + } + }, [dataset]) + + // Fall back to raw values when normalized values are not available. + const effectiveField: ExpressionValueField = + valueField === 'normalizedValue' && !normalizedAvailable ? 'value' : valueField + + const valueDomain = useMemo( + () => (dataset === undefined ? EMPTY_DOMAIN : expressionValueDomain(dataset, effectiveField)), + [dataset, effectiveField], + ) + + const selectPoint = useCallback((key: string | null) => { + setSelectedKey(key) + }, []) + + const clearSelection = useCallback(() => setSelectedKey(null), []) + + return { + status, + error, + refetch, + dataset, + samples, + valueField: effectiveField, + setValueField, + hasNormalizedValues: normalizedAvailable, + valueDomain, + selectedKey, + selectPoint, + clearSelection, + } +} diff --git a/apps/web/src/lib/visualization/visualizationModules.ts b/apps/web/src/lib/visualization/visualizationModules.ts index 7d9388d..976fba2 100644 --- a/apps/web/src/lib/visualization/visualizationModules.ts +++ b/apps/web/src/lib/visualization/visualizationModules.ts @@ -54,7 +54,8 @@ const MODULES: readonly VisualizationModule[] = [ { id: 'scientific-charts', title: 'Scientific Charts', - description: 'Statistical and research-oriented charts.', + description: + 'Expression charts: reusable chart primitives, native scales, axes, tooltips, legends, and selection.', milestone: '6.7', source: { kind: 'api', reference: '/api/visualization/charts' }, }, diff --git a/docs/visualization/README.md b/docs/visualization/README.md index bf77d7e..5713809 100644 --- a/docs/visualization/README.md +++ b/docs/visualization/README.md @@ -4,9 +4,9 @@ This directory documents the GenomeAI visualization platform (Phase 6). ## Status -**Phase 6.5 — Protein Viewer** and **Phase 6.6 — Biological Network Viewer** -are implemented. Phase 6.6 adds a deterministic, dependency-free relationship -network viewer on top of the Phase 6.1 foundation. +**Phase 6.7 — Scientific Charts** is implemented, on top of the Phase 6.6 +network viewer and the Phase 6.1 foundation. Phase 6.7 adds a deterministic, +dependency-free expression chart built on reusable chart primitives. | Milestone | Description | Status | |-----------|-------------|--------| @@ -16,7 +16,7 @@ network viewer on top of the Phase 6.1 foundation. | 6.4 | Variant Visualization | ✅ Implemented | | 6.5 | Protein Structure Viewer | ✅ Implemented | | 6.6 | Biological Network Visualization | ✅ Implemented | -| 6.7 | Scientific Charts | 📋 Planned | +| 6.7 | Scientific Charts | ✅ Implemented | | 6.8 | Integrated Research Workspace | 📋 Planned | | 6.9 | Visualization Performance & Optimization | 📋 Planned | | 6.10 | Visualization Testing & Documentation | 📋 Planned | @@ -105,6 +105,25 @@ network viewer on top of the Phase 6.1 foundation. normalizers as production. - Demo integrated at `/visualization`. +## What Phase 6.7 Provides + +- Scientific charts (see [Scientific Charts](./scientific-charts.md)): a + typed measurement dataset model (`ExpressionPoint` / `ExpressionSeries` / + `ExpressionDataset`), pure validation + normalization, and native scales + (`ContinuousScale`, `CategoryScale`, `niceTicks`) — no D3.js dependency. +- A reusable `useExpressionChart` hook composing the shared data lifecycle + with sorted samples, a raw/normalized value-field toggle, the y-domain, and + point selection, plus responsive sizing via `useChartSize`. +- Reusable chart primitives (`ChartAxes`, `ChartLegend`, `ChartTooltip`) and an + `ExpressionChart` SVG component: samples on the x-axis, the active value on + the y-axis, one series (gene) per color, gridlines, axes, legend, hover + tooltips, and keyboard-accessible point selection with a readable detail + panel. +- An expression-data boundary: the backend does not yet expose an expression + endpoint, so the demo uses a clearly isolated dev fixture routed through the + same normalizers as production. +- Demo integrated at `/visualization`. + ## Documents | Document | Description | @@ -115,6 +134,7 @@ network viewer on top of the Phase 6.1 foundation. | [Variant](variant.md) | Phase 6.4 Variant visualization: scope, data flow, API, a11y, tests | | [Protein Viewer](protein-viewer.md) | Phase 6.5 Protein Viewer: scope, data flow, API, a11y, tests | | [Network Viewer](network-viewer.md) | Phase 6.6 Biological Network Viewer: scope, design decision, data flow, API, a11y, tests | +| [Scientific Charts](scientific-charts.md) | Phase 6.7 Scientific Charts: scope, design decision, data flow, API, a11y, tests | | [Roadmap](roadmap.md) | Detailed phase tracking and future work | ## Technology Notes @@ -130,4 +150,7 @@ them arrives: behind the `createLayout` seam instead (see [network-viewer.md](./network-viewer.md)); Cytoscape.js remains available for later interactive/manipulation work -- D3.js → Phase 6.7 (scientific charts) \ No newline at end of file +- D3.js → deferred: Phase 6.7 ships native scales and tick generation behind + the `ContinuousScale` / `CategoryScale` seams instead (see + [scientific-charts.md](./scientific-charts.md)); D3.js remains available for + later heavy statistical charting work \ No newline at end of file diff --git a/docs/visualization/roadmap.md b/docs/visualization/roadmap.md index 1a1accc..cc57588 100644 --- a/docs/visualization/roadmap.md +++ b/docs/visualization/roadmap.md @@ -4,9 +4,52 @@ Tracks the Phase 6 visualization platform milestones. See [Phase 6 of the project ROADMAP]() for the authoritative milestone list. -## Current Milestone: 6.6 — Biological Network Viewer ✅ +## Current Milestone: 6.7 — Scientific Charts ✅ -Implemented on branch `feat/visualization-network-viewer`, on top of 6.5. +Implemented on top of 6.6. + +Delivered: + +- Typed measurement data model (`lib/scientific/types.ts`) — + `ExpressionPoint`, `ExpressionSeries`, `ExpressionDataset`, `PointKey`; + generic enough for later chart types (coverage, statistical, QC) +- Pure validation + normalization (`lib/scientific/expression.ts`) — + typed error reports, canonical ordering, dedupe by entity + sample, + sample/domain derivation, zero-anchored/padded y-domains +- Native scales (`lib/scientific/scale.ts`) — invertible continuous scale, + category scale, 1/2/5 × 10^n `niceTicks`, deterministic label stepping; no + D3.js (see + [Scientific Charts](scientific-charts.md#design-decision-native-scales-no-d3js)) +- Pure chart geometry (`lib/scientific/geometry.ts`) — margins/plot area, + deterministic colorblind-aware palette +- Tooltip mapping (`lib/scientific/tooltip.ts`) — shared labelled rows for the + hover tooltip and the accessible detail panel +- Thin typed adapter (`lib/scientific/api.ts`) documenting the future + `GET /expression/datasets/{id}` contract (no backend changes) +- `useExpressionChart` hook — load lifecycle (via the shared + `useVisualizationData`) + sorted samples + value-field toggle + y-domain + + point selection; `useChartSize` for responsive widths +- Reusable chart primitives (`components/scientific/`) — `ChartAxes`, + `ChartLegend`, `ChartTooltip`, and the `ExpressionChart` SVG component + (gridlines, axes, legend, hover tooltips, keyboard-accessible point + selection, detail panel) +- Demo integrated at `/visualization` (`ScientificDemo` uses the dev fixture) +- Tests (85 across the scientific modules) and docs (see + [Scientific Charts](scientific-charts.md)) + +Constraints honored: + +- The chart layer never asserts biological validity or hard-codes gene / + disease / database knowledge; expression values stay opaque finite numbers +- Backend expression endpoints are not yet exposed, so the demo uses a clearly + isolated dev fixture routed through the same normalizers as production +- No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js; no new + runtime dependencies +- Phase 5 search untouched + +## Previous milestones + +### 6.6 — Biological Network Viewer ✅ Delivered: @@ -99,8 +142,6 @@ Constraints honored: - No C++, WebAssembly, WebGPU, Three.js, Cytoscape.js, or D3.js - Phase 5 search untouched; no new runtime dependencies -## Previous milestones - ### 6.4 — Variant Visualization ✅ Implemented on branch `feat/visualization-variant`, on top of 6.3. @@ -215,7 +256,6 @@ Constraints honored: | # | Milestone | Notes | |---|-----------|-------| -| 6.7 | Scientific Charts | Trend/QC plots; D3-based | | 6.8 | Integrated Research Workspace | Assembles 6.5–6.7 into a UI | | 6.9 | Visualization Performance & Optimization | Virtualization / density rendering for large data | | 6.10 | Visualization Testing & Documentation | Stabilization + docs pass | diff --git a/docs/visualization/scientific-charts.md b/docs/visualization/scientific-charts.md new file mode 100644 index 0000000..0e72a7e --- /dev/null +++ b/docs/visualization/scientific-charts.md @@ -0,0 +1,243 @@ +# Scientific Charts (Phase 6.7) + +Provides the first scientific chart — an **expression chart** (samples on the +x-axis, expression values on the y-axis, one series per gene) — on top of a +small, reusable chart foundation: typed measurement datasets, native scales +and tick generation, axes, legends, tooltips, and keyboard-accessible point +selection. It follows the same layered architecture as the earlier +visualization milestones — pure, unit-tested math under `lib/scientific`, a +thin view-model hook, and presentation-only components over the shared +[Phase 6.1 foundation](README.md). + +## Status + +Implemented as part of Phase 6.7. + +## Design decision: native scales, no D3.js + +The Phase 6.1 technology notes anticipated D3.js for Phase 6.7. This +milestone deliberately delivers the chart **foundation without that +dependency**: linear value scales, category (sample) scales, and human-friendly +tick generation are implemented natively in TypeScript and rendered with plain +SVG, consistent with the platform's lightweight stance. + +Consequences and rationale: + +- **Determinism first.** "Same input, stable output" is a hard requirement for + scientific visualization and for tests. `createContinuousScale`, + `createCategoryScale`, and `niceTicks` are pure, allocation-free of shared + state, and locale independent — charts render identically across runs and + environments. +- **Zero new dependencies.** The web app remains React + TypeScript + Tailwind + + SVG. This matches how the genome/protein viewers re-implemented their own + scale/axis math (`lib/genome/geometry.ts`). +- **A clean seam.** Scales are plain interfaces (`ContinuousScale`, + `CategoryScale`); a D3-backed or logarithmic scale could be added later + without touching the component or the hook. +- If heavy statistical charting (many marks, animated transitions, a large + grammar-of-graphics API) becomes a real requirement, the scale seam is where + D3.js would plug in. + +## Scope (delivered) + +- Typed data model (`lib/scientific/types.ts`): `ExpressionPoint`, + `ExpressionSeries`, `ExpressionDataset`, `PointKey` — deliberately generic + about measurement *kind*, so later scientific charts (coverage, statistical + comparisons, QC metrics, ...) reuse the same dataset and infrastructure. +- Pure validation + normalization (`lib/scientific/expression.ts`): + `validateExpressionDataset` (typed error list), `normalizeExpressionDataset` + (drop invalid points, dedupe by entity + sample, deterministic ordering), + `availableSamples`, `seriesDomain` / `datasetDomain`, `expressionValueDomain` + (zero-anchored / padded defaults), `hasRenderablePoints`, + `hasNormalizedValues`, `sanitizeMetadata`. +- Native scales (`lib/scientific/scale.ts`): `createContinuousScale` + (value → pixel, invertible), `createCategoryScale` (even sample slots), + `niceTicks` / `continuousNiceTickStep` (1/2/5 × 10^n steps, negative + + fractional domains), `formatTickValue`, `categoryLabelTicks` (deterministic + label stepping when samples do not fit). +- Pure chart geometry (`lib/scientific/geometry.ts`): chart/plot dimensions, + margins, `plotArea`, and the deterministic colorblind-aware `SERIES_COLORS` + palette. +- Tooltip mapping (`lib/scientific/tooltip.ts`): `pointTooltip` builds the + labelled rows (sample, value, normalized value, metadata) shared by the hover + tooltip and the accessible detail panel; `lookupPoint` resolves a `PointKey`. +- Thin typed adapter (`lib/scientific/api.ts`): documented expected contract + for a future expression endpoint plus normalization seams — + **no backend changes** (see [API limitation](#api-limitation)). +- `useExpressionChart` hook: load lifecycle (via the shared 6.1 + `useVisualizationData`), sorted samples, active value field (raw / + normalized toggle with safe fallback), y-domain, and point selection. +- Responsive sizing (`useChartSize`): measures the container via + `ResizeObserver` with a deterministic fallback. +- Reusable chart primitives (`components/scientific/`): `ChartAxes` + (gridlines + tick labels + captions), `ChartLegend`, `ChartTooltip`, and the + `ExpressionChart` component composing them. +- Demo integrated at `/visualization` (`ScientificDemo`). +- Tests and docs. + +## Out of scope (later milestones or explicitly excluded) + +- Other chart types (coverage, volcano, heatmap, statistical comparison, QC). + The dataset model and scale/geometry/tooltip infrastructure were designed to + be reused by them. +- Logarithmic / transformed scales and error bars. +- D3.js / WebAssembly / WebGPU (see design decision above). +- Backend expression endpoints (see [API limitation](#api-limitation)). +- Large-data rendering work (deferred to 6.9; see the roadmap). +- Import from external biological databases (GEO, ArrayExpress, TCGA, ...). + The browser never talks to them; they feed GenomeAI through the later + connector/ingestion architecture. + +## Data model + +```ts +interface ExpressionPoint { + identifier: string // measured entity, e.g. gene symbol + sample: string // categorical x-axis group + value: number // measurement shown on the y-axis + normalizedValue?: number // alternative representation (z-score, log ratio, ...) + metadata?: ScientificMetadata +} + +interface ExpressionSeries { + id: string + label: string + points: ExpressionPoint[] +} + +interface ExpressionDataset { + id: string + title: string + series: ExpressionSeries[] + metadata?: ScientificMetadata +} +``` + +A point is uniquely identified by the triple `(seriesId, identifier, sample)` — +one series contains one measurement per entity per sample, so the identity is +serialized as `seriesId:identifier@sample` (`pointKeyToString` / +`parsePointKey`) and used for selection, keyboard navigation, and tooltips. + +## Architecture and data flow + +```text +ExpressionChart (component) apps/web/src/components/scientific/ExpressionChart.tsx + useExpressionChart (view model) apps/web/src/lib/scientific/useExpressionChart.ts + useVisualizationData (lifecycle) + reuse lib/visualization/useVisualizationData.ts (6.1) + fetchExpressionDataset (adapter) + lib/scientific/api.ts + -> GET /expression/datasets/{id} + reuse lib/genome/api.ts (API_BASE_URL, errors) + normalizeExpressionDataset (normalize) + lib/scientific/expression.ts + availableSamples / valueDomain + lib/scientific/expression.ts + createContinuousScale / CategoryScale + lib/scientific/scale.ts + plotArea / seriesColor + lib/scientific/geometry.ts + pointTooltip / lookupPoint + lib/scientific/tooltip.ts + ChartAxes / ChartLegend / ChartTooltip + components/scientific/*.tsx +``` + +`ExpressionChart` is fully controlled by an `ExpressionChartResult` returned +from `useExpressionChart`; the component renders nothing but presentation, so +the data lifecycle (loading / success / empty / error / retry) is handled by +the shared `VisualizationContainer`. + +## Rendering + +- The y-axis is a `ContinuousScale` over `expressionValueDomain`; all- + non-negative measurements start at zero, all-negative measurements end at + zero, and degenerate single-value datasets are padded. +- The x-axis is a `CategoryScale` over the sorted, unique sample names; + sample labels are stepped deterministically when they do not fit. +- Each series (gene) gets a stable palette color and a connecting polyline + through its (sample, value) positions; points that lack the active value + field (e.g. no normalized value in normalized view) are skipped. +- A "Value / Normalized" control toggles the y-axis between `value` and + `normalizedValue`; it only appears when the dataset carries normalized + values. +- Hovering a point shows a `ChartTooltip`; selecting a point (click or + Enter/Space) shows the labelled detail panel and highlights the point. + +## API limitation + +The GenomeAI backend does **not** yet expose an expression endpoint. Therefore: + +- `lib/scientific/api.ts` documents the expected contract + (`RawExpressionDatasetRecord` / `RawExpressionSeriesRecord` / + `RawExpressionPointRecord`), provides the normalization seams + (`toExpressionPoint`, `toExpressionSeries`, `expressionDatasetFromRecords`), + and `fetchExpressionDataset` attempts `GET /expression/datasets/{id}` (which + 404s today, surfacing the limitation as a typed `GenomeApiError`). +- The isolated development fixtures in `lib/scientific/expression.fixtures.ts` + supply representative TP53-pathway expression values today. They live apart + from production adapters, flow through the **same** normalizers the adapters + use, and must be replaced — not treated as a real API — as soon as the + backend exposes an expression endpoint. + +## Expression semantics + +- Raw `value`s are treated as opaque finite numbers; the chart does **not** + assert biological validity, log-transform anything, or hard-code gene / + disease / database knowledge. Negative and zero values are supported because + they are scientifically meaningful for normalized ratios and low/absent + expression. +- Normalized values are purely presentational alternatives selected by the + toggle; `expressionValueDomain` adapts the y-axis accordingly. + +## Accessibility + +- The SVG is a labelled group (`role="group"`, `aria-label` summarizing the + dataset, sample count, and series count) so the interactive point controls + stay in the accessibility tree. +- Each point is a keyboard-focusable selection control (`role="button"`, + `tabIndex=0`) with an accessible name (e.g. `Select TP53: TP53 in Tumor-1 = + 128.4`); Enter/Space toggles selection, `aria-pressed` announces state, and a + native `` mirrors the detail. +- The detail panel is a labelled `<section>` with a `<dl>` of typed fields; + the hover tooltip uses the same rows. +- The summary is announced via `aria-live="polite"`; the value-field toggle is + a `<fieldset>` with accessible buttons. + +## Tests + +`apps/web` root test run (`pnpm --filter @genomeai/web test`) covers: + +| Layer | File(s) | Focus | +|-------|---------|-------| +| validation | `expression.test.ts` | point/series/dataset validation, duplicate series ids, zero/negative values | +| normalization | `expression.test.ts` | deterministic ordering, dedupe by entity + sample, invalid drops, default fallbacks | +| derived data | `expression.test.ts` | samples, per-series/dataset domains, zero-anchored / padded / safe domains, metadata sanitizing | +| scale | `scale.test.ts` | continuous mapping + invert, inverted ranges, negative domains, category spacing, nice ticks, label stepping | +| geometry | `geometry.test.ts` | plot area from margins, clamping, deterministic palette | +| tooltip | `tooltip.test.ts` | row mapping (with/without normalized + metadata), format, unique point lookup | +| api | `api.test.ts` | record normalization, invalid-record handling, URL, error mapping, fixture integrity | +| hook | `useExpressionChart.test.tsx` | lifecycle states, empty/error/retry, derived samples + domain, field toggle + fallback, selection reset-on-new-dataset | +| component | `ExpressionChart.test.tsx` | rendering, axes/legend, point selection (click + keyboard), hover tooltip, detail panel, value-field toggle, responsive width, degenerate datasets | + +The pre-existing genome/protein/network suites continue to pass unchanged. + +## Files + +- `apps/web/src/lib/scientific/types.ts` +- `apps/web/src/lib/scientific/expression.ts` +- `apps/web/src/lib/scientific/scale.ts` +- `apps/web/src/lib/scientific/geometry.ts` +- `apps/web/src/lib/scientific/tooltip.ts` +- `apps/web/src/lib/scientific/api.ts` +- `apps/web/src/lib/scientific/expression.fixtures.ts` +- `apps/web/src/lib/scientific/useChartSize.ts` +- `apps/web/src/lib/scientific/useExpressionChart.ts` +- `apps/web/src/components/scientific/ChartAxes.tsx` +- `apps/web/src/components/scientific/ChartLegend.tsx` +- `apps/web/src/components/scientific/ChartTooltip.tsx` +- `apps/web/src/components/scientific/ExpressionChart.tsx` +- `apps/web/src/app/visualization/ScientificDemo.tsx` +- `apps/web/src/app/visualization/page.tsx` (renders the demo) + +## Validation + +All commands green on the branch: + +```shell +make lint # biome + ruff +make typecheck # pyright + tsc +make test # web vitest + sdk-ts + api pytest +make build # production web build +``` From 65f5feab7c45619d553785e583601b5a2f597e4a Mon Sep 17 00:00:00 2001 From: dsk-dev-ai <darshan.kachare.dev@gmail.com> Date: Thu, 13 Aug 2026 11:18:25 +0530 Subject: [PATCH 2/2] fix(visualization): address scientific charts review findings - Use index-suffixed row keys in tooltip and detail panels so metadata rows named like built-in labels (Value, Sample, Normalized) can't collide. - Report aria-pressed for selection only (hover no longer reports as pressed) and render a visible dashed focus ring for keyboard-focusable points. - Add a viewBox to the expression chart SVG so an explicit width scales content instead of clipping or leaving blank space. - Convert JSON parse failures in fetchExpressionDataset to GenomeApiError while preserving abort errors. - Dedupe series ids in normalizeExpressionDataset (first wins) so output always passes validateExpressionDataset. - Replace localeCompare with a code-unit comparator for deterministic, locale-independent ordering. - Clone points and metadata during normalization so output never shares mutable state with the input. - Fix categoryLabelTicks to use plotWidth/(count-1) center spacing so labels that fit are not hidden. - Use a collision-free length-prefixed PointKey encoding with round-trip tests for delimiters and empty values. - Refetch when datasetId changes in useExpressionChart. --- .../components/scientific/ChartTooltip.tsx | 13 ++-- .../scientific/ExpressionChart.test.tsx | 73 +++++++++++++++++-- .../components/scientific/ExpressionChart.tsx | 28 +++++-- apps/web/src/lib/scientific/api.test.ts | 19 +++++ apps/web/src/lib/scientific/api.ts | 17 ++++- .../web/src/lib/scientific/expression.test.ts | 46 ++++++++++++ apps/web/src/lib/scientific/expression.ts | 40 ++++++++-- apps/web/src/lib/scientific/scale.test.ts | 16 ++++ apps/web/src/lib/scientific/scale.ts | 5 +- apps/web/src/lib/scientific/types.test.ts | 37 ++++++++++ apps/web/src/lib/scientific/types.ts | 45 ++++++++---- .../scientific/useExpressionChart.test.tsx | 66 ++++++++++++++++- .../src/lib/scientific/useExpressionChart.ts | 11 +++ 13 files changed, 371 insertions(+), 45 deletions(-) create mode 100644 apps/web/src/lib/scientific/types.test.ts diff --git a/apps/web/src/components/scientific/ChartTooltip.tsx b/apps/web/src/components/scientific/ChartTooltip.tsx index 8972ebb..0f12da0 100644 --- a/apps/web/src/components/scientific/ChartTooltip.tsx +++ b/apps/web/src/components/scientific/ChartTooltip.tsx @@ -2,6 +2,9 @@ import type { PointTooltip } from '@/lib/scientific/tooltip' +/** Pixels reserved for the tooltip panel (`w-56` = 14rem = 224px). */ +export const TOOLTIP_WIDTH = 224 + export interface ChartTooltipProps { tooltip: PointTooltip /** Pixel position of the hovered point (SVG coordinates). */ @@ -17,20 +20,20 @@ export interface ChartTooltipProps { * accessible detail panel, so both surfaces stay in sync. */ export function ChartTooltip({ tooltip, x, y, width }: ChartTooltipProps) { - const left = Math.min(Math.max(x + 16, 4), Math.max(width - 240, 4)) + const left = Math.min(Math.max(x + 16, 4), Math.max(width - TOOLTIP_WIDTH, 4)) const top = Math.max(y - 8, 4) return ( <div data-testid="chart-tooltip" - className="pointer-events-none absolute z-10 w-56 rounded-md border border-gray-200 bg-white p-2 shadow-md" - style={{ left, top }} + className="pointer-events-none absolute z-10 rounded-md border border-gray-200 bg-white p-2 shadow-md" + style={{ left, top, width: TOOLTIP_WIDTH }} role="tooltip" > <p className="truncate text-xs font-semibold text-gray-900">{tooltip.title}</p> <p className="truncate text-xs text-gray-500">{tooltip.subtitle}</p> <dl className="mt-1 grid w-full grid-cols-[auto_1fr] gap-x-3 gap-y-0.5"> - {tooltip.rows.map((row) => ( - <div key={row.label} className="contents"> + {tooltip.rows.map((row, index) => ( + <div key={`${row.label}-${index}`} className="contents"> <dt className="text-xs text-gray-500">{row.label}</dt> <dd className="truncate text-xs text-gray-900">{row.value}</dd> </div> diff --git a/apps/web/src/components/scientific/ExpressionChart.test.tsx b/apps/web/src/components/scientific/ExpressionChart.test.tsx index 2f18f11..acc756c 100644 --- a/apps/web/src/components/scientific/ExpressionChart.test.tsx +++ b/apps/web/src/components/scientific/ExpressionChart.test.tsx @@ -11,10 +11,13 @@ import { buildExpressionDataset, } from '@/lib/scientific/expression.fixtures' import { SERIES_COLORS } from '@/lib/scientific/geometry' +import { pointKeyToString } from '@/lib/scientific/types' import type { ExpressionChartResult } from '@/lib/scientific/useExpressionChart' import { ExpressionChart } from './ExpressionChart' +const TP53_TUMOR_1_KEY = pointKeyToString({ seriesId: 'tp53', pointId: 'TP53', sample: 'Tumor-1' }) + function result(overrides: Partial<ExpressionChartResult> = {}): ExpressionChartResult { const data = TP53_PATHWAY_EXPRESSION_FIXTURE const base: ExpressionChartResult = { @@ -98,7 +101,7 @@ describe('ExpressionChart', () => { const selectPoint = vi.fn() render(<ExpressionChart result={result({ selectPoint })} />) fireEvent.click(screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ })) - expect(selectPoint).toHaveBeenCalledWith('tp53:TP53@Tumor-1') + expect(selectPoint).toHaveBeenCalledWith(TP53_TUMOR_1_KEY) }) it('supports keyboard selection via Enter and Space', () => { @@ -106,13 +109,13 @@ describe('ExpressionChart', () => { render(<ExpressionChart result={result({ selectPoint })} />) const control = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) fireEvent.keyDown(control, { key: 'Enter' }) - expect(selectPoint).toHaveBeenCalledWith('tp53:TP53@Tumor-1') + expect(selectPoint).toHaveBeenCalledWith(TP53_TUMOR_1_KEY) cleanup() const selectPoint2 = vi.fn() render( <ExpressionChart - result={result({ selectPoint: selectPoint2, selectedKey: 'tp53:TP53@Tumor-1' })} + result={result({ selectPoint: selectPoint2, selectedKey: TP53_TUMOR_1_KEY })} />, ) const control2 = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) @@ -134,7 +137,7 @@ describe('ExpressionChart', () => { }) it('renders a detail panel for a controlled selection', () => { - render(<ExpressionChart result={result({ selectedKey: 'tp53:TP53@Tumor-1' })} />) + render(<ExpressionChart result={result({ selectedKey: TP53_TUMOR_1_KEY })} />) expect(screen.getByTestId('chart-selection-detail')).toBeInTheDocument() expect(screen.getByText('TP53', { selector: 'h3' })).toBeInTheDocument() expect(screen.getByText('Value', { selector: 'dt' })).toBeInTheDocument() @@ -142,9 +145,7 @@ describe('ExpressionChart', () => { it('clears the selection from the detail panel', () => { const clearSelection = vi.fn() - render( - <ExpressionChart result={result({ selectedKey: 'tp53:TP53@Tumor-1', clearSelection })} />, - ) + render(<ExpressionChart result={result({ selectedKey: TP53_TUMOR_1_KEY, clearSelection })} />) fireEvent.click(screen.getByRole('button', { name: /clear selection/i })) expect(clearSelection).toHaveBeenCalledTimes(1) }) @@ -204,6 +205,64 @@ describe('ExpressionChart', () => { expect(screen.getByRole('button', { name: /Select S1/ })).toBeInTheDocument() }) + it('reports aria-pressed for selection, not hover', () => { + render(<ExpressionChart result={result()} />) + const control = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) + fireEvent.mouseEnter(control) + expect(control.getAttribute('aria-pressed')).toBe('false') + fireEvent.mouseLeave(control) + expect(control.getAttribute('aria-pressed')).toBe('false') + }) + + it('reports aria-pressed when selected', () => { + render(<ExpressionChart result={result({ selectedKey: TP53_TUMOR_1_KEY })} />) + const control = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) + expect(control.getAttribute('aria-pressed')).toBe('true') + }) + + it('shows a visible focus ring when a point is keyboard-focused', () => { + render(<ExpressionChart result={result()} />) + const control = screen.getByRole('button', { name: /Select TP53: TP53 in Tumor-1 = 128.4/ }) + expect(screen.queryByTestId('point-tp53-TP53-Tumor-1-focus-ring')).not.toBeInTheDocument() + fireEvent.focus(control) + expect(screen.getByTestId('point-tp53-TP53-Tumor-1-focus-ring')).toBeInTheDocument() + fireEvent.blur(control) + expect(screen.queryByTestId('point-tp53-TP53-Tumor-1-focus-ring')).not.toBeInTheDocument() + }) + + it('renders metadata rows that collide with built-in labels', () => { + const withCollidingMetadata: Parameters<typeof ExpressionChart>[0]['result']['dataset'] = { + id: 'colliding', + title: 'Colliding metadata', + series: [ + { + id: 's1', + label: 'S1', + points: [ + { + identifier: 'TP53', + sample: 'Tumor-1', + value: 10, + metadata: { Value: 'x', Sample: 'y', Normalized: 'z' }, + }, + ], + }, + ], + } + render( + <ExpressionChart + result={result({ + dataset: withCollidingMetadata, + samples: availableSamples(withCollidingMetadata), + valueDomain: expressionValueDomain(withCollidingMetadata, 'value'), + })} + />, + ) + const control = screen.getByRole('button', { name: /Select S1/ }) + fireEvent.mouseEnter(control) + expect(screen.getByTestId('chart-tooltip')).toHaveTextContent('Value') + }) + it('respects an explicit pixel width for responsive layouts', () => { render(<ExpressionChart result={result()} width={800} />) expect(screen.getByTestId('expression-chart-svg').getAttribute('width')).toBe('800') diff --git a/apps/web/src/components/scientific/ExpressionChart.tsx b/apps/web/src/components/scientific/ExpressionChart.tsx index 84d4dd6..4ab7c0c 100644 --- a/apps/web/src/components/scientific/ExpressionChart.tsx +++ b/apps/web/src/components/scientific/ExpressionChart.tsx @@ -35,7 +35,6 @@ function SeriesPoint({ color, label, selected, - pressed, testId, onMouseEnter, onMouseLeave, @@ -46,16 +45,29 @@ function SeriesPoint({ color: string label: string selected: boolean - pressed: boolean testId: string onMouseEnter: () => void onMouseLeave: () => void onSelect: () => void }) { + const [focused, setFocused] = useState(false) return ( <g data-testid={testId} onMouseEnter={onMouseEnter} onMouseLeave={onMouseLeave}> <title>{label} + {focused ? ( + + ) : null} setFocused(true)} + onBlur={() => setFocused(false)} onKeyDown={(event) => { if (event.key === 'Enter' || event.key === ' ') { event.preventDefault() @@ -165,8 +179,8 @@ function ChartDetail({ result }: { result: ExpressionChartResult }) {

{tooltip.subtitle}

- {tooltip.rows.map((row) => ( -
+ {tooltip.rows.map((row, index) => ( +
{row.label}
{row.value}
@@ -288,6 +302,8 @@ export function ExpressionChart({ setHoveredKey(pointKey)} onMouseLeave={() => setHoveredKey(null)} diff --git a/apps/web/src/lib/scientific/api.test.ts b/apps/web/src/lib/scientific/api.test.ts index 29912fd..8c0f23c 100644 --- a/apps/web/src/lib/scientific/api.test.ts +++ b/apps/web/src/lib/scientific/api.test.ts @@ -136,6 +136,25 @@ describe('fetchExpressionDataset', () => { await expect(fetchExpressionDataset('d1')).rejects.toBeInstanceOf(GenomeApiError) }) + it('throws a GenomeApiError when the response body is not JSON', async () => { + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(new SyntaxError('Unexpected token < in JSON')), + } as unknown as Response) + await expect(fetchExpressionDataset('d1')).rejects.toBeInstanceOf(GenomeApiError) + }) + + it('rethrows an abort error instead of converting it to a GenomeApiError', async () => { + const abortError = new DOMException('The operation was aborted', 'AbortError') + globalThis.fetch = vi.fn().mockResolvedValue({ + ok: true, + status: 200, + json: () => Promise.reject(abortError), + } as unknown as Response) + await expect(fetchExpressionDataset('d1')).rejects.toBe(abortError) + }) + it('throws a GenomeApiError on a malformed payload', async () => { globalThis.fetch = vi.fn().mockResolvedValue(jsonResponse(null)) await expect(fetchExpressionDataset('d1')).rejects.toBeInstanceOf(GenomeApiError) diff --git a/apps/web/src/lib/scientific/api.ts b/apps/web/src/lib/scientific/api.ts index d8cd97a..62628d6 100644 --- a/apps/web/src/lib/scientific/api.ts +++ b/apps/web/src/lib/scientific/api.ts @@ -144,7 +144,22 @@ export async function fetchExpressionDataset( response.status, ) } - const payload: unknown = await response.json() + let payload: unknown + try { + payload = await response.json() + } catch (cause) { + if ( + typeof cause === 'object' && + cause !== null && + 'name' in cause && + (cause as { name?: unknown }).name === 'AbortError' + ) { + throw cause + } + throw new GenomeApiError( + `GenomeAI API returned an unreadable response for expression dataset ${datasetId}`, + ) + } const dataset = expressionDatasetFromRecords(payload) if (dataset === undefined) { throw new GenomeApiError( diff --git a/apps/web/src/lib/scientific/expression.test.ts b/apps/web/src/lib/scientific/expression.test.ts index f0a7eb6..8990c20 100644 --- a/apps/web/src/lib/scientific/expression.test.ts +++ b/apps/web/src/lib/scientific/expression.test.ts @@ -126,6 +126,52 @@ describe('normalizeExpressionDataset', () => { expect(normalizeExpressionDataset(input).series.map((s) => s.id)).toEqual(['keep']) }) + it('dedupes series with the same id, keeping the first occurrence', () => { + const input = dataset([ + { id: 'dup', label: 'First', points: [point('A', 'Tumor-1', 1)] }, + { id: 'dup', label: 'Second', points: [point('B', 'Tumor-1', 2)] }, + { id: 'keep', label: 'Keep', points: [point('C', 'Tumor-1', 3)] }, + ]) + const normalized = normalizeExpressionDataset(input) + expect(normalized.series.map((s) => s.id)).toEqual(['dup', 'keep']) + expect(normalized.series[0].label).toBe('First') + expect(validateExpressionDataset(normalized).valid).toBe(true) + }) + + it('sorts with locale-independent code-unit order', () => { + // In most collation locales 'ä' sorts as "a" (before 'z'); code-unit order + // places 'z' (0x7A) before 'ä' (0xE4). LocaleCompare would put 'ä' first. + const input = dataset([ + { + id: 's1', + label: 'S1', + points: [point('X', 'ä', 1), point('X', 'z', 2), point('X', 'Z', 3)], + }, + ]) + const normalized = normalizeExpressionDataset(input) + const samples = normalized.series[0].points.map((p) => p.sample) + expect(samples).toEqual(['Z', 'z', 'ä']) + expect(availableSamples(input)).toEqual(['Z', 'z', 'ä']) + }) + + it('does not share mutable state with the input', () => { + const point = { identifier: 'TP53', sample: 'Tumor-1', value: 10, metadata: { status: 'on' } } + const input = dataset([{ id: 's1', label: 'S1', points: [point] }]) + const normalized = normalizeExpressionDataset(input) + + point.value = 999 + point.metadata.status = 'off' + point.identifier = 'mutated' + input.series[0].label = 'Mutated' + input.metadata = { mutated: true } + + const normalizedPoint = normalized.series[0].points[0] + expect(normalizedPoint.value).toBe(10) + expect(normalizedPoint.identifier).toBe('TP53') + expect(normalizedPoint.metadata?.status).toBe('on') + expect(normalized.series[0].label).toBe('S1') + }) + it('falls back to defaults for empty ids and titles', () => { const input = dataset([]) const empty = normalizeExpressionDataset({ ...input, id: '', title: '' }) diff --git a/apps/web/src/lib/scientific/expression.ts b/apps/web/src/lib/scientific/expression.ts index 2e616ad..c14c2cb 100644 --- a/apps/web/src/lib/scientific/expression.ts +++ b/apps/web/src/lib/scientific/expression.ts @@ -130,13 +130,20 @@ export function dedupePoints(points: ExpressionPoint[]): ExpressionPoint[] { * then identifier) so charts render identically across runs. * - Invalid points (empty identifier/sample or non-finite value) are dropped. * - Duplicate point identifiers within a series are deduped (first wins). + * - Duplicate series ids are deduped (first wins). * * The returned dataset is always structurally valid (`validateExpressionDataset` * passes) and never shares mutable state with the input. */ export function normalizeExpressionDataset(dataset: ExpressionDataset): ExpressionDataset { + const seenSeriesIds = new Set() const series = dataset.series - .filter((series) => isValidIdentifier(series.id) && isValidIdentifier(series.label)) + .filter((series) => { + if (!isValidIdentifier(series.id) || !isValidIdentifier(series.label)) return false + if (seenSeriesIds.has(series.id)) return false + seenSeriesIds.add(series.id) + return true + }) .map((series) => { const points = dedupePoints(series.points) .filter((point) => { @@ -150,24 +157,41 @@ export function normalizeExpressionDataset(dataset: ExpressionDataset): Expressi }) .sort( (left, right) => - left.sample.localeCompare(right.sample) || - left.identifier.localeCompare(right.identifier), + compareText(left.sample, right.sample) || + compareText(left.identifier, right.identifier), ) - return { ...series, points } + return { ...series, points: points.map(clonePoint) } }) - .sort((left, right) => left.id.localeCompare(right.id)) + .sort((left, right) => compareText(left.id, right.id)) return { id: dataset.id.trim().length > 0 ? dataset.id : 'unnamed-dataset', title: dataset.title.trim().length > 0 ? dataset.title : 'Unnamed dataset', series, - ...(dataset.metadata !== undefined ? { metadata: dataset.metadata } : {}), + ...(dataset.metadata !== undefined ? { metadata: { ...dataset.metadata } } : {}), + } +} + +/** + * Compares two strings in code-unit order. Unlike `String.prototype.localeCompare` + * (which depends on the runtime's active locale), this ordering is identical + * across every environment, keeping normalized output deterministic. + */ +function compareText(left: string, right: string): number { + return left < right ? -1 : left > right ? 1 : 0 +} + +/** Shallow copy of a point (including its metadata) so the input stays untouched. */ +function clonePoint(point: ExpressionPoint): ExpressionPoint { + return { + ...point, + ...(point.metadata !== undefined ? { metadata: { ...point.metadata } } : {}), } } /** * The sorted, unique set of sample names across every series. Drives the - * categorical x-axis; ordering is deterministic (locale compare). + * categorical x-axis; ordering is deterministic (code-unit compare). */ export function availableSamples(dataset: ExpressionDataset): string[] { const samples = new Set() @@ -176,7 +200,7 @@ export function availableSamples(dataset: ExpressionDataset): string[] { if (point.sample.trim().length > 0) samples.add(point.sample) } } - return [...samples].sort((left, right) => left.localeCompare(right)) + return [...samples].sort((left, right) => compareText(left, right)) } export interface ValueDomain { diff --git a/apps/web/src/lib/scientific/scale.test.ts b/apps/web/src/lib/scientific/scale.test.ts index c1a8d40..4570ac1 100644 --- a/apps/web/src/lib/scientific/scale.test.ts +++ b/apps/web/src/lib/scientific/scale.test.ts @@ -121,4 +121,20 @@ describe('categoryLabelTicks', () => { expect(visible.length).toBeLessThan(ticks.length) expect(visible[0].sample).toBe('S0') }) + + it('labels every category when centers are wide enough to fit', () => { + // Two categories over a 60px plot: centers are 60px apart (not 30px), + // so both labels fit even at the default 36px minimum gap. + const scale = createCategoryScale(['A', 'B'], [0, 60]) + const ticks = categoryLabelTicks(scale, 60, 36) + expect(ticks.map((tick) => tick.sample)).toEqual(['A', 'B']) + expect(ticks.every((tick) => tick.visible)).toBe(true) + }) + + it('labels every category for a single category', () => { + const scale = createCategoryScale(['A'], [0, 60]) + const ticks = categoryLabelTicks(scale, 60, 36) + expect(ticks.map((tick) => tick.sample)).toEqual(['A']) + expect(ticks.every((tick) => tick.visible)).toBe(true) + }) }) diff --git a/apps/web/src/lib/scientific/scale.ts b/apps/web/src/lib/scientific/scale.ts index 3eb96aa..6224096 100644 --- a/apps/web/src/lib/scientific/scale.ts +++ b/apps/web/src/lib/scientific/scale.ts @@ -190,8 +190,9 @@ export function categoryLabelTicks( ): Array<{ sample: string; x: number; visible: boolean }> { const count = scale.domain.length if (count === 0) return [] - const step = plotWidth / Math.max(count, 1) - const everyNth = Math.max(1, Math.ceil(minLabelGap / Math.max(step, 1))) + // Category centers are `plotWidth / (count - 1)` apart (see createCategoryScale). + const spacing = count > 1 ? plotWidth / (count - 1) : plotWidth + const everyNth = Math.max(1, Math.ceil(minLabelGap / Math.max(spacing, 1))) return scale.domain.map((sample, index) => ({ sample, x: scale.toPixel(sample), diff --git a/apps/web/src/lib/scientific/types.test.ts b/apps/web/src/lib/scientific/types.test.ts new file mode 100644 index 0000000..00e7a49 --- /dev/null +++ b/apps/web/src/lib/scientific/types.test.ts @@ -0,0 +1,37 @@ +import { describe, expect, it } from 'vitest' + +import { parsePointKey, pointKeyToString } from './types' +import type { PointKey } from './types' + +describe('pointKeyToString / parsePointKey', () => { + it('round-trips a plain key', () => { + const key: PointKey = { seriesId: 'tp53', pointId: 'TP53', sample: 'Tumor-1' } + expect(parsePointKey(pointKeyToString(key))).toEqual(key) + }) + + it('handles delimiters inside the fields', () => { + const key: PointKey = { seriesId: 'a:b', pointId: 'c@d', sample: 'e:f@g' } + expect(parsePointKey(pointKeyToString(key))).toEqual(key) + }) + + it('round-trips empty values', () => { + const key: PointKey = { seriesId: '', pointId: '', sample: '' } + expect(parsePointKey(pointKeyToString(key))).toEqual(key) + }) + + it('distinguishes keys that collide under a naive encoding', () => { + const first: PointKey = { seriesId: 'a:b', pointId: 'c', sample: 'd' } + const second: PointKey = { seriesId: 'a', pointId: 'b:c', sample: 'd' } + expect(pointKeyToString(first)).not.toBe(pointKeyToString(second)) + expect(parsePointKey(pointKeyToString(first))).toEqual(first) + expect(parsePointKey(pointKeyToString(second))).toEqual(second) + }) + + it('returns undefined for malformed input', () => { + expect(parsePointKey('')).toBeUndefined() + expect(parsePointKey('not-a-key')).toBeUndefined() + expect(parsePointKey(':')).toBeUndefined() + expect(parsePointKey('4:tp53')).toBeUndefined() + expect(parsePointKey('4:tp534:TP537:Tumor-1-extra')).toBeUndefined() + }) +}) diff --git a/apps/web/src/lib/scientific/types.ts b/apps/web/src/lib/scientific/types.ts index d94026e..b2d8905 100644 --- a/apps/web/src/lib/scientific/types.ts +++ b/apps/web/src/lib/scientific/types.ts @@ -71,22 +71,41 @@ export interface PointKey { sample: string } -/** Renders a canonical, collision-free key for a `PointKey`. */ +/** + * Renders a canonical, collision-free key for a `PointKey`. + * + * Fields are length-prefixed (`:`) so the encoding is + * unambiguous even when series ids, point ids, or sample names contain `:` + * or `@`. Empty values are supported and parse back to their exact strings. + */ export function pointKeyToString(key: PointKey): string { - return `${key.seriesId}:${key.pointId}@${key.sample}` + return `${key.seriesId.length}:${key.seriesId}${key.pointId.length}:${key.pointId}${key.sample.length}:${key.sample}` +} + +/** Reads one `:` field, returning the value and next offset. */ +function readPrefixedField( + value: string, + start: number, +): { field: string; next: number } | undefined { + const separator = value.indexOf(':', start) + if (separator === -1) return undefined + const lengthText = value.slice(start, separator) + if (lengthText.length === 0) return undefined + const length = Number(lengthText) + if (!Number.isInteger(length) || length < 0) return undefined + const fieldStart = separator + 1 + const fieldEnd = fieldStart + length + if (fieldEnd > value.length) return undefined + return { field: value.slice(fieldStart, fieldEnd), next: fieldEnd } } /** Parses a string produced by `pointKeyToString` back into a `PointKey`. */ export function parsePointKey(value: string): PointKey | undefined { - const atIndex = value.indexOf('@') - if (atIndex === -1) return undefined - const seriesPart = value.slice(0, atIndex) - const sample = value.slice(atIndex + 1) - const separatorIndex = seriesPart.indexOf(':') - if (separatorIndex === -1) return undefined - return { - seriesId: seriesPart.slice(0, separatorIndex), - pointId: seriesPart.slice(separatorIndex + 1), - sample, - } + const seriesId = readPrefixedField(value, 0) + if (seriesId === undefined) return undefined + const pointId = readPrefixedField(value, seriesId.next) + if (pointId === undefined) return undefined + const sample = readPrefixedField(value, pointId.next) + if (sample === undefined || sample.next !== value.length) return undefined + return { seriesId: seriesId.field, pointId: pointId.field, sample: sample.field } } diff --git a/apps/web/src/lib/scientific/useExpressionChart.test.tsx b/apps/web/src/lib/scientific/useExpressionChart.test.tsx index 6722674..90ce679 100644 --- a/apps/web/src/lib/scientific/useExpressionChart.test.tsx +++ b/apps/web/src/lib/scientific/useExpressionChart.test.tsx @@ -2,8 +2,11 @@ import { cleanup, render, screen, waitFor } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TP53_PATHWAY_EXPRESSION_FIXTURE, buildExpressionDataset } from './expression.fixtures' +import { pointKeyToString } from './types' import { useExpressionChart } from './useExpressionChart' +const TP53_TUMOR_1_KEY = pointKeyToString({ seriesId: 'tp53', pointId: 'TP53', sample: 'Tumor-1' }) + function Harness({ onModel, options = {}, @@ -120,8 +123,8 @@ describe('useExpressionChart', () => { const captured = renderHook({ loader }) await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) - captured.model.selectPoint('tp53:TP53@Tumor-1') - await waitFor(() => expect(captured.model.selectedKey).toBe('tp53:TP53@Tumor-1')) + captured.model.selectPoint(TP53_TUMOR_1_KEY) + await waitFor(() => expect(captured.model.selectedKey).toBe(TP53_TUMOR_1_KEY)) current = { ...buildExpressionDataset({ series: [] }), id: 'expression-other' } captured.model.refetch() @@ -135,4 +138,63 @@ describe('useExpressionChart', () => { // No backend during tests: fetch rejects, which is the expected lifecycle. expect(captured.model.error).toBeDefined() }) + + it('reloads when the datasetId changes', async () => { + const fetchMock = vi.fn(async (url: string) => { + if (url.endsWith('d1')) { + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: 'd1', + title: 'Dataset 1', + series: [ + { + id: 's1', + label: 'S1', + points: [{ identifier: 'A', sample: 'Tumor-1', value: 1 }], + }, + ], + }), + } as unknown as Response + } + return { + ok: true, + status: 200, + json: () => + Promise.resolve({ + id: 'd2', + title: 'Dataset 2', + series: [ + { id: 's2', label: 'S2', points: [{ identifier: 'B', sample: 'Tumor-2', value: 2 }] }, + ], + }), + } as unknown as Response + }) + globalThis.fetch = fetchMock as unknown as typeof fetch + + let model: ReturnType | undefined + const { rerender } = render( + { + model = next + }} + />, + ) + await waitFor(() => expect(screen.getByTestId('status').textContent).toBe('success')) + expect(model?.dataset?.id).toBe('d1') + + rerender( + { + model = next + }} + />, + ) + await waitFor(() => expect(model?.dataset?.id).toBe('d2')) + expect(fetchMock).toHaveBeenCalledTimes(2) + }) }) diff --git a/apps/web/src/lib/scientific/useExpressionChart.ts b/apps/web/src/lib/scientific/useExpressionChart.ts index 69a56e7..b955181 100644 --- a/apps/web/src/lib/scientific/useExpressionChart.ts +++ b/apps/web/src/lib/scientific/useExpressionChart.ts @@ -77,6 +77,17 @@ export function useExpressionChart(options: UseExpressionChartOptions = {}): Exp isEmpty: (dataset) => dataset.series.length === 0 || !hasRenderablePoints(dataset), }) + // Reload whenever the requested dataset id changes. The loader reads the + // latest `datasetId` through a ref, but `useVisualizationData` only fetches + // on mount, so a changed id would otherwise silently keep the old data. + const previousDatasetIdRef = useRef(datasetId) + useEffect(() => { + if (previousDatasetIdRef.current !== datasetId) { + previousDatasetIdRef.current = datasetId + refetch() + } + }, [datasetId, refetch]) + const dataset = data const samples = useMemo(() => (dataset === undefined ? [] : availableSamples(dataset)), [dataset])