Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
82 commits
Select commit Hold shift + click to select a range
db1f415
feat(mri-scanner): add the ids, sequence model and layout
veelenga Oct 6, 2026
d080ed9
feat(mri-scanner): add the assembly contract and placeholder
veelenga Oct 6, 2026
13a0929
feat(mri-scanner): add the timeline, camera views, part info and page…
veelenga Oct 6, 2026
dad0615
feat(mri-scanner): add the signal, spin, gradient, phantom and k-spac…
veelenga Oct 6, 2026
481bcad
feat(mri-scanner): add the store, presets and derived assembly state
veelenga Oct 6, 2026
251afdf
feat(mri-scanner): add the dock, readouts, chapter actions and k-spac…
veelenga Oct 6, 2026
094b738
feat(mri-scanner): add the scene controller, bindings and page entry
veelenga Oct 6, 2026
352ba1a
fix(mri-scanner): keep the spins Larmor readout out of the gauge
veelenga Oct 6, 2026
ff71919
fix(mri-scanner): frame the cryostat, coil and console views on a phone
veelenga Oct 6, 2026
53d42c8
build(vite): keep single-explainer packages out of the shared chunk
veelenga Oct 6, 2026
8c9ccda
feat(mri-scanner): add the English copy
veelenga Oct 6, 2026
44b7b6b
fix(mri-scanner): place the pacemaker risk inside the 0.5 mT line
veelenga Oct 6, 2026
fa30e6f
feat(mri-scanner): add the Chinese copy
veelenga Oct 6, 2026
188485a
feat(mri-scanner): add the Portuguese copy
veelenga Oct 6, 2026
54d94d4
feat(mri-scanner): add the Japanese copy
veelenga Oct 6, 2026
26cdfa5
fix(mri-scanner): match the page default to the pacemaker wording
veelenga Oct 6, 2026
2440873
fix(mri-scanner): call the spins tiny magnets in the echo chapter
veelenga Oct 6, 2026
16e2342
feat(mri-scanner): add the Spanish copy
veelenga Oct 6, 2026
a8b5e58
feat(mri-scanner): add the French copy
veelenga Oct 6, 2026
dc369ed
feat(mri-scanner): add the German copy
veelenga Oct 6, 2026
8d68697
feat(mri-scanner): add the Ukrainian copy
veelenga Oct 6, 2026
c027c11
fix(mri-scanner): take the slowdown factor formatted for each language
veelenga Oct 6, 2026
68ee06c
fix(mri-scanner): show the step count beside the scrubber's step name
veelenga Oct 6, 2026
e33ec20
fix(mri-scanner): count the step on the scrubber instead of repeating…
veelenga Oct 6, 2026
a76b588
feat(mri-scanner): describe the link preview image
veelenga Oct 6, 2026
5abfd17
feat(mri-scanner): add text alternatives for the k-space and picture …
veelenga Oct 6, 2026
04456c3
fix(mri-scanner): give the k-space and picture canvases a text altern…
veelenga Oct 6, 2026
dd33535
fix(mri-scanner): turn the along-field part over with the refocusing …
veelenga Oct 6, 2026
c8c043c
fix(mri-scanner): pause playback when the reader picks a line count
veelenga Oct 6, 2026
4a9895c
fix(mri-scanner): follow the speed slider in the precession slowdown
veelenga Oct 6, 2026
c370ba4
fix(mri-scanner): compute the spin arrows only while the voxel view i…
veelenga Oct 6, 2026
f8e3b48
fix(vite): read the explainer slug relative to the project root
veelenga Oct 6, 2026
a9347c0
test(vite): cover absolute posix and Windows ids in the chunk rule
veelenga Oct 6, 2026
176d1fa
fix(mri-scanner): drop the readouts spacing override in the tip widget
veelenga Oct 6, 2026
e850710
fix(mri-scanner): drop the unused playback flag from the line counter
veelenga Oct 6, 2026
5d8d7dd
fix(mri-scanner): drop the duplicate chapter control settings from th…
veelenga Oct 6, 2026
4fd106c
fix(mri-scanner): import the model constants from the model directly
veelenga Oct 6, 2026
cc65146
fix(mri-scanner): clarify the English copy after review
veelenga Oct 6, 2026
1904045
fix(mri-scanner): apply the review to the Portuguese copy
veelenga Oct 6, 2026
7116216
fix(mri-scanner): apply the review to the German copy
veelenga Oct 6, 2026
5a1cb5a
fix(mri-scanner): name the pacemaker zone in the magnet chapter
veelenga Oct 6, 2026
1b0a303
fix(mri-scanner): apply the review to the Spanish copy
veelenga Oct 6, 2026
ccb21a0
fix(mri-scanner): apply the review to the French copy
veelenga Oct 6, 2026
9896586
fix(mri-scanner): apply the review to the Ukrainian copy
veelenga Oct 6, 2026
eb17fd1
fix(mri-scanner): apply the review to the Japanese copy
veelenga Oct 6, 2026
a1116e6
fix(mri-scanner): apply the review to the Chinese copy
veelenga Oct 6, 2026
c16d108
feat(mri-scanner): add the scene constants, finishes and sweep geometry
veelenga Oct 6, 2026
bfed792
feat(mri-scanner): add the magnet with its cutaway slice, gradient gl…
veelenga Oct 6, 2026
26f1411
feat(mri-scanner): add the shielded room, control window and monitor
veelenga Oct 6, 2026
c2228bd
feat(mri-scanner): add the patient, table and head coil
veelenga Oct 6, 2026
6d01f66
feat(mri-scanner): add the field lines, voxel, radio and echo effects
veelenga Oct 6, 2026
704375a
feat(mri-scanner): add the scene lighting, regions and options
veelenga Oct 6, 2026
e7ff5c8
feat(mri-scanner): add the mri scanner assembly
veelenga Oct 6, 2026
aceebb9
test(mri-scanner): name the pulse steps as the copy does
veelenga Oct 6, 2026
5ce544f
feat(mri-scanner): mount the real scanner scene
veelenga Oct 6, 2026
549ba86
feat(mri-scanner): place the picture screen on the real monitor
veelenga Oct 6, 2026
8a62540
fix(mri-scanner): dim the gradient glow with the rest of the scene
veelenga Oct 6, 2026
97ffb22
fix(mri-scanner): sit the cover trim band flush at the cutaway
veelenga Oct 6, 2026
0caa431
fix(mri-scanner): hold the radio and echo rings still while paused
veelenga Oct 6, 2026
f6ff57e
fix(mri-scanner): frame the chapter views on the real scene
veelenga Oct 6, 2026
5c3c5a4
fix(mri-scanner): let labels see through the room shell
veelenga Oct 6, 2026
d22ab5c
fix(mri-scanner): place the bore and patient labels where they show
veelenga Oct 6, 2026
cc993c3
fix(mri-scanner): label the picture beside the screen and the room on…
veelenga Oct 6, 2026
992f09f
fix(mri-scanner): frame the room closer and pull back the cryostat view
veelenga Oct 6, 2026
49c73db
feat(mri-scanner): add the cover and social images
veelenga Oct 6, 2026
1fe33b1
fix(mri-scanner): polish the English copy
veelenga Oct 6, 2026
0502c67
fix(mri-scanner): polish the Chinese copy
veelenga Oct 6, 2026
68381c6
fix(mri-scanner): polish the Portuguese copy
veelenga Oct 6, 2026
db13586
fix(mri-scanner): polish the Japanese copy
veelenga Oct 6, 2026
0a72627
fix(mri-scanner): polish the German copy
veelenga Oct 6, 2026
26fb45c
fix(mri-scanner): polish the Ukrainian copy
veelenga Oct 6, 2026
b24f4b4
fix(mri-scanner): polish the French copy
veelenga Oct 6, 2026
bf9cf3c
fix(mri-scanner): polish the Spanish copy
veelenga Oct 6, 2026
1d9146e
fix(mri-scanner): draw the net magnet arrow through the spins without…
veelenga Oct 6, 2026
ed12ece
fix(mri-scanner): keep the gauge panel a steady width while its numbe…
veelenga Oct 6, 2026
2f47cc0
fix(mri-scanner): make the Ukrainian copy read as a native writer wou…
veelenga Oct 6, 2026
bc2d192
fix(mri-scanner): make the German copy read as a native writer would …
veelenga Oct 6, 2026
2174acc
fix(mri-scanner): make the French copy read as a native writer would …
veelenga Oct 6, 2026
21cf94d
fix(mri-scanner): make the Spanish copy read as a native writer would…
veelenga Oct 6, 2026
8506238
fix(mri-scanner): make the Portuguese copy read as a native writer wo…
veelenga Oct 6, 2026
900be22
fix(mri-scanner): make the Japanese copy read as a native writer woul…
veelenga Oct 6, 2026
9f6445c
fix(mri-scanner): make the Chinese copy read as a native writer would…
veelenga Oct 6, 2026
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
527 changes: 527 additions & 0 deletions explainers/mri-scanner/chapters.html

