dicom-viewer source

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 &amp; 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 },
};