Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
77 changes: 57 additions & 20 deletions apps/web/src/components/network/NetworkViewer.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
'use client'

import { useCallback, useMemo, useRef } from 'react'
import { memo, useCallback, useMemo, useRef } from 'react'

import { VisualizationContainer } from '@/components/visualization/VisualizationContainer'
import {
Expand All @@ -24,7 +24,7 @@ import {
} from '@/lib/network/labels'
import { NODE_RADIUS } from '@/lib/network/layout'
import { availableEdgeTypes, availableNodeTypes, edgeById, nodeById } from '@/lib/network/model'
import type { GraphEdge, GraphNode } from '@/lib/network/types'
import type { Graph, GraphEdge, GraphLayout, GraphNode, NetworkViewport } from '@/lib/network/types'
import type { NetworkViewerResult } from '@/lib/network/useNetworkViewer'

function NetworkSummary({ result }: { result: NetworkViewerResult }) {
Expand Down Expand Up @@ -138,11 +138,22 @@ function NetworkControls({ result }: { result: NetworkViewerResult }) {
)
}

function EdgeElement({ edge, result }: { edge: GraphEdge; result: NetworkViewerResult }) {
const graph = result.graph
if (graph === undefined) return null
const points = edgeScreenPoints(edge, result.layout, result.viewport)
const selected = edge.id === result.selectedEdgeId
const EdgeElement = memo(function EdgeElement({
edge,
graph,
layout,
viewport,
selected,
onSelect,
}: {
edge: GraphEdge
graph: Graph
layout: GraphLayout
viewport: NetworkViewport
selected: boolean
onSelect: (edgeId: string | null) => void
}) {
const points = edgeScreenPoints(edge, layout, viewport)
const label = edgeAccessibleLabel(edge, graph)
const color = edgeTypeColor(edge.type)
return (
Expand Down Expand Up @@ -177,22 +188,33 @@ function EdgeElement({ edge, result }: { edge: GraphEdge; result: NetworkViewerR
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
result.selectEdge(selected ? null : edge.id)
onSelect(selected ? null : edge.id)
}
}}
onClick={() => result.selectEdge(selected ? null : edge.id)}
onClick={() => onSelect(selected ? null : edge.id)}
/>
</g>
)
}
})