Large diffs are not rendered by default.

12 changes: 12 additions & 0 deletions explainers/mri-scanner/explainer.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
{
"slug": "mri-scanner",
"tags": ["physics", "biology"],
"cover": "cover.webp",
"entry": "src/index.ts",
"chapters": "chapters.html",
"locales": ["en", "zh", "es", "uk", "pt", "fr", "de", "ja"],
"social": {
"image": "social/og-image.png",
"alt": "A cutaway MRI scanner with its nested magnet layers around the tunnel, beside a slice picture of a head and the title How an MRI scanner works"
}
}
250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/de.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/en.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/es.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/fr.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/ja.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/pt.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/uk.json

Large diffs are not rendered by default.

250 changes: 250 additions & 0 deletions explainers/mri-scanner/locales/zh.json

Large diffs are not rendered by default.

Binary file added explainers/mri-scanner/public/cover.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
34 changes: 34 additions & 0 deletions explainers/mri-scanner/src/ids.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
import { describe, expect, it } from 'vitest';
import {
AXIS_CHOICE_IDS,
FOLLOW_SEQUENCE,
GRADIENT_AXIS_IDS,
MOMENT_IDS,
PART_IDS,
PHASE_IDS,
PRESET_IDS,
} from './ids';

describe('ids', () => {
it('names every part once', () => {
expect(PART_IDS).toHaveLength(27);
expect(new Set(PART_IDS).size).toBe(PART_IDS.length);
});

it('orders the steps of one repetition and the chapters', () => {
expect(PHASE_IDS).toEqual(['excite', 'encode', 'refocus', 'echo', 'recover']);
expect(MOMENT_IDS).toEqual(['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd']);
expect(PRESET_IDS).toEqual([
'overview',
'magnet',
'spins',
'resonance',
'gradients',
'picture',
]);
});

it('offers the three axes plus following the sequence', () => {
expect(AXIS_CHOICE_IDS).toEqual([...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE]);
});
});
143 changes: 143 additions & 0 deletions explainers/mri-scanner/src/ids.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
export const PART_IDS = [
'room',
'cover',
'vacuumVessel',
'radiationShield',
'heliumVessel',
'mainCoils',
'shieldCoils',
'shims',
'coldHead',
'quenchPipe',
'gradientX',
'gradientY',
'gradientZ',
'bodyCoil',
'bore',
'table',
'patient',
'headCoil',
'sliceSlab',
'spinArrows',
'netMagnet',
'mainField',
'rfWave',
'echoWave',
'fieldLinesGroup',
'fringeLine',
'screen',
] as const;

