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
16 changes: 13 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,9 @@ branches to the renderer.
| `src/common/model/refractiveIndex.ts` | Gladstone-Dale gases, N-BK7 Sellmeier, tilted plates |
| `src/common/model/LightSourceModel.ts` | Source selection → spectral groups |
| `src/common/view/FringePatternNode.ts` | The renderer (CanvasNode) |
| `src/common/view/IntensityProfileNode.ts` | Bamboo chart of intensity across a cut through the detector |
| `src/michelson/view/CoherenceEnvelopeNode.ts` | Bamboo chart of visibility vs path difference |
| `src/common/TimeModel.ts` | Play/pause clock composed into the Mach-Zehnder and Fabry-Pérot models |
| `src/common/view/spectralColor.ts` | CIE XYZ colour pipeline — see the carve-out below |
| `src/common/view/InterferometryLabNumberControl.ts` | Themed slider; requires an accessible name and explicit keyboard steps |
| `src/{michelson,mach-zehnder,fabry-perot}/` | One folder per screen, `model/` + `view/` |
Expand All @@ -61,6 +64,13 @@ branches to the renderer.
- **Zero path difference with parallel mirrors shows a single flat tone, not fringes.** That is
correct. `opdSpread()` exists so the a11y description says so rather than claiming rings.
- **Fringe counts are derived from a reference, never accumulated**, so they cannot drift.
- **A chart node must dispose every Property it created**, not just its `Multilink`. The
formatter Properties behind an `accessibleParagraph` each link a model Property of their own, so
leaving them alive keeps the node reachable from a model that outlives it. `tests/memory-leak.test.ts`
catches this; it caught it once already.
- **The analysis charts pad their value axis** past what the physics can reach, because the traces
that matter most are flat ones (a dark port at 0, a constant total at 1, a laser's visibility at
1). Against the frame those look like an empty chart.

## Accessibility

Expand Down Expand Up @@ -90,15 +100,15 @@ micrometer, mirror tilt) only reach their useful precision via shift-arrow.

## Testing

124 vitest specs; `happy-dom`, template `tests/setup.ts`.
138 vitest specs; `happy-dom`, template `tests/setup.ts`.

| Path | Covers |
|---|---|
| `tests/spectrum.test.ts` | coherence, visibility envelopes, doublet beats, line splitting |
| `tests/fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse |
| `tests/fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse, `intensityProfile` |
| `tests/refractiveIndex.test.ts` | Sellmeier vs published indices, Gladstone-Dale, tilted plates |
| `tests/interferometerModels.test.ts` | all three models: derived values, controls, reset |
| `tests/memory-leak.test.ts` | dispose/WeakRef, extended to `FringePatternNode` + `PhotonMarksNode` |
| `tests/memory-leak.test.ts` | dispose/WeakRef, extended to the four nodes that link model Properties |

