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
4 changes: 2 additions & 2 deletions src/brand.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ import "./splash.js";
import type { TBrand } from "scenerystack/brand";
import { brand, madeWithSceneryStackOnDark, madeWithSceneryStackOnLight } from "scenerystack/brand";

const Brand: TBrand = {
const Brand = {
// Nickname for the brand, which should match the brand subdirectory name, grunt option for --brand as well as the
// query parameter for ?brand. This is used in Joist to provide brand-specific logic, such as what to show in the
// About dialog, decorative text around the PhET button, and whether to check for updates.
Expand All @@ -32,6 +32,6 @@ const Brand: TBrand = {
getLinks: () => [],
logoOnBlackBackground: madeWithSceneryStackOnDark,
logoOnWhiteBackground: madeWithSceneryStackOnLight,
};
} satisfies TBrand;

brand.register("Brand", Brand);
4 changes: 1 addition & 3 deletions src/screen-name/graph/AxisGestureHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -124,9 +124,7 @@ export default class AxisGestureHandler {
singleTouchStart = coord(pt);
initialRange = getRange().copy();
} else if (activePointers.size === 2) {
const points = Array.from(activePointers.values());
const p0 = points[0];
const p1 = points[1];
const [p0, p1] = activePointers.values();
if (p0 && p1) {
initialPinchDistance = Math.abs(coord(p0) - coord(p1));
initialPinchMidpoint = (coord(p0) + coord(p1)) / 2;
Expand Down
6 changes: 3 additions & 3 deletions src/screen-name/graph/ConfigurableGraph.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ import GraphControlsPanel from "./GraphControlsPanel.js";
import GraphDataManager from "./GraphDataManager.js";
import GraphInteractionHandler from "./GraphInteractionHandler.js";
import GraphRenderer from "./GraphRenderer.js";
import type { PlottableProperty } from "./PlottableProperty.js";
import { isRecordPlottable, type PlottableProperty } from "./PlottableProperty.js";

// Line-plot stroke width — only ConfigurableGraph creates LinePlots
const PLOT_LINE_WIDTH = 2;
Expand All @@ -80,8 +80,8 @@ function mapDataPoints(
): Array<{ x: number; y: number }> {
const result: Array<{ x: number; y: number }> = [];
for (const point of dataPoints) {
const x = "accessor" in xProperty ? xProperty.accessor(point) : 0;
const y = "accessor" in yProperty ? yProperty.accessor(point) : 0;
const x = isRecordPlottable(xProperty) ? xProperty.accessor(point) : 0;
const y = isRecordPlottable(yProperty) ? yProperty.accessor(point) : 0;
if (!(Number.isNaN(x) || Number.isNaN(y))) {
result.push({ x, y });
}
Expand Down
6 changes: 6 additions & 0 deletions src/screen-name/graph/PlottableProperty.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,3 +44,9 @@ export type LivePlottable = PlottableBase & {

/** Union of the two concrete plottable variants. */
export type PlottableProperty = RecordPlottable | LivePlottable;

/** True when {@link p} sources its value from a data-record accessor. */
export const isRecordPlottable = (p: PlottableProperty): p is RecordPlottable => "accessor" in p;

/** True when {@link p} sources its value from a live reactive property. */
export const isLivePlottable = (p: PlottableProperty): p is LivePlottable => "property" in p;
4 changes: 1 addition & 3 deletions src/screen-name/graph/ZoomGestureHandler.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,9 +114,7 @@ export default class ZoomGestureHandler {
activePointers.set(event.pointer, localPoint);

if (activePointers.size === 2) {
const points = Array.from(activePointers.values());
const point0 = points[0];
const point1 = points[1];
const [point0, point1] = activePointers.values();
if (point0 && point1) {
initialDistance = point0.distance(point1);
initialMidpoint = point0.average(point1);
Expand Down
12 changes: 8 additions & 4 deletions src/screen-name/model/OverlayToolsModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,10 @@ import { buildModelViewTransform } from "./ModelViewTransformFactory.js";
// ── Calibration unit type ──────────────────────────────────────────────────
export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const;
export type CalibrationUnit = (typeof CALIBRATION_UNITS)[number];
/** Velocity unit derived from the calibration unit (e.g. `"m/s"`). */
export type VelocityUnit = `${CalibrationUnit}/s`;
/** Acceleration unit derived from the calibration unit (e.g. `"m/s²"`). */
export type AccelerationUnit = `${CalibrationUnit}/s²`;
export const CALIBRATION_DISTANCE_RANGE = new Range(0.001, 100000);

// ── Video-local coordinate helpers ──────────────────────────────────────────
Expand Down Expand Up @@ -83,13 +87,13 @@ export class OverlayToolsModel {
public readonly calibUnitProperty = new Property<CalibrationUnit>("m");

// ── Derived unit strings (for display in graphs and tables) ───────────────
public readonly velocityUnitProperty: TReadOnlyProperty<string> = new DerivedProperty(
public readonly velocityUnitProperty: TReadOnlyProperty<VelocityUnit> = new DerivedProperty(
[this.calibUnitProperty],
(unit) => `${unit}/s`,
(unit): VelocityUnit => `${unit}/s`,
);
public readonly accelerationUnitProperty: TReadOnlyProperty<string> = new DerivedProperty(
public readonly accelerationUnitProperty: TReadOnlyProperty<AccelerationUnit> = new DerivedProperty(
[this.calibUnitProperty],
(unit) => `${unit}/s²`,
(unit): AccelerationUnit => `${unit}/s²`,
);

// ── Model-view transform (derived; the view never writes to this) ─────────
Expand Down
8 changes: 4 additions & 4 deletions src/screen-name/model/TrackingModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,9 +104,9 @@ export class TrackingModel {
points: [],
};

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

/**
Expand Down Expand Up @@ -151,7 +151,7 @@ export class TrackingModel {
public addTrackAndActivate(): void {
this.addTrack();
const tracks = this.tracksProperty.value;
const newest = tracks[tracks.length - 1];
const newest = tracks.at(-1);
if (newest) {
this.activeTrackIdProperty.value = newest.id;
}
Expand Down
20 changes: 10 additions & 10 deletions src/screen-name/view/AutoTrackerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -228,9 +228,9 @@ export class AutoTrackerNode extends Node {
// automatically once isTrackerReady becomes true. Staleness detection
// (new drag starting before this one resolves) is handled inside the
// model: initTracker() returns false when superseded.
this.tracking
.initTracker(videoElement, region)
.then((ready) => {
(async () => {
try {
const ready = await this.tracking.initTracker(videoElement, region);
if (!ready) {
// Superseded by a newer drag — silently discard.
return;
Expand All @@ -246,16 +246,16 @@ export class AutoTrackerNode extends Node {
this.tracking.resetTracker();
this.hintText.visible = true;
}
})
.catch((err: unknown) => {
} catch (err: unknown) {
// biome-ignore lint/suspicious/noConsole: error logging for tracker init failure
console.error("AutoTracker: failed to initialise OpenCV tracker:", err);
const message =
err instanceof Error ? err.message : autoTrackerStrings.trackingInitFailedStringProperty.value;
this.errorText.string = message;
this.errorText.visible = true;
this.hintText.visible = true;
});
}
})();
} else {
this.hintText.visible = true;
}
Expand Down Expand Up @@ -324,8 +324,9 @@ export class AutoTrackerNode extends Node {
this.pendingFrameId = requestAnimationFrame(processFrame);
}
};
videoElement.addEventListener("timeupdate", onFrame);
videoElement.addEventListener("seeked", onFrame);
const listenerController = new AbortController();
videoElement.addEventListener("timeupdate", onFrame, { signal: listenerController.signal });
videoElement.addEventListener("seeked", onFrame, { signal: listenerController.signal });

// ── Show/hide based on combined "video loaded && autoTracking" ────────
const autoTrackingShownListener = (shown: boolean) => {
Expand All @@ -341,8 +342,7 @@ export class AutoTrackerNode extends Node {

// ── Centralised cleanup (mirrors the disposeXxx pattern used elsewhere) ─
this.disposeAutoTrackerNode = () => {
videoElement.removeEventListener("timeupdate", onFrame);
videoElement.removeEventListener("seeked", onFrame);
listenerController.abort();
this.cancelPendingFrame();
autoTrackingShownProperty.unlink(autoTrackingShownListener);
videoDimensionsProperty.unlink(videoDimensionsListener);
Expand Down
2 changes: 1 addition & 1 deletion src/screen-name/view/PlaybackControlsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,7 +63,7 @@ export class PlaybackControlsNode extends HBox {
[TimeSpeed.NORMAL, SPEED_NORMAL],
[TimeSpeed.SLOW, SPEED_SLOW],
]);
const rateToSpeed = new Map(Array.from(speedMap.entries()).map(([k, v]) => [v, k]));
const rateToSpeed = new Map([...speedMap].map(([k, v]) => [v, k]));
const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL);

// view → model
Expand Down
31 changes: 15 additions & 16 deletions src/screen-name/view/VideoPlayerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -111,8 +111,14 @@ export class VideoPlayerNode extends Node {
const onVideoLoadStart = () => {
videoErrorText.visible = false;
};
this.videoElement.addEventListener("error", onVideoError);
this.videoElement.addEventListener("loadstart", onVideoLoadStart);

// AbortController lets dispose() cancel all video-element listeners with a
// single controller.abort() instead of a matching removeEventListener call.
const listenerController = new AbortController();
const { signal } = listenerController;

this.videoElement.addEventListener("error", onVideoError, { signal });
this.videoElement.addEventListener("loadstart", onVideoLoadStart, { signal });

const updateDuration = () => {
const d = this.videoElement.duration;
Expand All @@ -138,13 +144,13 @@ export class VideoPlayerNode extends Node {
this.videoElement.currentTime = Number.MAX_SAFE_INTEGER;
}
};
this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata);
this.videoElement.addEventListener("durationchange", updateDuration);
this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata, { signal });
this.videoElement.addEventListener("durationchange", updateDuration, { signal });

const onEnded = () => {
model.playback.isPlayingProperty.value = false;
};
this.videoElement.addEventListener("ended", onEnded);
this.videoElement.addEventListener("ended", onEnded, { signal });

// ── Auto-tracking overlay ──────────────────────────────────────────────
const autoTrackingShownProperty = new DerivedProperty(
Expand Down Expand Up @@ -354,15 +360,15 @@ export class VideoPlayerNode extends Node {
this.videoSourceControlNode.centerX = scaledW / 2;
resizeHandle.center = this.videoContentWrapper.rightBottom;
};
this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded);
this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded, { signal });

// Sync model time from video during playback (event-driven, not polled)
const onTimeUpdate = () => {
if (!this.playbackControlsNode.scrubbing) {
model.playback.currentTimeProperty.value = this.videoElement.currentTime;
}
};
this.videoElement.addEventListener("timeupdate", onTimeUpdate);
this.videoElement.addEventListener("timeupdate", onTimeUpdate, { signal });

// ── Apply video transform (translate + uniform scale) ────────────────
const videoTransformListener = (matrix: import("scenerystack/dot").Matrix3) => {
Expand All @@ -372,11 +378,11 @@ export class VideoPlayerNode extends Node {

// ── Keyboard shortcuts ─────────────────────────────────────────────────
const onKeyDown = this.createKeyboardHandler(model);
document.addEventListener("keydown", onKeyDown);
document.addEventListener("keydown", onKeyDown, { signal });

// Store cleanup function
this.disposeVideoPlayer = () => {
document.removeEventListener("keydown", onKeyDown);
listenerController.abort(); // removes keydown + all videoElement listeners at once
model.overlayTools.videoContentVisibleProperty.unlink(videoContentVisibleListener);
TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener);
TrackLabColors.panelHeaderColorProperty.unlink(panelHeaderColorListener);
Expand All @@ -385,13 +391,6 @@ export class VideoPlayerNode extends Node {
model.playback.playbackRateProperty.unlink(playbackRateListener);
model.playback.videoTransformProperty.unlink(videoTransformListener);
model.playback.panelSizeScaleProperty.unlink(panelSizeScaleListener);
this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata);
this.videoElement.removeEventListener("loadedmetadata", onDimensionsLoaded);
this.videoElement.removeEventListener("durationchange", updateDuration);
this.videoElement.removeEventListener("ended", onEnded);
this.videoElement.removeEventListener("timeupdate", onTimeUpdate);
this.videoElement.removeEventListener("error", onVideoError);
this.videoElement.removeEventListener("loadstart", onVideoLoadStart);
if (this.currentBlobUrl) {
URL.revokeObjectURL(this.currentBlobUrl);
this.currentBlobUrl = null;
Expand Down
5 changes: 3 additions & 2 deletions src/tracking/OpenCVTracker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -156,8 +156,9 @@ export class OpenCVTracker {
try {
return this.ctx.getImageData(x, y, w, h);
} catch (e) {
const err = new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers.");
throw Object.assign(err, { cause: e });
throw new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers.", {
cause: e,
});
}
}

Expand Down
Loading
Loading