export type PartId = (typeof PART_IDS)[number];

export const PHASE_IDS = ['excite', 'encode', 'refocus', 'echo', 'recover'] as const;

export type PhaseId = (typeof PHASE_IDS)[number];

export const MOMENT_IDS = ['pulse90', 'pulse180', 'echoPeak', 'repetitionEnd'] as const;

export type MomentId = (typeof MOMENT_IDS)[number];

export const PRESET_IDS = [
'overview',
'magnet',
'spins',
'resonance',
'gradients',
'picture',
] as const;

export type PresetId = (typeof PRESET_IDS)[number];

export const FIELD_IDS = ['field15', 'field30'] as const;

export type FieldId = (typeof FIELD_IDS)[number];

export const WEIGHTING_IDS = ['t1', 't2'] as const;

export type WeightingId = (typeof WEIGHTING_IDS)[number];

export const TISSUE_IDS = ['fat', 'whiteMatter', 'greyMatter', 'fluid'] as const;

export type TissueId = (typeof TISSUE_IDS)[number];

export const GRADIENT_AXIS_IDS = ['x', 'y', 'z'] as const;

export type GradientAxisId = (typeof GRADIENT_AXIS_IDS)[number];

export const CHOICE_IDS = ['field', 'weighting'] as const;

export type ChoiceId = (typeof CHOICE_IDS)[number];

export const FOLLOW_SEQUENCE = 'sequence';

export const AXIS_CHOICE_IDS = [...GRADIENT_AXIS_IDS, FOLLOW_SEQUENCE] as const;

export type AxisChoiceId = (typeof AXIS_CHOICE_IDS)[number];