Several assertions are anchored to **published** values (N-BK7 at 632.8 nm and 587.6 nm, its Abbe
number, air's refractivity) rather than to the implementation, so a wrong constant fails instead
Expand Down
17 changes: 12 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,18 +14,25 @@ produce rather than drawing a picture of one.

- **Michelson** — coarse and micrometer mirror travel, mirror tilt to drive circular fringes into
straight ones, an evacuable gas cell for the classic index-of-refraction measurement, a
compensator plate, and a fringe counter
compensator plate, a fringe counter, an intensity trace across the detector, and a visibility
curve that shows the coherence envelope — flat for a laser, a needle for white light, and the
sodium doublet's nulls and revivals
- **Mach-Zehnder** — both complementary output ports shown at once, an insertable sample slide
with adjustable thickness, index and tilt, and a single-photon mode that builds the pattern one
detection at a time, with a which-path marker that erases it
detection at a time (pausable and steppable), with a which-path marker that erases it — and a
trace of both ports whose dashed total stays flat, so interference visibly moves light rather
than destroying it
- **Fabry-Pérot** — mirror reflectance from 0.04 to 0.99, absorption, cavity spacing and a
scanning mode, with live finesse, free spectral range and resolving power, plus a transmission
spectrum showing whether two lines are resolved
scanning mode you can pause and step onto a transmission peak, with live finesse, free spectral
range and resolving power, plus a transmission spectrum showing whether two lines are resolved
- **Six light sources** from a helium-neon laser to white light, spanning 200 mm to 1 µm of
coherence length — including the sodium doublet and its visibility beats
- Physically computed colour: white-light fringes come out with the correct achromatic centre and
coloured orders, summed in linear light through CIE XYZ
- Full keyboard access and live screen-reader descriptions of the pattern
- An optional Preferences overlay labelling each optical element with what it contributes to the
optical path difference — including the factor of two a Michelson arm carries and a
Mach-Zehnder's does not
- Full keyboard access and live screen-reader descriptions of the pattern and of both charts
- English, Spanish, and French localization via `StringManager`
- Default and projector color profiles
- Progressive Web App (installable, offline-capable)
Expand Down
51 changes: 49 additions & 2 deletions doc/implementation-notes.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,51 @@ shared physics module.
**Repaint policy.** The pattern is repainted when its `FringeSpec` changes, not on a clock. A
static scene costs nothing.

## The charts share the renderer's physics, not a copy of it

Three screens now carry a quantitative second view alongside the detector image:
`IntensityProfileNode` (Michelson and Mach-Zehnder), `CoherenceEnvelopeNode` (Michelson) and the
older `TransmissionSpectrumNode` (Fabry-Pérot). All three are bamboo charts built the same way —
`ChartTransform` + `ChartRectangle` + `LinePlot`, redrawn from a `Multilink` on the model
Properties they read.

None of them re-implements any physics. The intensity trace calls `intensityProfile()` in
`common/model/fringeIntensity.ts`, which is a thin loop over the same `intensityAt()` the renderer
uses per pixel; the visibility curve calls `spectrumVisibility()` from `spectrum.ts`, the same
function behind the Michelson's visibility readout. Sampling the physics from the model layer
rather than the view is what lets `tests/fringeIntensity.test.ts` assert that the two Mach-Zehnder
ports' profiles sum to a constant — the claim `doc/model.md` §6 makes and the dashed total on that
chart draws.

**Both charts pad their value axis past the reachable range** (−0.06 to 1.08 rather than 0 to 1).
The traces that matter most are the flat ones: a dark port pinned at zero, a constant total pinned
at one, a laser's visibility flat at full contrast. Drawn hard against the frame those read as an
empty box rather than as a result, so the padding and the half-scale gridlines are what make "flat"
legible as an answer.

**The coherence curve rescales itself**, because a laser's coherence length is 200 mm and white
light's is a micrometre and both have to be readable. The span comes from the source's own feature
scale — the smaller of its coherence length and, for a doublet, its beat period `λ₀²/δλ` — clamped
to the mirror stage's reach. This is the same trick `TransmissionSpectrumNode` already used to zoom
around a line separation.

**Dispose is not optional here.** Each chart links Properties it does not own, and so do the
formatter Properties feeding its `accessibleParagraph`. Disposing only the multilink leaves those
intermediates listening, which keeps the node reachable from a model that has outlived it;
`tests/memory-leak.test.ts` covers both nodes for exactly that.

## Time controls

`common/TimeModel.ts` is composed into the two models that evolve on their own: `MachZehnderModel`
(starting played, so photons flow immediately) and `FabryPerotModel` (starting paused). Each also
exposes `stepOnce()`, which advances one frame's worth regardless of the clock — a handful of
photons, or a three-hundredth of a cavity sweep. `createTimeControl()` in `controlFactory.ts` binds
both to a `TimeControlNode`.

The Fabry-Pérot's "Scan the spacing" checkbox was replaced by that control rather than joined to
it: `scanningProperty` and `timer.isPlayingProperty` would have been two ways to stop the same
sweep.

## Colour is computed in linear light through CIE XYZ

`src/common/view/spectralColor.ts` converts wavelengths to colour and adds them the way light
Expand Down Expand Up @@ -105,6 +150,7 @@ src/
SourceType.ts
view/
FringePatternNode.ts the renderer
IntensityProfileNode.ts intensity across a cut through the detector
DetectorScreenNode.ts bezelled detector + overlay layer
OpticalTableNode.ts breadboard background
BeamPathNode.ts a beam: bright core in a soft halo
Expand Down Expand Up @@ -145,15 +191,16 @@ several thousand of them, they are never interactive, and at that size the path

## Tests

124 vitest specs under `tests/`, environment `happy-dom` with the template's `tests/setup.ts`.
138 vitest specs under `tests/`, environment `happy-dom` with the template's `tests/setup.ts`.

| File | Covers |
|---|---|
| `spectrum.test.ts` | coherence length, visibility envelopes, doublet beats, line splitting |
| `fringeIntensity.test.ts` | …and `intensityProfile`, including the two ports summing to a constant |
| `fringeIntensity.test.ts` | detector geometry, two-beam and Airy intensity, finesse |
| `refractiveIndex.test.ts` | Sellmeier against published indices, Gladstone-Dale, tilted plates |
| `interferometerModels.test.ts` | all three screen models: derived values, controls, reset |
| `memory-leak.test.ts` | dispose/WeakRef regression, extended to the two listening nodes |
| `memory-leak.test.ts` | dispose/WeakRef regression, extended to the four listening nodes |
| `TimeModel.test.ts` | template model retained |

Several assertions are anchored to published numbers rather than to the implementation — N-BK7's
Expand Down
16 changes: 16 additions & 0 deletions src/InterferometryLabColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -182,6 +182,22 @@ const InterferometryLabColors = {
projector: "#1565a8",
}),

