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) => (
+
+
+ {item.label}
+
+ ))}
+
+ )
+}
diff --git a/apps/web/src/components/scientific/ChartTooltip.tsx b/apps/web/src/components/scientific/ChartTooltip.tsx
new file mode 100644
index 0000000..0f12da0
--- /dev/null
+++ b/apps/web/src/components/scientific/ChartTooltip.tsx
@@ -0,0 +1,44 @@
+'use client'
+
+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). */
+ 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 - TOOLTIP_WIDTH, 4))
+ const top = Math.max(y - 8, 4)
+ return (
+
+
{tooltip.title}
+
{tooltip.subtitle}
+
+ {tooltip.rows.map((row, index) => (
+
+
{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..acc756c
--- /dev/null
+++ b/apps/web/src/components/scientific/ExpressionChart.test.tsx
@@ -0,0 +1,270 @@
+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 { 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 {
+ 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_TUMOR_1_KEY)
+ })
+
+ 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_TUMOR_1_KEY)
+
+ 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('reports aria-pressed for selection, not hover', () => {
+ render( )
+ 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( )
+ 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( )
+ 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[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(
+ ,
+ )
+ 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( )
+ 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..4ab7c0c
--- /dev/null
+++ b/apps/web/src/components/scientific/ExpressionChart.tsx
@@ -0,0 +1,372 @@
+'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,
+ testId,
+ onMouseEnter,
+ onMouseLeave,
+ onSelect,
+}: {
+ x: number
+ y: number
+ color: string
+ label: string
+ selected: boolean
+ testId: string
+ onMouseEnter: () => void
+ onMouseLeave: () => void
+ onSelect: () => void
+}) {
+ const [focused, setFocused] = useState(false)
+ return (
+
+ {label}
+
+ {focused ? (
+
+ ) : null}
+ setFocused(true)}
+ onBlur={() => setFocused(false)}
+ onKeyDown={(event) => {
+ 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) => (
+ result.setValueField(field)}
+ className="rounded-md border border-gray-300 px-2 py-1 text-xs text-gray-700 hover:bg-gray-50"
+ >
+ {field === 'value' ? 'Value' : 'Normalized'}
+
+ ))}
+
+ )
+}
+
+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, index) => (
+
+
{row.label}
+ {row.value}
+
+ ))}
+
+
+ Clear selection
+
+
+ )
+}
+
+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 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..8c0f23c
--- /dev/null
+++ b/apps/web/src/lib/scientific/api.test.ts
@@ -0,0 +1,178 @@
+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 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)
+ })
+})
+
+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..62628d6
--- /dev/null
+++ b/apps/web/src/lib/scientific/api.ts
@@ -0,0 +1,170 @@
+/**
+ * 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,
+ )
+ }
+ 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(
+ `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..8990c20
--- /dev/null
+++ b/apps/web/src/lib/scientific/expression.test.ts
@@ -0,0 +1,295 @@
+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('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: '' })
+ 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..c14c2cb
--- /dev/null
+++ b/apps/web/src/lib/scientific/expression.ts
@@ -0,0 +1,297 @@
+/**
+ * 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).
+ * - 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) => {
+ 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) => {
+ 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) =>
+ compareText(left.sample, right.sample) ||
+ compareText(left.identifier, right.identifier),
+ )
+ return { ...series, points: points.map(clonePoint) }
+ })
+ .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 } } : {}),
+ }
+}
+
+/**
+ * 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 (code-unit 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) => compareText(left, 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..4570ac1
--- /dev/null
+++ b/apps/web/src/lib/scientific/scale.test.ts
@@ -0,0 +1,140 @@
+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')
+ })
+
+ 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
new file mode 100644
index 0000000..6224096
--- /dev/null
+++ b/apps/web/src/lib/scientific/scale.ts
@@ -0,0 +1,201 @@
+/**
+ * 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 []
+ // 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),
+ 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.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
new file mode 100644
index 0000000..b2d8905
--- /dev/null
+++ b/apps/web/src/lib/scientific/types.ts
@@ -0,0 +1,111 @@
+/**
+ * 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`.
+ *
+ * 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.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 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/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..90ce679
--- /dev/null
+++ b/apps/web/src/lib/scientific/useExpressionChart.test.tsx
@@ -0,0 +1,200 @@
+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 = {},
+}: {
+ 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_TUMOR_1_KEY)
+ await waitFor(() => expect(captured.model.selectedKey).toBe(TP53_TUMOR_1_KEY))
+
+ 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()
+ })
+
+ 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
new file mode 100644
index 0000000..b955181
--- /dev/null
+++ b/apps/web/src/lib/scientific/useExpressionChart.ts
@@ -0,0 +1,141 @@
+/**
+ * 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),
+ })
+
+ // 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])
+
+ 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 `` with a `` of typed fields;
+ the hover tooltip uses the same rows.
+- The summary is announced via `aria-live="polite"`; the value-field toggle is
+ a `` 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
+```