export const LINE_CHOICE_IDS = ['8', '16', '32', '64'] as const;

export type LineChoiceId = (typeof LINE_CHOICE_IDS)[number];

export const CHAPTER_ACTION_IDS = [
'field',
'weighting',
'tissue',
'gradientAxis',
'moment',
'lines',
] as const;

export type ChapterActionId = (typeof CHAPTER_ACTION_IDS)[number];

export type CameraView = 'room' | 'cryostat' | 'voxel' | 'coil' | 'gradient' | 'console';

export type RegionId = 'room' | 'scanner' | 'layers' | 'voxel' | 'bore' | 'console';

export type AnchorId = 'isocentre' | 'voxel' | 'headCoil' | 'screen' | 'coldHead';

export type Point = readonly [x: number, y: number, z: number];

export type RfPulse = 0 | 90 | 180;

export interface Fringe {
along: number;
side: number;
}

export interface SequenceReading {
phase: number;
step: PhaseId;
rf: RfPulse | null;
gradient: GradientAxisId | null;
gradientLevel: number;
echo: number;
}

export interface SpinReading {
net: Point;
arrows: readonly Point[];
precession: number;
}

export interface ViewOptions {
cutaway: boolean;
fieldLines: boolean;
voxel: boolean;
labels: boolean;
}

export interface AssemblyState {
phase: number;
playing: boolean;
field: FieldId;
weighting: WeightingId;
sequence: SequenceReading;
spins: SpinReading;
tissue: TissueId;
gradientAxis: GradientAxisId | null;
picture: Float32Array;
pictureVersion: number;
fringe: Fringe;
view: ViewOptions;
}
28 changes: 28 additions & 0 deletions explainers/mri-scanner/src/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
import './style.css';
import { defineExplainer } from '@core/explainer';
import { SCENE_OPTIONS, mountMriScannerScene } from './scene';
import { PART_INFO } from './scene/partInfo';
import { PRESETS, createMriScannerStore } from './state';
import type { MriScannerStoreState } from './state';
import { MRI_SCANNER_TIMELINE } from './timeline';
import {
CHAPTER_ACTIONS,
MRI_SCANNER_CHOICES,
MRI_SCANNER_READOUTS,
VIEW_TOGGLES,
mountMriScannerUi,
} from './ui';

export default defineExplainer<MriScannerStoreState>({
id: 'mri-scanner',
timeline: MRI_SCANNER_TIMELINE,
presets: PRESETS,
parts: PART_INFO,
createStore: () => createMriScannerStore(),
dock: { choices: MRI_SCANNER_CHOICES, toggles: VIEW_TOGGLES },
readouts: MRI_SCANNER_READOUTS,
actions: CHAPTER_ACTIONS,
scene: SCENE_OPTIONS,
mountScene: mountMriScannerScene,
mountUi: mountMriScannerUi,
});
27 changes: 27 additions & 0 deletions explainers/mri-scanner/src/model/constants.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { describe, expect, it } from 'vitest';
import {
BODY_K,
EARTH_UT,
GAMMA_MHZ_PER_T,
HELIUM_K,
KELVIN_AT_ZERO_CELSIUS,
MODEL_SIZE,
REAL_LINES,
} from './constants';