/**
* A second plot trace, for charts that draw two curves at once — the
* Mach-Zehnder's two output ports. Chosen warm against `plotTrace`'s cool blue
* so the pair stays distinguishable without relying on hue alone.
*/
plotTraceAltColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotTraceAlt", {
default: "#ff9e6d",
projector: "#b34a12",
}),

/** The dashed total of several traces, drawn quieter than the traces themselves. */
plotSumColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotSum", {
default: "#9aa8bd",
projector: "#6b7285",
}),

plotAxisColorProperty: new ProfileColorProperty(InterferometryLabNamespace, "plotAxis", {
default: "#7a86a0",
projector: "#5a6070",
Expand Down
96 changes: 91 additions & 5 deletions src/common/InterferometryLabScreenIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,19 +3,64 @@
*
* Programmatic home-screen / navigation-bar icons for each screen.
* Drawn on the standard PhET 548 × 373 canvas using InterferometryLabColors.
* Replace the stub backgrounds with screen-specific motifs.
*
* Each icon is a miniature of what that screen's detector actually shows, which
* is the only thing about the three screens a learner can tell apart at a
* glance. They have to survive being shrunk to navigation-bar size, so the three
* differ in shape as well as colour: broad rings, a pair of complementary
* patches, and narrow rings.
*/
import { Node, Rectangle } from "scenerystack/scenery";
import { Circle, Node, Rectangle } from "scenerystack/scenery";
import { ScreenIcon } from "scenerystack/sim";
import InterferometryLabColors from "../InterferometryLabColors.js";

const W = 548;
const H = 373;

/** Side of the detector square each icon is built around, view units. */
const FACE_SIZE = 260;

function background(): Rectangle {
return new Rectangle(0, 0, W, H, { fill: InterferometryLabColors.backgroundColorProperty });
}

/** A detector face: the dark square a pattern is drawn on. */
function face(size: number, centerX: number): Rectangle {
return new Rectangle(centerX - size / 2, (H - size) / 2, size, size, {
fill: InterferometryLabColors.detectorFaceColorProperty,
stroke: InterferometryLabColors.tableBorderColorProperty,
lineWidth: 2,
});
}

/**
* Concentric fringes of equal inclination, clipped to a detector face.
*
* Radii go as √n because that is where the rings actually fall: the path
* difference varies as cos θ, so successive orders crowd together outwards. It
* costs nothing to draw them correctly and it is the shape the screen produces.
*/
function rings(count: number, lineWidth: number, color: Rectangle["fill"], centerX: number): Node {
const children: Node[] = [];
const maxRadius = FACE_SIZE * 0.72;

for (let n = 1; n <= count; n++) {
children.push(
new Circle(maxRadius * Math.sqrt(n / count), {
centerX,
centerY: H / 2,
stroke: color,
lineWidth,
}),
);
}

return new Node({
children,
clipArea: new Rectangle(centerX - FACE_SIZE / 2, (H - FACE_SIZE) / 2, FACE_SIZE, FACE_SIZE).getShape(),
});
}

function iconFrom(content: Node): ScreenIcon {
return new ScreenIcon(content, {
maxIconWidthProportion: 1,
Expand All @@ -24,26 +69,67 @@ function iconFrom(content: Node): ScreenIcon {
});
}

/** Broad circular fringes — what a Michelson with parallel mirrors puts on its screen. */
export function createMichelsonIcon(): ScreenIcon {
return iconFrom(
new Node({
children: [background()],
children: [
background(),
face(FACE_SIZE, W / 2),
rings(7, 14, InterferometryLabColors.accentColorProperty, W / 2),
],
}),
);
}

/**
* Two detectors carrying complementary straight fringes: bright where the other
* is dark. The pair is the Mach-Zehnder screen's whole subject.
*/
export function createMachZehnderIcon(): ScreenIcon {
const portSize = 200;
const gap = 44;
const leftX = W / 2 - (portSize + gap) / 2;
const rightX = W / 2 + (portSize + gap) / 2;
const barCount = 4;
const barWidth = portSize / (2 * barCount);

const bars = (centerX: number, offset: number): Node => {
const children: Node[] = [];
for (let i = 0; i < barCount; i++) {
children.push(
new Rectangle(centerX - portSize / 2 + (2 * i + offset) * barWidth, (H - portSize) / 2, barWidth, portSize, {
fill: InterferometryLabColors.accentColorProperty,
}),
);
}
return new Node({ children });
};

return iconFrom(
new Node({
children: [background()],
children: [
background(),
face(portSize, leftX),
bars(leftX, 0),
face(portSize, rightX),
// Offset by one bar: where port A is bright, port B is dark.
bars(rightX, 1),
],
}),
);
}

/**
* Narrow, sharp rings. The same geometry as the Michelson's, drawn thin and
* numerous, because that is exactly what raising the mirror reflectance does to
* the Airy pattern — and the difference between the two icons is the difference
* between two-beam and multi-beam interference.
*/
export function createFabryPerotIcon(): ScreenIcon {
return iconFrom(
new Node({
children: [background()],
children: [background(), face(FACE_SIZE, W / 2), rings(9, 6, InterferometryLabColors.valueColorProperty, W / 2)],
}),
);
}
29 changes: 29 additions & 0 deletions src/common/model/fringeIntensity.ts
Original file line number Diff line number Diff line change
Expand Up @@ -189,6 +189,34 @@ export function axialIntensity(spec: FringeSpec): number {
return intensityAt(spec, 0, 0, scratch);
}

/**
* Intensity along a horizontal cut through the centre of the detector: `v = 0`,
* `u` running −1 to +1, sampled at pixel centres the same way the renderer
* samples its grid.
*
* This is the trace a slit detector scanned across the pattern would record, and
* it is what turns the image into a measurement — the depth of the modulation
* *is* the visibility, and the number of ripples *is* the fringe count. It lives
* here rather than in the plotting node so that it stays a pure function of a
* {@link FringeSpec}, testable without a canvas, like the rest of the physics.
*
* @param spec - the pattern description
* @param sampleCount - number of points across the detector
* @param out - optional buffer of at least `sampleCount` entries to fill
* @returns the filled buffer
*/
export function intensityProfile(spec: FringeSpec, sampleCount: number, out?: Float64Array): Float64Array {
const result = out && out.length >= sampleCount ? out : new Float64Array(sampleCount);
const scratch = new Float64Array(spec.groups.length);

for (let i = 0; i < sampleCount; i++) {
const u = (2 * (i + 0.5)) / sampleCount - 1;
result[i] = intensityAt(spec, u, 0, scratch);
}

return result;
}

InterferometryLabNamespace.register("fringeIntensity", {
axialCosine,
opticalPathDifference,
Expand All @@ -200,4 +228,5 @@ InterferometryLabNamespace.register("fringeIntensity", {
airyIntensity,
intensityAt,
axialIntensity,
intensityProfile,
});
Loading