App.tsx
import { useEffect, useMemo, useState } from 'react';
import { Platform, useWindowDimensions } from 'react-native';
import * as DocumentPicker from 'expo-document-picker';
import { File } from 'expo-file-system';
import {
AppShell,
Badge,
Block,
Button,
Card,
Column,
PlocksProvider,
Row,
SegmentedControl,
Slider,
Text,
} from '@plocks/ui';
import { ScanViewport } from './components/ScanViewport';
import { parseDicom } from './dicom';
import { createFileStudy, createSampleStudy, type Study } from './study';
import { C, VIEWER_THEME } from './theme';
const PRESETS = {
original: { label: 'Original' },
soft: { label: 'Soft tissue', window: 400, level: 40 },
lung: { label: 'Lung', window: 1400, level: -500 },
bone: { label: 'Bone', window: 1800, level: 450 },
} as const;
type Preset = keyof typeof PRESETS | 'custom';
function SectionLabel({ children, value }: { children: string; value?: string }) {
return (
<Block align="center" justify="space-between" gap="sm" direction="row" flex={1}>
<Text c={C.muted} size={10} fw="bold" lts={1.5}>
{children.toUpperCase()}
</Text>
{!!value && (
<Text c={C.ink} size={12} fw="bold">
{value}
</Text>
)}
</Block>
);
}
function ViewerApp() {
const { width: screenWidth } = useWindowDimensions();
const compact = screenWidth < 800;
const scanSize = Math.max(245, Math.min(compact ? screenWidth - 76 : 500, 500));
const [study, setStudy] = useState<Study>(createSampleStudy);
const [index, setIndex] = useState(17);
const [preset, setPreset] = useState<Preset>('original');
const [customWindow, setCustomWindow] = useState(400);
const [customLevel, setCustomLevel] = useState(40);
const [zoom, setZoom] = useState(1);
const [invert, setInvert] = useState(false);
const [playing, setPlaying] = useState(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const active = study.frames[index];
const image = active.image;
const total = study.frames.length;
const presetSettings = preset === 'custom' ? null : PRESETS[preset];
const window =
preset === 'original'
? image.window
: presetSettings && 'window' in presetSettings
? presetSettings.window
: customWindow;
const level =
preset === 'original'
? image.level
: presetSettings && 'level' in presetSettings
? presetSettings.level
: customLevel;
const modality = image.modality || 'DICOM';
const spacing = image.pixelSpacing
?.split('\\')
.map((value) => Number(value).toFixed(2))
.join(' × ');
useEffect(() => {
if (!playing || total < 2) return;
const timer = setInterval(() => setIndex((current) => (current + 1) % total), 140);
return () => clearInterval(timer);
}, [playing, total]);
function loadStudy(next: Study) {
setPlaying(false);
setStudy(next);
setIndex(next.isSample ? Math.floor(next.frames.length / 2) : 0);
setPreset('original');
setCustomWindow(next.frames[0].image.window);
setCustomLevel(next.frames[0].image.level);
setZoom(1);
setInvert(false);
setError('');
}
async function openFiles() {
try {
setLoading(true);
setPlaying(false);
setError('');
const result = await DocumentPicker.getDocumentAsync({ type: '*/*', multiple: true, copyToCacheDirectory: true });
if (result.canceled) return;
if (result.assets.length > 120) throw new Error('Select 120 or fewer files at a time.');
const files = [];
let totalPixels = 0;
for (const asset of result.assets) {
const buffer =
Platform.OS === 'web' && asset.file
? await asset.file.arrayBuffer()
: await new File(asset.uri).arrayBuffer();
const image = parseDicom(buffer);
totalPixels += image.values.length;
if (totalPixels > 80000000) throw new Error('This image stack is too large for the example viewer.');
files.push({ name: asset.name, image });
}
loadStudy(createFileStudy(files));
} catch (cause) {
setError(cause instanceof Error ? cause.message : 'Unable to open these files.');
} finally {
setLoading(false);
}
}
function choosePreset(value: string) {
if (value === 'original' || value === 'soft' || value === 'lung' || value === 'bone') setPreset(value);
}
function changeWindow(value: number) {
setCustomWindow(value);
setCustomLevel(level);
setPreset('custom');
}
function changeLevel(value: number) {
setCustomWindow(window);
setCustomLevel(value);
setPreset('custom');
}
function step(delta: number) {
setPlaying(false);
setIndex((current) => Math.max(0, Math.min(total - 1, current + delta)));
}
const presetData = useMemo(() => Object.entries(PRESETS).map(([value, item]) => ({ value, label: item.label })), []);
return (
<AppShell
header={{ height: 72 }}
autoLayout
maxContentWidth={1260}
centerContent
padding={0}
headerContent={
<Block align="center" justify="space-between" direction="row" flex={1} px={22}>
<Row gap="sm" align="center">
<Block
w={32}
h={32}
radius="md"
bg={C.accentSoft}
align="center"
justify="center"
borderWidth={1}
borderColor={C.accent}
>
<Text c={C.accent} fw="bold" size={19}>
✳
</Text>
</Block>
<Column gap={0} fullWidth={false}>
<Text c={C.accent} size={9} fw="bold" lts={2}>
NORTHSTAR
</Text>
<Text c={C.ink} size={17} fw="bold">
IMAGING STUDIO
</Text>
</Column>
</Row>
<Badge color={C.accent} variant="light">
LOCAL VIEWER
</Badge>
</Block>
}
>
<AppShell.Section grow withScrollArea>
<Block gap="lg" direction="column" w="100%" p={compact ? 16 : 24} pb={44}>
<Row align="center" justify="space-between" wrap="wrap" gap="md">
<Block gap={4} fullWidth={false} direction="column" shrink={1}>
<Text c={C.accent} size={10} fw="bold" lts={2}>
STUDY WORKSPACE / 01
</Text>
<Text c={C.ink} size={compact ? 25 : 30} fw="bold">
DICOM Viewer
</Text>
<Text c={C.muted} size={13}>
Explore an image stack with clinical style viewing controls.
</Text>
</Block>
<Row gap="sm" wrap="wrap">
<Button
title="Open DICOM files"
onPress={openFiles}
loading={loading}
color={C.accent}
variant="filled"
/>
<Button
title="Sample study"
onPress={() => loadStudy(createSampleStudy())}
color={C.accent}
variant="outline"
/>
</Row>
</Row>
{!!error && (
<Card bg="#452B30" borderColor={C.error} padding="md">
<Text c={C.error} size={13}>
{error}
</Text>
</Card>
)}
<Block gap="lg" align="flex-start" wrap={compact ? 'wrap' : 'nowrap'} direction="row" w="100%">
<Block gap="md" direction="column" flex={1} miw={compact ? '100%' : 500}>
<Card bg={C.panel} borderColor={C.border} borderWidth={1} radius="lg" padding="md" w="100%">
<Row align="center" justify="space-between" wrap="wrap" gap="sm">
<Block gap={3} fullWidth={false} direction="column" shrink={1}>
<Text c={C.accent} size={10} fw="bold" lts={1.5}>
CURRENT SERIES
</Text>
<Text c={C.ink} size={18} fw="bold" numberOfLines={1}>
{study.name}
</Text>
</Block>
<Row gap="xs" align="center">
<Badge color={C.accent} variant="light">
{modality}
</Badge>
<Badge color={C.warning} variant="light">
{total} IMAGES
</Badge>
</Row>
</Row>
</Card>
<ScanViewport
image={image}
frame={active.frame}
index={index}
total={total}
window={window}
level={level}
zoom={zoom}
invert={invert}
size={scanSize}
isSample={study.isSample}
/>
<Card bg={C.panel} borderColor={C.border} borderWidth={1} radius="lg" padding="lg" w="100%">
<Column gap="md">
<SectionLabel value={`${index + 1} / ${total}`}>Slice position</SectionLabel>
<Slider
min={1}
max={total}
step={1}
value={index + 1}
onChange={(value) => {
setPlaying(false);
setIndex(value - 1);
}}
color={C.accent}
accessibilityLabel="Scrub through scan slices"
valueLabel={(value) => `Slice ${value} of ${total}`}
/>
<Row gap="sm" align="center" justify="space-between" wrap="wrap">
<Row gap="xs">
<Button
title="− Previous"
size="sm"
variant="outline"
color={C.accent}
disabled={index === 0}
onPress={() => step(-1)}
/>
<Button
title="Next +"
size="sm"
variant="outline"
color={C.accent}
disabled={index === total - 1}
onPress={() => step(1)}
/>
</Row>
<Button
title={playing ? 'Ⅱ Pause' : '▶ Cine'}
size="sm"
variant={playing ? 'filled' : 'light'}
color={C.accent}
disabled={total < 2}
onPress={() => setPlaying((current) => !current)}
/>
</Row>
</Column>
</Card>
</Block>
<Block gap="md" direction="column" w={compact ? '100%' : 344}>
<Card bg={C.panel} borderColor={C.border} borderWidth={1} radius="lg" padding="lg" w="100%">
<Column gap="lg">
<Column gap={3}>
<Text c={C.accent} size={10} fw="bold" lts={1.5}>
DISPLAY CONTROLS
</Text>
<Text c={C.ink} size={19} fw="bold">
Window & level
</Text>
</Column>
<SegmentedControl
data={presetData}
value={preset === 'custom' ? '' : preset}
onChange={choosePreset}
color={C.accent}
size="xs"
fullWidth
accessibilityLabel="Window presets"
/>
{preset === 'custom' && (
<Text c={C.accent} size={11}>
Custom adjustment
</Text>
)}
<Column gap="sm">
<SectionLabel value={`${Math.round(window)} HU`}>Window width</SectionLabel>
<Slider
min={1}
max={3000}
step={1}
value={window}
onChange={changeWindow}
color={C.accent}
accessibilityLabel="Window width"
valueLabel={(value) => `${Math.round(value)} HU`}
/>
</Column>
<Column gap="sm">
<SectionLabel value={`${Math.round(level)} HU`}>Window level</SectionLabel>
<Slider
min={-1200}
max={1800}
step={1}
value={level}
onChange={changeLevel}
color={C.accent}
accessibilityLabel="Window level"
valueLabel={(value) => `${Math.round(value)} HU`}
/>
</Column>
</Column>
</Card>
<Card bg={C.panel} borderColor={C.border} borderWidth={1} radius="lg" padding="lg" w="100%">
<Column gap="lg">
<Column gap={3}>
<Text c={C.accent} size={10} fw="bold" lts={1.5}>
VIEW OPTIONS
</Text>
<Text c={C.ink} size={19} fw="bold">
Image tools
</Text>
</Column>
<Column gap="sm">
<SectionLabel value={`${Math.round(zoom * 100)}%`}>Zoom</SectionLabel>
<Slider
min={0.75}
max={2}
step={0.05}
value={zoom}
onChange={setZoom}
color={C.accent}
accessibilityLabel="Image zoom"
valueLabel={(value) => `${Math.round(value * 100)}%`}
/>
</Column>
<Row gap="sm" wrap="wrap">
<Button
title={invert ? 'Restore polarity' : 'Invert image'}
size="sm"
variant="outline"
color={C.accent}
onPress={() => setInvert((current) => !current)}
/>
<Button
title="Reset view"
size="sm"
variant="outline"
color={C.accent}
onPress={() => {
setZoom(1);
setInvert(false);
setPreset('original');
}}
/>
</Row>
</Column>
</Card>
<Card bg={C.panel} borderColor={C.border} borderWidth={1} radius="lg" padding="lg" w="100%">
<Column gap="md">
<Column gap={3}>
<Text c={C.accent} size={10} fw="bold" lts={1.5}>
SERIES INFORMATION
</Text>
<Text c={C.ink} size={19} fw="bold">
Acquisition
</Text>
</Column>
<Row justify="space-between">
<Text c={C.muted} size={12}>
Matrix
</Text>
<Text c={C.ink} size={12}>
{image.width} × {image.height}
</Text>
</Row>
<Row justify="space-between">
<Text c={C.muted} size={12}>
Pixel spacing
</Text>
<Text c={C.ink} size={12}>
{spacing ? `${spacing} mm` : '—'}
</Text>
</Row>
<Row justify="space-between">
<Text c={C.muted} size={12}>
Slice thickness
</Text>
<Text c={C.ink} size={12}>
{image.sliceThickness ? `${image.sliceThickness} mm` : '—'}
</Text>
</Row>
<Row justify="space-between">
<Text c={C.muted} size={12}>
Instances
</Text>
<Text c={C.ink} size={12}>
{study.images.length}
</Text>
</Row>
</Column>
</Card>
</Block>
</Block>
<Block bg={C.accentSoft} radius="md" p={12} w="full">
<Text c={C.muted} size={11}>
Educational viewer. Synthetic sample contains no patient data. Local DICOM files stay on this device.
Supports uncompressed 8/16-bit monochrome little-endian images; not for clinical decisions.
</Text>
</Block>
</Block>
</AppShell.Section>
</AppShell>
);
}
export default function App() {
return (
<PlocksProvider theme={VIEWER_THEME}>
<ViewerApp />
</PlocksProvider>
);
}
components/ScanViewport.tsx
import { useMemo } from 'react';
import Svg, { Path } from 'react-native-svg';
import { Block, Column, Row, Text } from '@plocks/ui';
import { imagePaths, type DicomImage } from '../dicom';
import { C } from '../theme';
type Props = {
image: DicomImage; frame: number; index: number; total: number;
window: number; level: number; zoom: number; invert: boolean;
size: number; isSample: boolean;
};
export function ScanViewport({ image, frame, index, total, window, level, zoom, invert, size, isSample }: Props) {
const paths = useMemo(() => imagePaths(image, frame, window, level, invert), [image, frame, window, level, invert]);
return <Block bg={C.viewer} h={size + 64} radius="lg" borderWidth={1} borderColor={C.border}
position="relative" align="center" justify="center" overflow="hidden" w="full"
accessibilityLabel={`Axial scan image, slice ${index + 1} of ${total}`}>
<Block position="absolute" top={15} left={16}>
<Text c={C.accent} size={10} fw="bold" lts={1.5}>AXIAL / {image.modality || 'DICOM'}</Text>
</Block>
<Block position="absolute" top={15} right={16}>
<Text c={C.muted} size={10} fw="bold" lts={1}>IM {String(index + 1).padStart(3, '0')} / {String(total).padStart(3, '0')}</Text>
</Block>
<Block w={size * zoom} h={size * zoom} align="center" justify="center">
<Svg width="100%" height="100%" viewBox="0 0 128 128" preserveAspectRatio="xMidYMid meet" accessibilityLabel="Rendered DICOM pixels">
{paths.map(({ color, path }) => <Path key={color} d={path} fill={color} />)}
</Svg>
</Block>
<Block position="absolute" left={16} top="50%"><Text c={C.accent} size={13} fw="bold">R</Text></Block>
<Block position="absolute" right={16} top="50%"><Text c={C.accent} size={13} fw="bold">L</Text></Block>
<Block position="absolute" bottom={14} left={16}>
<Column gap={2} fullWidth={false}>
<Text c={C.muted} size={10} fw="bold" lts={1}>W {Math.round(window)} L {Math.round(level)}</Text>
<Text c={C.dim} size={9}>{isSample ? 'SYNTHETIC IMAGE · NO PATIENT DATA' : `${image.width} × ${image.height} PX · LOCAL FILE`}</Text>
</Column>
</Block>
<Block position="absolute" bottom={14} right={16}>
<Row gap={5} align="center"><Block w={5} h={5} radius="full" bg={C.accent} /><Text c={C.muted} size={10}>{Math.round(zoom * 100)}%</Text></Row>
</Block>
</Block>;
}
dicom.ts
/** Minimal local decoder for uncompressed, monochrome, little-endian DICOM images. */
export type DicomImage = {
width: number; height: number; frames: number; values: Float32Array;
window: number; level: number; inverted: boolean;
modality?: string; seriesDescription?: string; instanceNumber?: number;
seriesInstanceUid?: string; pixelSpacing?: string; sliceThickness?: string;
};
export function parseDicom(buffer: ArrayBuffer): DicomImage {
const bytes = new Uint8Array(buffer);
const view = new DataView(buffer);
const ascii = (start: number, length: number) => String.fromCharCode(...bytes.subarray(start, start + length)).replace(/\0/g, '').trim();
let position = ascii(128, 4) === 'DICM' ? 132 : 0;
let syntax = '1.2.840.10008.1.2.1';
let explicit = true;
let width = 0, height = 0, bits = 0, representation = 0, samples = 1, frames = 1;
let slope = 1, intercept = 0, window = 0, level = 0, photo = 'MONOCHROME2';
let modality: string | undefined, seriesDescription: string | undefined;
let instanceNumber: number | undefined, seriesInstanceUid: string | undefined;
let pixelSpacing: string | undefined, sliceThickness: string | undefined;
let pixelOffset = -1, pixelLength = 0;
const longVR = new Set(['OB', 'OD', 'OF', 'OL', 'OV', 'OW', 'SQ', 'UC', 'UN', 'UR', 'UT', 'UV', 'SV']);
const skipUndefined = (start: number, closingElement: number, depth = 0): number => {
if (depth > 16) throw new Error('The DICOM sequence nesting is too deep.');
let cursor = start;
while (cursor + 8 <= bytes.length) {
const group = view.getUint16(cursor, true), element = view.getUint16(cursor + 2, true);
if (group === 0xfffe) {
const length = view.getUint32(cursor + 4, true);
if (element === closingElement) return cursor + 8;
if (element === 0xe000) {
cursor = length === 0xffffffff ? skipUndefined(cursor + 8, 0xe00d, depth + 1) : cursor + 8 + length;
continue;
}
throw new Error('The DICOM sequence delimiters are invalid.');
}
const vr = explicit ? ascii(cursor + 4, 2) : '';
const long = explicit && longVR.has(vr);
const header = explicit ? long ? 12 : 8 : 8;
if (cursor + header > bytes.length) break;
const length = explicit ? long ? view.getUint32(cursor + 8, true) : view.getUint16(cursor + 6, true) : view.getUint32(cursor + 4, true);
cursor = length === 0xffffffff ? skipUndefined(cursor + header, 0xe0dd, depth + 1) : cursor + header + length;
}
throw new Error('The DICOM sequence is incomplete.');
};
let steps = 0;
while (position + 8 <= bytes.length && steps++ < 100000) {
const group = view.getUint16(position, true), element = view.getUint16(position + 2, true);
if (group !== 2) explicit = syntax !== '1.2.840.10008.1.2';
const vr = explicit ? ascii(position + 4, 2) : '';
const long = explicit && longVR.has(vr);
const header = explicit ? long ? 12 : 8 : 8;
if (position + header > bytes.length) break;
const length = explicit ? long ? view.getUint32(position + 8, true) : view.getUint16(position + 6, true) : view.getUint32(position + 4, true);
const start = position + header;
const tag = `${group.toString(16).padStart(4, '0')}${element.toString(16).padStart(4, '0')}`;
if (length === 0xffffffff) {
if (tag === '7fe00010') throw new Error('Compressed or encapsulated pixel data is not supported.');
position = skipUndefined(start, 0xe0dd);
continue;
}
if (start + length > bytes.length) throw new Error('The DICOM file is incomplete.');
const text = () => ascii(start, length);
const uint = () => length >= 2 ? view.getUint16(start, true) : 0;
switch (tag) {
case '00020010': syntax = text(); break;
case '00080060': modality = text(); break;
case '0008103e': seriesDescription = text(); break;
case '00180050': sliceThickness = text(); break;
case '0020000e': seriesInstanceUid = text(); break;
case '00200013': instanceNumber = Number(text()) || undefined; break;
case '00280002': samples = uint(); break;
case '00280004': photo = text(); break;
case '00280030': pixelSpacing = text(); break;
case '00280008': frames = Math.max(1, Number(text()) || 1); break;
case '00280010': height = uint(); break;
case '00280011': width = uint(); break;
case '00280100': bits = uint(); break;
case '00280103': representation = uint(); break;
case '00281052': intercept = Number(text()) || 0; break;
case '00281053': slope = Number(text()) || 1; break;
case '00281050': level = Number(text().split('\\')[0]) || 0; break;
case '00281051': window = Number(text().split('\\')[0]) || 0; break;
case '7fe00010': pixelOffset = start; pixelLength = length; break;
}
position = start + length;
if (pixelOffset >= 0) break;
}
if (!['1.2.840.10008.1.2', '1.2.840.10008.1.2.1'].includes(syntax)) throw new Error('Only uncompressed little-endian DICOM is supported.');
if (!width || !height || pixelOffset < 0 || ![8, 16].includes(bits) || samples !== 1 || !['MONOCHROME1', 'MONOCHROME2'].includes(photo))
throw new Error('This demo supports only 8- or 16-bit grayscale DICOM images.');
const count = width * height * frames;
if (count > 40000000 || count * (bits / 8) > pixelLength) throw new Error('The image dimensions or pixel data are invalid.');
const values = new Float32Array(count);
for (let i = 0; i < count; i++) {
const offset = pixelOffset + i * (bits / 8);
const raw = bits === 8 ? representation ? view.getInt8(offset) : view.getUint8(offset)
: representation ? view.getInt16(offset, true) : view.getUint16(offset, true);
values[i] = raw * slope + intercept;
}
if (!window) {
let min = Infinity, max = -Infinity;
for (const value of values) { if (value < min) min = value; if (value > max) max = value; }
window = Math.max(1, max - min); level = (max + min) / 2;
}
return { width, height, frames, values, window, level, inverted: photo === 'MONOCHROME1',
modality, seriesDescription, instanceNumber, seriesInstanceUid, pixelSpacing, sliceThickness };
}
/** Quantize a frame into 64 SVG paths rather than thousands of individual rectangles. */
export function imagePaths(image: DicomImage, frame: number, window: number, level: number, invert = false, side = 128): { color: string; path: string }[] {
const paths = Array.from({ length: 64 }, () => [] as string[]);
const offset = frame * image.width * image.height;
const low = level - Math.max(1, window) / 2;
for (let y = 0; y < side; y++) for (let x = 0; x < side; x++) {
const sourceX = Math.min(image.width - 1, Math.floor((x + .5) * image.width / side));
const sourceY = Math.min(image.height - 1, Math.floor((y + .5) * image.height / side));
const value = image.values[offset + sourceY * image.width + sourceX];
const normalized = Math.max(0, Math.min(1, (value - low) / Math.max(1, window)));
const shade = Math.round(((image.inverted !== invert) ? 1 - normalized : normalized) * 63);
paths[shade].push(`M${x} ${y}h1v1h-1z`);
}
return paths.map((parts, shade) => {
const grey = Math.round(shade * 255 / 63).toString(16).padStart(2, '0');
return { color: `#${grey}${grey}${grey}`, path: parts.join('') };
}).filter(item => item.path.length > 0);
}
export function imageCells(image: DicomImage, frame: number, window: number, level: number, side = 64): string[] {
const cells: string[] = [];
const offset = frame * image.width * image.height;
for (let y = 0; y < side; y++) for (let x = 0; x < side; x++) {
const sourceX = Math.min(image.width - 1, Math.floor((x + .5) * image.width / side));
const sourceY = Math.min(image.height - 1, Math.floor((y + .5) * image.height / side));
const value = image.values[offset + sourceY * image.width + sourceX];
const normalized = Math.max(0, Math.min(1, (value - (level - window / 2)) / window));
const grey = Math.round((image.inverted ? 1 - normalized : normalized) * 255);
cells.push(`#${grey.toString(16).padStart(2, '0').repeat(3)}`);
}
return cells;
}
index.ts
import { registerRootComponent } from 'expo';
import App from './App';
registerRootComponent(App);
study.ts
import type { DicomImage } from './dicom';
export type Study = {
name: string;
images: DicomImage[];
frames: { image: DicomImage; frame: number }[];
isSample: boolean;
};
const SIZE = 160;
const SLICES = 36;
const ellipse = (x: number, y: number, cx: number, cy: number, rx: number, ry: number) =>
((x - cx) / rx) ** 2 + ((y - cy) / ry) ** 2;
const soft = (v: number, edge = 0.035) => Math.max(0, Math.min(1, (1 - v) / edge));
/** A deterministic, purely synthetic chest CT-style volume for exploring controls. */
export function createSampleStudy(): Study {
const values = new Float32Array(SIZE * SIZE * SLICES);
for (let z = 0; z < SLICES; z++) {
const phase = (z - (SLICES - 1) / 2) / SLICES;
const bodyWidth = 0.8 - Math.abs(phase) * 0.17;
const lungSize = 1 - Math.abs(phase) * 0.35;
for (let py = 0; py < SIZE; py++) for (let px = 0; px < SIZE; px++) {
const x = (px + .5 - SIZE / 2) / (SIZE / 2);
const y = (py + .5 - SIZE / 2) / (SIZE / 2);
const grain = (Math.sin(px * 47.71 + py * 13.37 + z * 7.13) * Math.sin(py * 31.19 - px * 17.23)) * 13;
const body = ellipse(x, y, 0, .035, bodyWidth, .68);
let hu = -1000;
if (body < 1) {
hu = 38 + grain;
if (body > .88) hu = -95 + grain * 2; // subcutaneous rim
const leftLung = ellipse(x, y, -.32, -.05 + phase * .08, .235 * lungSize, .37 * lungSize);
const rightLung = ellipse(x, y, .32, -.05 + phase * .08, .235 * lungSize, .37 * lungSize);
if (leftLung < 1 || rightLung < 1) {
const parenchyma = Math.sin(px * .48 + z * .19) * Math.cos(py * .51 - z * .11) * 40;
hu = -780 + grain * 5 + parenchyma;
for (const side of [-1, 1]) {
const inside = side < 0 ? leftLung < 1 : rightLung < 1;
if (!inside) continue;
const hilumX = side * (.17 + .07 * Math.cos(y * 3 + phase));
if (Math.abs(x - hilumX) < .018 && y > -.23 && y < .2) hu = -140 + grain;
for (let branch = 0; branch < 4; branch++) {
const originY = -.25 + branch * .135 + phase * .03;
const offsetY = y - originY;
const branchX = side * (.18 + (branch % 2 ? .32 : .43) * Math.abs(offsetY));
if (offsetY > -.03 && offsetY < .22 && Math.abs(x - branchX) < .008 + offsetY * .014)
hu = -300 + grain * 2;
}
}
}
const heart = ellipse(x, y, .075 + phase * .11, .19, .21, .21);
if (heart < 1) hu = 65 + grain;
const spine = ellipse(x, y, 0, .48, .105, .115);
if (spine < 1) hu = 800 * soft(spine, .25) + 90;
const canal = ellipse(x, y, 0, .43, .037, .038);
if (canal < 1) hu = 45;
const aorta = ellipse(x, y, -.105, .31 + phase * .04, .045, .05);
if (aorta < 1) hu = 135;
for (const side of [-1, 1]) {
const ribBand = Math.abs(ellipse(x, y, side * .31, .02, .43, .52) - .96);
if (ribBand < .045 && body < .88 && ((Math.atan2(y, x * side) + 4) * 5 + z * .13) % 2.5 < 1.65) hu = 650 + grain * 2;
const vessel = ellipse(x, y, side * (.30 + phase * .035), -.02, .013, .17);
if (vessel < 1 && (leftLung < 1 || rightLung < 1)) hu = -180;
}
}
values[z * SIZE * SIZE + py * SIZE + px] = hu;
}
}
const image: DicomImage = { width: SIZE, height: SIZE, frames: SLICES, values,
window: 400, level: 40, inverted: false, modality: 'CT',
seriesDescription: 'Synthetic chest · axial', pixelSpacing: '0.80\\0.80', sliceThickness: '2.5' };
return { name: 'CHEST · SYNTHETIC STUDY', images: [image],
frames: Array.from({ length: SLICES }, (_, frame) => ({ image, frame })), isSample: true };
}
/** Order individual DICOM instances and flatten any multi-frame images into one stack. */
export function createFileStudy(files: { name: string; image: DicomImage }[]): Study {
if (!files.length) throw new Error('Choose at least one DICOM file.');
const sorted = [...files].sort((a, b) =>
(a.image.instanceNumber ?? Number.MAX_SAFE_INTEGER) - (b.image.instanceNumber ?? Number.MAX_SAFE_INTEGER)
|| a.name.localeCompare(b.name, undefined, { numeric: true }));
const first = sorted[0].image;
if (sorted.some(({ image }) => image.width !== first.width || image.height !== first.height ||
(first.seriesInstanceUid && image.seriesInstanceUid && image.seriesInstanceUid !== first.seriesInstanceUid)))
throw new Error('Selected files must share the same image dimensions and DICOM series.');
return { name: files.length === 1 ? files[0].name : (first.seriesDescription || `${files.length} DICOM instances`),
images: sorted.map(item => item.image),
frames: sorted.flatMap(({ image }) => Array.from({ length: image.frames }, (_, frame) => ({ image, frame }))),
isSample: false };
}
theme.ts
export const C = {
page: '#09131B', panel: '#10222D', panelRaised: '#162C38', viewer: '#02070B',
border: '#26414D', ink: '#ECF5F7', muted: '#91AAB5', dim: '#66838F',
accent: '#6EE4DB', accentSoft: '#183F43', warning: '#EDBB77', error: '#FFAA9F',
} as const;
export const VIEWER_THEME = {
colorScheme: 'dark' as const,
primaryColor: C.accent,
backgrounds: { base: C.page, subtle: C.panel, surface: C.panel,
elevated: C.panelRaised, border: C.border, borderStrong: C.dim },
};