describe('constants', () => {
it('takes the gyromagnetic ratio of hydrogen from the facts sheet', () => {
expect(GAMMA_MHZ_PER_T).toBeCloseTo(42.58, 2);
});

it('puts the body at 37 °C and liquid helium at about −269 °C', () => {
expect(BODY_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(37, 9);
expect(HELIUM_K - KELVIN_AT_ZERO_CELSIUS).toBeCloseTo(-268.95, 2);
});

it('keeps the Earth field band and the picture sizes', () => {
expect(EARTH_UT).toEqual({ min: 25, typical: 50, max: 65 });
expect(REAL_LINES).toBe(256);
expect(MODEL_SIZE).toBe(64);
});
});
19 changes: 19 additions & 0 deletions explainers/mri-scanner/src/model/constants.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
export const GAMMA_MHZ_PER_T = 42.577478;
export const PLANCK = 6.62607015e-34;
export const BOLTZMANN = 1.380649e-23;
export const BODY_K = 310.15;
export const EARTH_UT = { min: 25, typical: 50, max: 65 } as const;
export const HELIUM_K = 4.2;
export const HYDROGEN_PER_MM3 = 6.7e19;
export const GRADIENT_MT_PER_M = 45;
export const SLEW_T_PER_M_S = 200;
export const HEAD_HALF_FOV_M = 0.12;
export const REAL_LINES = 256;
export const MODEL_SIZE = 64;
export const DISPLAY_TURNS_PER_S = 0.5;

export const HZ_PER_MHZ = 1e6;
export const TESLA_PER_UT = 1e-6;
export const MS_PER_S = 1000;
export const SECONDS_PER_MINUTE = 60;
export const KELVIN_AT_ZERO_CELSIUS = 273.15;
38 changes: 38 additions & 0 deletions explainers/mri-scanner/src/model/field.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { describe, expect, it } from 'vitest';
import { FIELDS, earthMultiple, larmorMHz, slowdown, spinSurplus, surplusPerMm3 } from './field';

describe('main field', () => {
it('compares a 1.5 T and a 3 T magnet with their 0.5 mT lines', () => {
expect(FIELDS.field15).toEqual({ tesla: 1.5, fringe: { along: 4.0, side: 2.5 } });
expect(FIELDS.field30).toEqual({ tesla: 3, fringe: { along: 5.2, side: 2.8 } });
});

it('makes hydrogen precess at 63.87 MHz and 127.73 MHz', () => {
expect(larmorMHz('field15')).toBeCloseTo(63.87, 2);
expect(larmorMHz('field30')).toBeCloseTo(127.73, 2);
});

it('leans about 4.9 and 9.9 spins in a million the field way', () => {
expect(spinSurplus('field15') * 1e6).toBeCloseTo(4.9, 1);
expect(spinSurplus('field30') * 1e6).toBeCloseTo(9.9, 1);
});

it('still adds to about 3 × 10¹⁴ spare spins per cubic millimetre at 1.5 T', () => {
expect(surplusPerMm3('field15')).toBeGreaterThan(3e14);
expect(surplusPerMm3('field15')).toBeLessThan(3.5e14);
expect(surplusPerMm3('field30')).toBeCloseTo(2 * surplusPerMm3('field15'), -10);
});

it('is tens of thousands of times the Earth field', () => {
const times = earthMultiple('field15');
expect(times.min).toBeCloseTo(23077, 0);
expect(times.typical).toBeCloseTo(30000, 6);
expect(times.max).toBeCloseTo(60000, 6);
expect(earthMultiple('field30').typical).toBeCloseTo(60000, 6);
});

it('draws the precession about 128 million times slower than real at 1.5 T', () => {
expect(slowdown('field15') / 1e6).toBeCloseTo(127.7, 1);
expect(slowdown('field30') / 1e6).toBeCloseTo(255.5, 1);
});
});
53 changes: 53 additions & 0 deletions explainers/mri-scanner/src/model/field.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,53 @@
import type { FieldId, Fringe } from '../ids';
import {
BODY_K,
BOLTZMANN,
DISPLAY_TURNS_PER_S,
EARTH_UT,
GAMMA_MHZ_PER_T,
HYDROGEN_PER_MM3,
HZ_PER_MHZ,
PLANCK,
TESLA_PER_UT,
} from './constants';

export interface FieldSpec {
tesla: number;
fringe: Fringe;
}

export interface EarthMultiple {
min: number;
typical: number;
max: number;
}

export const FIELDS: Readonly<Record<FieldId, FieldSpec>> = {
field15: { tesla: 1.5, fringe: { along: 4.0, side: 2.5 } },
field30: { tesla: 3, fringe: { along: 5.2, side: 2.8 } },
};

export function larmorMHz(field: FieldId): number {
return GAMMA_MHZ_PER_T * FIELDS[field].tesla;
}

export function larmorHz(field: FieldId): number {
return larmorMHz(field) * HZ_PER_MHZ;
}

export function spinSurplus(field: FieldId): number {
return (PLANCK * larmorHz(field)) / (2 * BOLTZMANN * BODY_K);
}

export function surplusPerMm3(field: FieldId): number {
return HYDROGEN_PER_MM3 * spinSurplus(field);
}

export function earthMultiple(field: FieldId): EarthMultiple {
const times = (microtesla: number): number => FIELDS[field].tesla / (microtesla * TESLA_PER_UT);
return { min: times(EARTH_UT.max), typical: times(EARTH_UT.typical), max: times(EARTH_UT.min) };
}

export function slowdown(field: FieldId): number {
return larmorHz(field) / DISPLAY_TURNS_PER_S;
}
Loading
Loading