function NodeElement({ node, result }: { node: GraphNode; result: NetworkViewerResult }) {
const position = result.layout.positions.get(node.id)
const NodeElement = memo(function NodeElement({
node,
layout,
viewport,
selected,
onSelect,
}: {
node: GraphNode
layout: GraphLayout
viewport: NetworkViewport
selected: boolean
onSelect: (nodeId: string | null) => void
}) {
const position = layout.positions.get(node.id)
if (position === undefined) return null
const selected = node.id === result.selectedNodeId
const label = nodeAccessibleLabel(node)
const box = nodeScreenBox(position, result.viewport)
const scale = result.viewport.scale
const box = nodeScreenBox(position, viewport)
const scale = viewport.scale
const centerX = box.x + box.width / 2
const centerY = box.y + NODE_RADIUS * scale
return (
Expand Down Expand Up @@ -228,14 +250,14 @@ function NodeElement({ node, result }: { node: GraphNode; result: NetworkViewerR
onKeyDown={(event) => {
if (event.key === 'Enter' || event.key === ' ') {
event.preventDefault()
result.selectNode(selected ? null : node.id)
onSelect(selected ? null : node.id)
}
}}
onClick={() => result.selectNode(selected ? null : node.id)}
onClick={() => onSelect(selected ? null : node.id)}
/>
</g>
)
}
})

function NetworkGraph({ result }: { result: NetworkViewerResult }) {
const graph = result.graph
Expand Down Expand Up @@ -305,10 +327,25 @@ function NetworkGraph({ result }: { result: NetworkViewerResult }) {
{hasNodes ? (
<g>
{edges.map((edge) => (
<EdgeElement key={edge.id} edge={edge} result={result} />
<EdgeElement
key={edge.id}
edge={edge}
graph={graph}
layout={result.layout}
viewport={result.viewport}
selected={result.selectedEdgeId === edge.id}
onSelect={result.selectEdge}
/>
))}
{nodes.map((node) => (
<NodeElement key={node.id} node={node} result={result} />
<NodeElement
key={node.id}
node={node}
layout={result.layout}
viewport={result.viewport}
selected={result.selectedNodeId === node.id}
onSelect={result.selectNode}
/>
))}
</g>
) : (
Expand Down
32 changes: 25 additions & 7 deletions apps/web/src/components/scientific/CoverageChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import type { AxisTick } from '@/lib/genome/geometry'
import { createScale } from '@/lib/genome/geometry'
import type { CoverageBin } from '@/lib/scientific/advancedTypes'
import { coverageBinTooltip } from '@/lib/scientific/coverage'
import { coverageColumns } from '@/lib/scientific/downsample'
import {
DEFAULT_CHART_HEIGHT,
DEFAULT_CHART_MARGINS,
Expand All @@ -26,6 +27,7 @@ const AXIS_COLOR = '#cbd5e1'
const GRID_COLOR = '#e2e8f0'
const COVERAGE_COLOR = '#2563eb'
const CAPTION_COLOR = '#94a3b8'
const MAX_COVERAGE_COLUMNS = 2000

export interface CoverageChartProps {
/** View model produced by `useCoverageChart`. */
Expand Down Expand Up @@ -285,14 +287,30 @@ function CoverageBins({
() => createScale(viewport.start, viewport.end, plot.width),
[viewport, plot],
)
if (dataset === undefined || chromosome === '') return null
const bins = dataset.bins.filter((bin) => bin.chromosome === chromosome)
const bins = useMemo(
() =>
dataset === undefined ? [] : dataset.bins.filter((bin) => bin.chromosome === chromosome),
[dataset, chromosome],
)
// When a chromosome has far more bins than pixels, aggregate to per-pixel
// peak-preserving columns so both the path and the interactive targets stay
// bounded (see docs/visualization/performance.md). Under the cap, bins are
// rendered exactly as provided.
const columns = useMemo(
() => coverageColumns(bins, (base) => scale.toX(base), plot.width, MAX_COVERAGE_COLUMNS),
[bins, scale, plot.width],
)
const points = useMemo(
() =>
columns.map((bin) => {
const x0 = scale.toX(bin.start)
const x1 = scale.toX(bin.end + 1)
return { bin, x0, x1, y: yScale.toPixel(bin.coverage) }
}),
[columns, scale, yScale],
)

const points = bins.map((bin) => {
const x0 = scale.toX(bin.start)
const x1 = scale.toX(bin.end + 1)
return { bin, x0, x1, y: yScale.toPixel(bin.coverage) }
})
if (dataset === undefined || chromosome === '') return null

const areaPath = buildAreaPath(plot, points)
const linePath = buildLinePath(points)
Expand Down
19 changes: 15 additions & 4 deletions apps/web/src/components/scientific/DistributionChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import { useMemo, useState } from 'react'
import { VisualizationContainer } from '@/components/visualization/VisualizationContainer'
import type { DistributionDataset } from '@/lib/scientific/advancedTypes'
import { distributionTooltip, valuesForGroup } from '@/lib/scientific/distribution'
import { decimateItems } from '@/lib/scientific/downsample'
import {
DEFAULT_CHART_HEIGHT,
DEFAULT_CHART_MARGINS,
Expand All @@ -28,6 +29,7 @@ const MEDIAN_COLOR = '#0f172a'
const OUTLIER_COLOR = '#dc2626'
const JITTER_RADIUS = 2.5
const HIT_RADIUS = 12
const MAX_SCATTER_POINTS_PER_GROUP = 1000

export interface DistributionChartProps {
/** View model produced by `useDistributionChart`. */
Expand Down Expand Up @@ -212,7 +214,16 @@ function GroupBox({
if (slot === undefined) return null

const [focused, setFocused] = useState(false)
const values = useMemo(() => valuesForGroup(dataset, entry.group), [dataset, entry.group])
const scatterValues = useMemo(() => {
const all = valuesForGroup(dataset, entry.group)
if (all.length <= MAX_SCATTER_POINTS_PER_GROUP) return all
const isOutlier = (value: number) =>
whiskers !== undefined && (value < whiskers.lower || value > whiskers.upper)
const outliers = all.filter(isOutlier)
const rest = all.filter((value) => !isOutlier(value))
const sampled = decimateItems(rest, Math.max(0, MAX_SCATTER_POINTS_PER_GROUP - outliers.length))
return [...sampled, ...outliers]
}, [dataset, entry.group, whiskers])

if (summary === undefined) return null

Expand All @@ -227,13 +238,13 @@ function GroupBox({

const jitteredValues = useMemo(() => {
const spread = Math.max(0, boxWidth - 14)
const count = Math.max(1, values.length)
return values.map((value, index) => {
const count = Math.max(1, scatterValues.length)
return scatterValues.map((value, index) => {
const offset = count === 1 ? 0 : (index / (count - 1) - 0.5) * spread
const isOutlier = whiskers !== undefined && (value < whiskers.lower || value > whiskers.upper)
return { x: centerX + offset, y: yScale.toPixel(value), outlier: isOutlier, value }
})
}, [values, whiskers, centerX, boxWidth, yScale])
}, [scatterValues, whiskers, centerX, boxWidth, yScale])

return (
<g
Expand Down
35 changes: 27 additions & 8 deletions apps/web/src/components/scientific/ExpressionChart.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
import { useId, useMemo, useState } from 'react'

import { VisualizationContainer } from '@/components/visualization/VisualizationContainer'
import { decimateItems } from '@/lib/scientific/downsample'
import {
DEFAULT_CHART_HEIGHT,
DEFAULT_CHART_MARGINS,
Expand All @@ -28,6 +29,7 @@ import { ChartTooltip } from './ChartTooltip'

const POINT_RADIUS = 4
const HIT_RADIUS = 11
const MAX_SERIES_POINTS = 1000

function SeriesPoint({
x,
Expand Down Expand Up @@ -97,21 +99,21 @@ function SeriesPoint({
}

function SeriesLines({
dataset,
series,
xScale,
yScale,
valueField,
}: {
dataset: ExpressionDataset
series: ExpressionSeries[]
xScale: ReturnType<typeof createCategoryScale>
yScale: ReturnType<typeof createContinuousScale>
valueField: 'value' | 'normalizedValue'
}) {
return (
<g data-testid="chart-series-lines">
{dataset.series.map((series, seriesIndex) => {
{series.map((item, seriesIndex) => {
const color = seriesColor(seriesIndex)
const positions = series.points
const positions = item.points
.map((point) => {
const value = point[valueField]
if (value === undefined || !Number.isFinite(value)) return null
Expand All @@ -122,13 +124,13 @@ function SeriesLines({
const pointsAttribute = positions.map((position) => `${position.x},${position.y}`).join(' ')
return (
<polyline
key={series.id}
key={item.id}
points={pointsAttribute}
fill="none"
stroke={color}
strokeWidth={1.5}
opacity={0.7}
data-testid={`series-line-${series.id}`}
data-testid={`series-line-${item.id}`}
/>
)
})}
Expand Down Expand Up @@ -237,6 +239,18 @@ export function ExpressionChart({
const dataset = result.dataset
const field = result.valueField

// Bound the rendered points for very large datasets: the original series
// stay the source of truth for tooltips/selection; only the drawn marks are
// decimated (see docs/visualization/performance.md).
const renderedSeries = useMemo(
() =>
dataset?.series.map((series) => ({
...series,
points: [...decimateItems(series.points, MAX_SERIES_POINTS)],
})) ?? [],
[dataset],
)

const xScale = useMemo(
() => createCategoryScale(result.samples, [plot.x0, plot.x0 + plot.width]),
[result.samples, plot],
Expand Down Expand Up @@ -320,9 +334,14 @@ export function ExpressionChart({
yLabel={field === 'value' ? 'Expression value' : 'Normalized value'}
formatValue={formatTickValue}
/>
<SeriesLines dataset={dataset} xScale={xScale} yScale={yScale} valueField={field} />
<SeriesLines
series={renderedSeries}
xScale={xScale}
yScale={yScale}
valueField={field}
/>
<g data-testid="chart-points">
{dataset.series.map((series, seriesIndex) => {
{renderedSeries.map((series, seriesIndex) => {
const color = seriesColor(seriesIndex)
return series.points.map((point) => {
const value = point[field]
Expand Down
Loading
Loading