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
37 changes: 28 additions & 9 deletions src/TrackLabColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,16 +24,35 @@ function profileColor(
});
}

// ── Track colour palette (one CSS color per symbol A–H, repeats after 8) ────
// ── Track colour palette (one CSS color per symbol A–Z) ─────────────────────
// 26 distinct, high-contrast colours so every possible track has a unique hue.
export const TRACK_COLORS = [
new Color(255, 140, 0), // A – orange
new Color(0, 188, 212), // B – cyan
new Color(233, 30, 140), // C – magenta
new Color(156, 39, 176), // D – purple
new Color(205, 220, 57), // E – lime-yellow
new Color(0, 229, 255), // F – light cyan
new Color(255, 87, 34), // G – deep orange
new Color(118, 255, 3), // H – light green
new Color(255, 140, 0), // A – orange
new Color(0, 188, 212), // B – cyan
new Color(233, 30, 140), // C – magenta
new Color(156, 39, 176), // D – purple
new Color(205, 220, 57), // E – lime-yellow
new Color(0, 229, 255), // F – light cyan
new Color(255, 87, 34), // G – deep orange
new Color(118, 255, 3), // H – light green
new Color(244, 67, 54), // I – red
new Color(63, 81, 181), // J – indigo
new Color(0, 150, 136), // K – teal
new Color(255, 235, 59), // L – yellow
new Color(121, 85, 72), // M – brown
new Color(96, 125, 139), // N – blue-grey
new Color(233, 30, 99), // O – pink
new Color(33, 150, 243), // P – blue
new Color(139, 195, 74), // Q – light green (darker)
new Color(255, 193, 7), // R – amber
new Color(0, 188, 84), // S – green
new Color(121, 134, 203), // T – periwinkle
new Color(255, 112, 67), // U – deep orange (lighter)
new Color(77, 208, 225), // V – light teal
new Color(174, 213, 129), // W – sage
new Color(240, 98, 146), // X – light pink
new Color(129, 212, 250), // Y – sky blue
new Color(178, 132, 190), // Z – lavender
];

/**
Expand Down
2 changes: 1 addition & 1 deletion src/screen-name/graph/ConfigurableGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -672,7 +672,7 @@ export default class ConfigurableGraph extends Node {
const x = this.getValueForAxis(xProperty, point);
const y = this.getValueForAxis(yProperty, point);

if (x !== null && y !== null) {
if (x !== null && y !== null && Number.isFinite(x) && Number.isFinite(y)) {
mappedPoints.push({ x, y });
}
}
Expand Down
68 changes: 32 additions & 36 deletions src/screen-name/graph/GraphDataManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,10 @@ export default class GraphDataManager {
private readonly trailLength: number = 5;
private isManuallyZoomed: boolean = false;

// Pool of Circle nodes reused by updateTrail() to avoid allocating and
// discarding SceneryStack nodes on every update (pan, zoom, new data, etc.).
private readonly trailCirclePool: Circle[] = [];

// Grid and tick components
private readonly verticalGridLineSet: GridLineSet;
private readonly horizontalGridLineSet: GridLineSet;
Expand Down Expand Up @@ -240,52 +244,44 @@ export default class GraphDataManager {
}

/**
* Update the trail visualization showing the most recent points
* Update the trail visualization showing the most recent points.
* Reuses a pool of Circle nodes (updating radius, opacity, position) instead
* of destroying and recreating nodes on every call.
*/
public updateTrail(): void {
// Clear existing trail circles
this.trailNode.removeAllChildren();

// Get the last N points (up to trailLength)
const numTrailPoints = Math.min(this.trailLength, this.dataPoints.length);
if (numTrailPoints === 0) {
return;
}

// Start from the most recent points
const startIndex = this.dataPoints.length - numTrailPoints;

for (let i = 0; i < numTrailPoints; i++) {
const point = this.dataPoints[startIndex + i];
if (!point) continue;

// Calculate the age of this point (0 = oldest in trail, numTrailPoints-1 = newest)
const age = i;
const fraction = age / (numTrailPoints - 1 || 1); // 0 to 1, where 1 is newest
const minRadius = 3;
const maxRadius = 5;
const minOpacity = 0.2;
const maxOpacity = 0.8;

// Size and opacity increase with recency
// Oldest point: small and transparent
// Newest point: large and opaque
const minRadius = 3;
const maxRadius = 5;
const radius = minRadius + (maxRadius - minRadius) * fraction;

const minOpacity = 0.2;
const maxOpacity = 0.8;
const opacity = minOpacity + (maxOpacity - minOpacity) * fraction;

// Transform model coordinates to view coordinates
const viewPosition = this.chartTransform.modelToViewPosition(point);

// Create circle for this trail point
const circle = new Circle(radius, {
// Grow the pool if needed.
while (this.trailCirclePool.length < numTrailPoints) {
const circle = new Circle(minRadius, {
fill: TrackLabColors.plot1Property,
opacity: opacity,
center: viewPosition,
opacity: minOpacity,
});

this.trailCirclePool.push(circle);
this.trailNode.addChild(circle);
}

// Update each pool circle (visible ones first, then hide extras).
for (const [i, circle] of this.trailCirclePool.entries()) {
if (i < numTrailPoints) {
const point = this.dataPoints[startIndex + i];
if (!point) { circle.visible = false; continue; }

const fraction = i / (numTrailPoints - 1 || 1); // 0 = oldest, 1 = newest
circle.radius = minRadius + (maxRadius - minRadius) * fraction;
circle.opacity = minOpacity + (maxOpacity - minOpacity) * fraction;
circle.center = this.chartTransform.modelToViewPosition(point);
circle.visible = true;
} else {
circle.visible = false;
}
}
}

/**
Expand Down
13 changes: 6 additions & 7 deletions src/screen-name/graph/GraphInteractionHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -412,9 +412,9 @@ export default class GraphInteractionHandler {
// Single touch - vertical pan
const deltaY = globalPoint.y - singleTouchStartY;

// Convert delta to model coordinates
// Convert delta to model coordinates (negate: dragging down pans values down)
const modelDeltaY =
deltaY * (initialYRange.getLength() / this.graphHeight);
-deltaY * (initialYRange.getLength() / this.graphHeight);

const newYRange = new Range(
initialYRange.min + modelDeltaY,
Expand Down Expand Up @@ -1077,19 +1077,18 @@ export default class GraphInteractionHandler {
* Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data
*/
public zoomIn(): void {
// Zoom centered on the middle of the chart
// Zoom centered on the middle of the chart; set manual flag so auto-rescale won't override.
const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2);
this.zoom(this.zoomFactor, centerPoint, false);
this.zoom(this.zoomFactor, centerPoint, true);
}

/**
* Zoom out centered on the graph
* Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data
*/
public zoomOut(): void {
// Zoom out centered on the middle of the chart
// Zoom out centered on the middle of the chart; set manual flag so auto-rescale won't override.
const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2);
this.zoom(1 / this.zoomFactor, centerPoint, false);
this.zoom(1 / this.zoomFactor, centerPoint, true);
}

/**
Expand Down
6 changes: 3 additions & 3 deletions src/screen-name/model/SimModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,8 +40,8 @@ export const FRAME_RATE_RANGE = new Range(1, 120);
// The VideoPlayerNode is centered at layoutBounds.center + (0, -20).
const LAYOUT_CENTER_X = 512; // 1024 / 2
const LAYOUT_CENTER_Y = 309; // 618 / 2
const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512
const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289
export const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512
export const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289
const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint

// Initial tool positions (view / pixel space)
Expand Down Expand Up @@ -417,7 +417,7 @@ export class SimModel {
};

const tracks = [...this.tracksProperty.value, track];
tracks.sort((a, b) => a.symbol.localeCompare(b.symbol));
tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0));
this.tracksProperty.value = tracks;
}

Expand Down
36 changes: 29 additions & 7 deletions src/screen-name/view/AutoTrackerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,12 @@ export class AutoTrackerNode extends Node {
private selecting = false;
private selStart = Vector2.ZERO;

// Monotonically increasing counter — each new initFromVideo call captures the
// current value and only applies results if the counter hasn't changed by the
// time the async initialisation completes, preventing stale results from a
// previous drag from overwriting a more recent one.
private initVersion = 0;

// Kept for removeEventListener / unlink in dispose()
private readonly boundVideoElement: HTMLVideoElement;
private readonly boundOnFrame: () => void;
Expand Down Expand Up @@ -137,6 +143,8 @@ export class AutoTrackerNode extends Node {
const dragListener = new DragListener({
start: (event) => {
this.trail.length = 0;
// Bump version so any in-flight initFromVideo call is discarded when it resolves.
this.initVersion++;
this.model.tracker.dispose();
this.setCrosshairVisible(false);
this.trailPath.shape = null;
Expand Down Expand Up @@ -191,14 +199,25 @@ export class AutoTrackerNode extends Node {

// initFromVideo is async (loads WASM on first call); tracking begins
// automatically once `ready` becomes true.
// Capture the current version so stale results from a previous drag
// (still awaiting WASM load) are discarded if a new drag has started.
const capturedVersion = this.initVersion;
this.model.tracker
.initFromVideo(videoElement, region)
.then(() => {
if (this.initVersion !== capturedVersion) {
// A newer drag has already started; discard this result.
this.model.tracker.dispose();
}
})
.catch((err) => {
console.error(
"[AutoTracker] Tracking initialisation failed:",
err,
);
this.hintText.visible = true;
if (this.initVersion === capturedVersion) {
console.error(
"[AutoTracker] Tracking initialisation failed:",
err,
);
this.hintText.visible = true;
}
});
} else {
this.hintText.visible = true;
Expand All @@ -222,8 +241,11 @@ export class AutoTrackerNode extends Node {
const activeId = model.activeTrackIdProperty.value;
if (activeId) {
const time = videoElement.currentTime;
const frameDuration = model.frameDurationProperty.value;
const frame = Math.round(time / frameDuration);
// Multiply by frame rate directly rather than dividing by frameDuration
// (1/fps) to avoid cascading floating-point error at non-integer fps values
// like 29.97, which could cause two adjacent timestamps to map to the same
// frame or skip a frame entirely.
const frame = Math.round(time * model.frameRateProperty.value);

// O(1) duplicate-frame check via Set (vs O(n) linear scan).
if (!this.recordedFrames.has(frame)) {
Expand Down
25 changes: 25 additions & 0 deletions src/screen-name/view/CalibrationToolNode.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import type { TReadOnlyProperty } from "scenerystack/axon";
import { DerivedProperty } from "scenerystack/axon";
import { Color } from "scenerystack";
import { Shape } from "scenerystack/kite";
import {
Circle,
Expand All @@ -24,6 +25,7 @@ import type { SimModel } from "../model/SimModel.js";
import { CALIBRATION_UNITS } from "../model/SimModel.js";

const FONT = new PhetFont(14);
const WARNING_FONT = new PhetFont({ size: 11, weight: "bold" });
const ENDPOINT_RADIUS = 4;
const ENDPOINT_TOUCH_DILATION = 12; // extra pixels for easier pickup (mouseArea/touchArea)
const LINE_WIDTH = 2;
Expand All @@ -38,6 +40,9 @@ const MIDPOINT_PANEL_SPACING = 8;
const MIDPOINT_Y_OFFSET = 12; // pixels above midpoint where the panel sits
const ENDPOINT_DRAG_SPEED = 200; // pixels/s for normal keyboard drag
const ENDPOINT_SHIFT_DRAG_SPEED = 40; // pixels/s for shift-key keyboard drag
// Pixel distance below which endpoints are considered overlapping and a warning is shown.
const OVERLAP_WARNING_DISTANCE = 10;
const ENDPOINT_WARNING_COLOR = new Color(255, 60, 60);

export class CalibrationToolNode extends Node {
public constructor(
Expand Down Expand Up @@ -161,6 +166,15 @@ export class CalibrationToolNode extends Node {
midpointPanel.setScaleMagnitude(MIDPOINT_PANEL_SCALE);
this.addChild(midpointPanel);

// ── Overlap warning text ──────────────────────────────────────────────
// Shown when endpoints are too close together to produce a valid calibration.
const overlapWarning = new Text("Points too close — move apart to calibrate", {
font: WARNING_FONT,
fill: ENDPOINT_WARNING_COLOR,
visible: false,
});
this.addChild(overlapWarning);

// ── Update geometry when endpoints move ───────────────────────────────
const updateGeometry = () => {
const p1 = model.calibPoint1Property.value;
Expand All @@ -171,6 +185,17 @@ export class CalibrationToolNode extends Node {
const mid = p1.blend(p2, 0.5);
midpointPanel.centerX = mid.x;
midpointPanel.bottom = mid.y - MIDPOINT_Y_OFFSET;

// Show warning and highlight endpoints when too close to be useful.
const tooClose = p1.distance(p2) < OVERLAP_WARNING_DISTANCE;
const endpointFill = tooClose ? ENDPOINT_WARNING_COLOR : TrackLabColors.calibrationFillProperty.value;
endpoint1.fill = endpointFill;
endpoint2.fill = endpointFill;
overlapWarning.visible = tooClose;
if (tooClose) {
overlapWarning.centerX = mid.x;
overlapWarning.top = mid.y + MIDPOINT_Y_OFFSET;
}
};
model.calibPoint1Property.link(updateGeometry);
model.calibPoint2Property.link(updateGeometry);
Expand Down
24 changes: 24 additions & 0 deletions src/screen-name/view/CoordinateSystemNode.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,23 @@
import type { TReadOnlyProperty } from "scenerystack/axon";
import { Bounds2 } from "scenerystack/dot";
import { Shape } from "scenerystack/kite";
import { Circle, Node, RichDragListener, Text } from "scenerystack/scenery";
import { ArrowNode, PhetFont } from "scenerystack/scenery-phet";
import { Tandem } from "scenerystack/tandem";
import { StringManager } from "../../i18n/StringManager.js";
import TrackLabColors from "../../TrackLabColors.js";
import type { SimModel } from "../model/SimModel.js";
import { VIDEO_CENTER_X, VIDEO_CENTER_Y, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";

const ARROW_LENGTH = 120;

// Bounds of the video area in view (pixel) coordinates — used to clamp the coord origin drag.
const VIDEO_BOUNDS = new Bounds2(
VIDEO_CENTER_X - VIDEO_WIDTH / 2,
VIDEO_CENTER_Y - VIDEO_HEIGHT / 2,
VIDEO_CENTER_X + VIDEO_WIDTH / 2,
VIDEO_CENTER_Y + VIDEO_HEIGHT / 2,
);
const HANDLE_FRACTION = 1 / 3;
const FONT = new PhetFont({ size: 14, weight: "bold" });

Expand Down Expand Up @@ -135,6 +145,20 @@ export class CoordinateSystemNode extends Node {
rotatingNode.rotation = angle;
});

// ── Clamp coord origin to video bounds on every change ────────────────
// Prevents the user from dragging the coordinate system completely off-screen.
let isClamping = false;
model.coordOriginProperty.lazyLink((pos) => {
if (isClamping) return;
const clampedX = Math.max(VIDEO_BOUNDS.minX, Math.min(VIDEO_BOUNDS.maxX, pos.x));
const clampedY = Math.max(VIDEO_BOUNDS.minY, Math.min(VIDEO_BOUNDS.maxY, pos.y));
if (clampedX !== pos.x || clampedY !== pos.y) {
isClamping = true;
model.coordOriginProperty.value = model.coordOriginProperty.value.copy().setXY(clampedX, clampedY);
isClamping = false;
}
});

// ── Drag: translate the entire coordinate system ──────────────────────
positionNode.addInputListener(
new RichDragListener({
Expand Down
Loading