From 0ad226328abfd63998f71156637786c3e6b5ab02 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 00:42:09 +0000 Subject: [PATCH 1/2] chore: update package-lock.json peer flags Remove stale "peer": true annotations from several devDependencies in package-lock.json, generated automatically by npm install in the session startup hook. https://claude.ai/code/session_01AkJKY77taZh12TtxiEJ81S --- package-lock.json | 8 -------- 1 file changed, 8 deletions(-) diff --git a/package-lock.json b/package-lock.json index 6f25fd6..00387fd 100644 --- a/package-lock.json +++ b/package-lock.json @@ -73,7 +73,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -3247,7 +3246,6 @@ "integrity": "sha512-m0jEgYlYz+mDJZ2+F4v8D1AyQb+QzsNqRuI7xg1VQX/KlKS0qT9r1Mo16yo5F/MtifXFgaofIFsdFMox2SxIbQ==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "undici-types": "~7.16.0" } @@ -3285,7 +3283,6 @@ "integrity": "sha512-PlXPeEWMXMZ7sPYOHqmDyCJzcfNrUr3fGNKtezX14ykXOEIvyK81d+qydx89KY5O71FKMPaQ2vBfBFI5NHR63A==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", @@ -3510,7 +3507,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -6153,7 +6149,6 @@ "integrity": "sha512-jTwoImyr/QbOWFFso3YoU3ik0jBBDJ6JTOQiy/J2YxVJdZCc+5u7skhNwiOR3FQIygFqVUPHl7qbbxtjW2K3Qg==", "dev": true, "license": "BSD-2-Clause", - "peer": true, "dependencies": { "@jridgewell/source-map": "^0.3.3", "acorn": "^8.15.0", @@ -6209,7 +6204,6 @@ "integrity": "sha512-5C1sg4USs1lfG0GFb2RLXsdpXqBSEhAaA/0kPL01wxzpMqLILNxIxIOKiILz+cdg/pLnOUxFYOR5yhHU666wbw==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "~0.27.0", "get-tsconfig": "^4.7.5" @@ -6470,7 +6464,6 @@ "integrity": "sha512-w+N7Hifpc3gRjZ63vYBXA56dvvRlNWRczTdmCBBa+CotUzAPf5b7YMdMR/8CQoeYE5LX3W4wj6RYTgonm1b9DA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.27.0", "fdir": "^6.5.0", @@ -6848,7 +6841,6 @@ "integrity": "sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==", "dev": true, "license": "MIT", - "peer": true, "bin": { "rollup": "dist/bin/rollup" }, From 949eb92e1ead025ae67a0f8b3476f4d3360055fa Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 27 Feb 2026 01:18:44 +0000 Subject: [PATCH 2/2] refactor: split SimModel, document kinematics cache invariant, consolidate icons MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SimModel scope creep (High): - Extract VideoPlaybackModel: timing, frame rate, playback speed, video dimensions/transform - Extract VideoSourceModel: webcam recordings, uploaded videos, active blob, isWebcamVideo flag - Extract TrackingModel: particle tracks, kinematics cache with explicit invariant docs, OpenCV tracker facade - SimModel becomes a thin coordinator (model.playback.*, model.sources.*, model.tracking.*) - Re-export constants and types from SimModel for backward compatibility - Update all 12 view files to use the new sub-model property paths Kinematics cache implicit invariant (Medium): - Add detailed comment in TrackingModel explaining identity-based cache invalidation: cached.points === track.points is valid because all mutation paths replace the entire Track object immutably; track IDs are never reused (A–Z monotonic sequence) Scattered icon definitions (Medium): - Create src/TrackLabIcons.ts with makeDownloadIcon, makeUploadIcon, makePlusIcon, makeTrashIcon - TrackLabButton.ts re-exports from TrackLabIcons.ts (backward-compatible) - TrackListPanel.ts imports from TrackLabIcons.ts and removes local icon definitions https://claude.ai/code/session_01AkJKY77taZh12TtxiEJ81S --- src/TrackLabButton.ts | 61 +-- src/TrackLabIcons.ts | 124 +++++ src/screen-name/model/SimModel.ts | 429 +++--------------- src/screen-name/model/TrackingModel.ts | 222 +++++++++ src/screen-name/model/VideoPlaybackModel.ts | 96 ++++ src/screen-name/model/VideoSourceModel.ts | 104 +++++ src/screen-name/view/AutoTrackerNode.ts | 38 +- src/screen-name/view/CalibrationToolNode.ts | 4 +- src/screen-name/view/CoordinateSystemNode.ts | 4 +- src/screen-name/view/DataTableNode.ts | 10 +- src/screen-name/view/DigitizingOverlayNode.ts | 40 +- src/screen-name/view/KinematicsGraphNode.ts | 18 +- src/screen-name/view/PlaybackControlsNode.ts | 53 ++- src/screen-name/view/SimScreenView.ts | 16 +- src/screen-name/view/TrackListPanel.ts | 82 +--- src/screen-name/view/VideoPlayerNode.ts | 48 +- .../view/VideoSourceControlNode.ts | 32 +- src/screen-name/view/WebcamPanel.ts | 4 +- 18 files changed, 760 insertions(+), 625 deletions(-) create mode 100644 src/TrackLabIcons.ts create mode 100644 src/screen-name/model/TrackingModel.ts create mode 100644 src/screen-name/model/VideoPlaybackModel.ts create mode 100644 src/screen-name/model/VideoSourceModel.ts diff --git a/src/TrackLabButton.ts b/src/TrackLabButton.ts index 620ef3b..59c7768 100644 --- a/src/TrackLabButton.ts +++ b/src/TrackLabButton.ts @@ -22,8 +22,7 @@ * }); */ -import { Shape } from "scenerystack/kite"; -import { HStrut, Node, Path, VStrut } from "scenerystack/scenery"; +import { HStrut, Node, VStrut } from "scenerystack/scenery"; import { ButtonNode, RectangularPushButton } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "./TrackLabColors.js"; @@ -35,6 +34,11 @@ import { TOUCH_AREA_DILATION, } from "./TrackLabConstants.js"; +// Re-export all icon factories from the central icons module so that existing +// imports of the form `import { makeDownloadIcon } from "…/TrackLabButton.js"` +// continue to work without modification. +export { makeDownloadIcon, makeUploadIcon } from "./TrackLabIcons.js"; + // ── Types ───────────────────────────────────────────────────────────────────── type PushButtonOptions = ConstructorParameters[0]; @@ -98,56 +102,3 @@ export function createTrackLabButton(content: Node, options?: TrackLabButtonOpti content: sizeContent(content), }); } - -// ── Icon helpers ────────────────────────────────────────────────────────────── - -/** - * Download icon: downward arrow with a tray bar. - * - * Replaces the plain ⬇ unicode glyph with a proper symbolic Path icon that - * scales cleanly at all sizes and renders crisply regardless of font hinting. - * - * | shaft | - * \ arrow / - * \_head__/ - * [=tray bar=] - */ -export function makeDownloadIcon(): Node { - const totalW = 12; // total icon width - const shaftW = 4; // width of the vertical arrow shaft - const shaftH = 5; // height of the shaft above the arrowhead - const headH = 4; // height of the arrowhead triangle - const gap = 1; // gap between arrowhead tip and tray bar - const barH = 2; // height of the tray bar - - const shape = new Shape(); - - // Vertical shaft (centered horizontally) - shape.rect((totalW - shaftW) / 2, 0, shaftW, shaftH); - - // Arrowhead triangle (pointing down) - shape.moveTo(0, shaftH); - shape.lineTo(totalW / 2, shaftH + headH); - shape.lineTo(totalW, shaftH); - shape.close(); - - // Tray bar at bottom - shape.rect(0, shaftH + headH + gap, totalW, barH); - - return new Path(shape, { fill: TrackLabColors.textOnDarkProperty }); -} - -/** - * Upload icon: folder shape indicating "open a file". - */ -export function makeUploadIcon(): Node { - const folderShape = new Shape() - .moveTo(0, 3) - .lineTo(4, 3) - .lineTo(5.5, 0) - .lineTo(14, 0) - .lineTo(14, 10) - .lineTo(0, 10) - .close(); - return new Path(folderShape, { fill: TrackLabColors.textOnDarkProperty }); -} diff --git a/src/TrackLabIcons.ts b/src/TrackLabIcons.ts new file mode 100644 index 0000000..412747e --- /dev/null +++ b/src/TrackLabIcons.ts @@ -0,0 +1,124 @@ +/** + * TrackLabIcons.ts + * + * Centralised icon factory functions for all SceneryStack Path/Node icons used + * in TrackLab buttons and panels. Keeping icon definitions here prevents + * duplication across view files and makes it easy to update a glyph in one + * place. + * + * Usage: + * import { makeDownloadIcon, makeTrashIcon } from '../../TrackLabIcons.js'; + */ + +import { Shape } from "scenerystack/kite"; +import { Line, Node, Path, Rectangle } from "scenerystack/scenery"; +import TrackLabColors from "./TrackLabColors.js"; + +/** + * Download icon: downward arrow with a tray bar. + * + * | shaft | + * \ arrow / + * \_head__/ + * [=tray bar=] + */ +export function makeDownloadIcon(): Node { + const totalW = 12; // total icon width + const shaftW = 4; // width of the vertical arrow shaft + const shaftH = 5; // height of the shaft above the arrowhead + const headH = 4; // height of the arrowhead triangle + const gap = 1; // gap between arrowhead tip and tray bar + const barH = 2; // height of the tray bar + + const shape = new Shape(); + + // Vertical shaft (centered horizontally) + shape.rect((totalW - shaftW) / 2, 0, shaftW, shaftH); + + // Arrowhead triangle (pointing down) + shape.moveTo(0, shaftH); + shape.lineTo(totalW / 2, shaftH + headH); + shape.lineTo(totalW, shaftH); + shape.close(); + + // Tray bar at bottom + shape.rect(0, shaftH + headH + gap, totalW, barH); + + return new Path(shape, { fill: TrackLabColors.textOnDarkProperty }); +} + +/** + * Upload icon: folder shape indicating "open a file". + */ +export function makeUploadIcon(): Node { + const folderShape = new Shape() + .moveTo(0, 3) + .lineTo(4, 3) + .lineTo(5.5, 0) + .lineTo(14, 0) + .lineTo(14, 10) + .lineTo(0, 10) + .close(); + return new Path(folderShape, { fill: TrackLabColors.textOnDarkProperty }); +} + +/** + * Plus icon: two perpendicular lines forming a "+" glyph. + * Used in "Add Track" buttons. + */ +export function makePlusIcon(): Node { + const size = 12; // size of the plus icon + const lw = 2; // line width + const half = size / 2; + + const horizontal = new Line(-half, 0, half, 0, { + stroke: TrackLabColors.textOnDarkProperty, + lineWidth: lw, + }); + const vertical = new Line(0, -half, 0, half, { + stroke: TrackLabColors.textOnDarkProperty, + lineWidth: lw, + }); + + return new Node({ children: [horizontal, vertical] }); +} + +/** + * Trash-can icon: body with lid, handle, and three vertical line slots. + * Used in "Remove Track" buttons. + */ +export function makeTrashIcon(): Node { + const lw = 1.2; // reduced from 1.5 + const bw = 8; // reduced from 10 + const bh = 9; // reduced from 11 + + const body = new Rectangle(0, 0, bw, bh, 1, 1, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: lw, + fill: null, + }); + const lid = new Rectangle(-1.5, -3.5, bw + 3, 3, 0, 0, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: lw, + fill: null, + }); + const handle = new Rectangle(2.5, -7, 5, 3.5, 1, 1, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: lw, + fill: null, + }); + const l1 = new Line(bw / 4, 2, bw / 4, bh - 2, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: 1, + }); + const l2 = new Line(bw / 2, 2, bw / 2, bh - 2, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: 1, + }); + const l3 = new Line((bw * 3) / 4, 2, (bw * 3) / 4, bh - 2, { + stroke: TrackLabColors.trashIconProperty, + lineWidth: 1, + }); + + return new Node({ children: [handle, lid, body, l1, l2, l3] }); +} diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 609dea8..46a9d88 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -1,425 +1,104 @@ /** * SimModel.ts * - * Holds all reactive state for the physics video analysis simulation including - * video playback, tracks, and auto-tracking configuration. Overlay tool state - * (coordinate system, calibration, measuring tape, angle tool) is delegated - * to OverlayToolsModel. + * Thin coordinator that composes four focused sub-models: + * + * model.playback – VideoPlaybackModel: timing, frame rate, display transform + * model.sources – VideoSourceModel: webcam recordings, uploads, active blob + * model.tracking – TrackingModel: particle tracks, kinematics, OpenCV facade + * model.overlayTools – OverlayToolsModel: axes, calibration, measurement tools + * + * SimModel itself handles cross-cutting orchestration only: + * - activating a video source (coordinates sources + playback properties) + * - converting pixel → model coordinates (uses overlayTools MVT) + * - re-expressing track points when the model-view transform changes */ -import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; -import { Dimension2, Matrix3, Range, type Transform3, Vector2 } from "scenerystack/dot"; -import { TRACK_COLORS } from "../../TrackLabColors.js"; -import { - MAX_TRACKS, - TRACK_SYMBOL_FIRST_CODE, - TRACK_SYMBOL_LAST_CODE, - VIDEO_HEIGHT, - VIDEO_WIDTH, -} from "../../TrackLabConstants.js"; +import type { Transform3, Vector2 } from "scenerystack/dot"; import trackLab from "../../TrackLabNamespace.js"; -import { OpenCVTracker, type TrackerRegion } from "../../tracking/OpenCVTracker.js"; -import { computeTrackKinematics } from "./KinematicsComputer.js"; import { OverlayToolsModel } from "./OverlayToolsModel.js"; -import type { Track, TrackKinematics, TrackPoint } from "./Track.js"; - -// ── Frame rate options ───────────────────────────────────────────────────── -export const FRAME_RATE_OPTIONS = [15, 24, 25, 29.97, 30, 50, 60] as const; -export const DEFAULT_FRAME_RATE = 30; -export const FRAME_RATE_RANGE = new Range(1, 120); - -// ── Playback speed multiplier ────────────────────────────────────────────── -// Stores the actual rate multiplier (1 = normal, 0.5 = slow, 2 = fast). -// The view maps a TimeSpeed enum to one of these values; the model never -// imports scenery-phet, so it only sees the numeric rate. -export const DEFAULT_PLAYBACK_RATE = 1; -export const PLAYBACK_RATE_RANGE = new Range(0.1, 4); - -// ── Webcam recording entry ──────────────────────────────────────────────── -export type WebcamRecording = { - id: string; - blob: Blob; - num: number; - duration: number; - fps: number; - timestamp: number; -}; - -// ── Uploaded video entry ───────────────────────────────────────────────── -export type UploadedVideo = { - id: string; - blob: Blob; - num: number; - name: string; - duration: number; - fps: number; - /** Actual frame count from countWebmFrames / getAnimatedWebPInfo, or undefined if unknown. */ - frameCount?: number; - timestamp: number; -}; +import { TrackingModel } from "./TrackingModel.js"; +import { VideoPlaybackModel } from "./VideoPlaybackModel.js"; +import { type UploadedVideo, VideoSourceModel, type WebcamRecording } from "./VideoSourceModel.js"; + +// ── Re-export constants so existing imports from SimModel continue to work ─ +export { + DEFAULT_FRAME_RATE, + DEFAULT_PLAYBACK_RATE, + FRAME_RATE_OPTIONS, + FRAME_RATE_RANGE, + PLAYBACK_RATE_RANGE, +} from "./VideoPlaybackModel.js"; +export type { UploadedVideo, WebcamRecording } from "./VideoSourceModel.js"; export class SimModel { - // ── Overlay tools (coordinate system, calibration, measuring tape, angle) ── + // ── Composed sub-models ─────────────────────────────────────────────── public readonly overlayTools = new OverlayToolsModel(); + public readonly playback = new VideoPlaybackModel(); + public readonly sources = new VideoSourceModel(); + public readonly tracking = new TrackingModel(); - public readonly isPlayingProperty = new BooleanProperty(false); - public readonly currentTimeProperty = new NumberProperty(0, { - range: new Range(0, Number.MAX_VALUE), - }); - public readonly durationProperty = new Property(0); - - // ── Frame rate (user-settable, default 30 fps) ───────────────────────── - public readonly frameRateProperty = new NumberProperty(DEFAULT_FRAME_RATE, { - range: FRAME_RATE_RANGE, - }); - - // Track whether the current video is from webcam (allows FPS editing) - public readonly isWebcamVideoProperty = new BooleanProperty(false); - - // ── Webcam recordings storage ────────────────────────────────────────── - public readonly webcamRecordingsProperty = new Property([]); - public readonly currentWebcamBlobProperty = new Property(null); - private nextRecordingNumber = 1; - - // ── Uploaded videos storage ──────────────────────────────────────────── - public readonly uploadedVideosProperty = new Property([]); - private nextUploadNumber = 1; - - // ── Playback speed multiplier (1 = normal, 0.5 = slow, 2 = fast) ──────── - // The view maps its TimeSpeed enum to this value; the model stays free of - // any scenery-phet dependency. - public readonly playbackRateProperty = new NumberProperty(DEFAULT_PLAYBACK_RATE, { - range: PLAYBACK_RATE_RANGE, - }); - - // ── Exact frame count when known (0 = unknown; derive from duration × fps) ── - public readonly totalFrameCountProperty = new NumberProperty(0, { - range: new Range(0, Number.MAX_VALUE), - }); - - // Derived frame duration for convenience - public readonly frameDurationProperty: TReadOnlyProperty = new DerivedProperty( - [this.frameRateProperty], - (fps) => 1 / fps, - ); - - // ── Actual display dimensions of the loaded video ───────────────────── - // Updated in VideoPlayerNode once loadedmetadata fires. Starts at the - // max dimensions; overlays and the OpenCV canvas react to changes. - public readonly videoDimensionsProperty = new Property(new Dimension2(VIDEO_WIDTH, VIDEO_HEIGHT)); - - // ── OpenCV Tracker (computational service) ──────────────────────────── - private readonly tracker = new OpenCVTracker(VIDEO_WIDTH, VIDEO_HEIGHT); - - // ── Video display transform (translate + uniform scale) ─────────────── - // Applied to the video content layer so the video and all overlays - // (tools, digitized points) can be dragged and magnified together while - // keeping the same aspect ratio. - public readonly videoScaleProperty = new NumberProperty(1, { - range: new Range(0.5, 4), - }); - public readonly videoOffsetProperty = new Property(Vector2.ZERO); - public readonly videoTransformProperty: TReadOnlyProperty = new DerivedProperty( - [this.videoScaleProperty, this.videoOffsetProperty], - (scale, offset) => Matrix3.translationFromVector(offset).timesMatrix(Matrix3.scaling(scale)), - ); - - // ── Video loaded (true once a finite-duration video is loaded) ─────────── - public readonly videoLoadedProperty: TReadOnlyProperty = new DerivedProperty( - [this.durationProperty], - (d) => d > 0, - ); - - // ── Manual particle tracks ──────────────────────────────────────────── - // INVARIANT: every TrackPoint's (x, y) is expressed in the coordinate - // system defined by the *current* overlayTools.modelViewTransformProperty. - // Whenever the MVT changes, retransformTrackPoints() re-expresses every stored - // point in the new coordinate system so that each point remains visually - // anchored to the same pixel on the video. - public readonly tracksProperty = new Property([]); - public readonly activeTrackIdProperty = new Property(null); - public readonly canAddTrackProperty: TReadOnlyProperty = new DerivedProperty( - [this.tracksProperty], - (tracks) => tracks.length < MAX_TRACKS, - ); - - // ── Derived kinematics for all tracks ─────────────────────────────────── - // Cache keyed by track ID; only recomputes kinematics for tracks whose - // point array reference has changed since the last derivation. - private readonly kinematicsCache = new Map(); - - public readonly trackKinematicsProperty: TReadOnlyProperty = new DerivedProperty( - [this.tracksProperty], - (tracks) => - tracks.map((track) => { - const cached = this.kinematicsCache.get(track.id); - if (cached && cached.points === track.points) { - return cached.kinematics; - } - const kinematics = computeTrackKinematics(track); - this.kinematicsCache.set(track.id, { points: track.points, kinematics }); - return kinematics; - }), - ); - // Symbols are assigned sequentially (A → Z) and intentionally not reused - // after a track is removed. - private nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; - - // Cache the previous MVT to compute retransforms when it changes. + // Cache the previous MVT so we can compute retransforms when it changes. private prevModelViewTransform: Transform3 | null = null; public constructor() { + // Whenever the model-view transform changes, re-express all stored track + // points in the new coordinate system so they remain visually anchored to + // the same pixel on the video. this.overlayTools.modelViewTransformProperty.lazyLink((newMvt) => { if (this.prevModelViewTransform !== null) { - this.retransformTrackPoints(this.prevModelViewTransform, newMvt); + this.tracking.retransformTrackPoints(this.prevModelViewTransform, newMvt); } this.prevModelViewTransform = newMvt; }); } - /** - * Re-expresses every stored track point in the coordinate system of `newMVT`, - * preserving the pixel-space position of each point on the video. - */ - private retransformTrackPoints(prevMvt: Transform3, newMvt: Transform3): void { - const tracks = this.tracksProperty.value; - if (tracks.length === 0) { - return; - } - - this.tracksProperty.value = tracks.map((track) => ({ - ...track, - points: track.points.map((pt) => { - const pixelPos = prevMvt.transformPosition2(new Vector2(pt.x, pt.y)); - const newModelPt = newMvt.inversePosition2(pixelPos); - return { ...pt, x: newModelPt.x, y: newModelPt.y }; - }), - })); - } - - /** - * Create a new track labelled with the next available letter (A–Z) and a - * unique color index. Does nothing if the track limit or symbol limit is reached. - */ - public addTrack(): void { - if (this.tracksProperty.value.length >= MAX_TRACKS || this.nextSymbolCode > TRACK_SYMBOL_LAST_CODE) { - return; - } - const symbol = String.fromCharCode(this.nextSymbolCode); - const colorIndex = (this.nextSymbolCode - TRACK_SYMBOL_FIRST_CODE) % TRACK_COLORS.length; - this.nextSymbolCode++; - - const track: Track = { - id: `track-${symbol}`, - symbol, - colorIndex, - points: [], - }; - - const tracks = [...this.tracksProperty.value, track]; - tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0)); - this.tracksProperty.value = tracks; - } - - /** - * Remove the track with the given `id`. If that track is currently active, - * `activeTrackIdProperty` is cleared to null first. - */ - public removeTrack(id: string): void { - if (this.activeTrackIdProperty.value === id) { - this.activeTrackIdProperty.value = null; - } - this.tracksProperty.value = this.tracksProperty.value.filter((t) => t.id !== id); - } - /** * Convert a point from pixel/scene space to model coordinates using the * current model-view transform. - * - * @param pixelPoint - A point in scene/pixel coordinate space. - * @returns The equivalent position in model (real-world) coordinates. */ public pixelToModelCoords(pixelPoint: Vector2): Vector2 { return this.overlayTools.modelViewTransformProperty.value.inversePosition2(pixelPoint); } - /** - * Record a digitized position for `frame` on the track identified by `id`. - */ - public addPointToTrack(id: string, frame: number, time: number, x: number, y: number): void { - const tracks = this.tracksProperty.value.map((track) => { - if (track.id !== id) { - return track; - } - - const existingIndex = track.points.findIndex((p) => p.frame === frame); - if (existingIndex !== -1) { - const updatedPoints = [...track.points]; - updatedPoints[existingIndex] = { frame, time, x, y }; - return { ...track, points: updatedPoints }; - } - - const point: TrackPoint = { frame, time, x, y }; - const updated: Track = { ...track, points: [...track.points, point] }; - return updated; - }); - this.tracksProperty.value = tracks; - } - - // ── Tracker facade ────────────────────────────────────────────────────── - // Views interact with the tracker exclusively through these methods so that - // the tracker implementation stays encapsulated inside the model layer. - - /** True once a template has been captured and frame-to-frame tracking can begin. */ - public get isTrackerReady(): boolean { - return this.tracker.ready; - } - - /** Reset tracking state. Cancels any in-flight operation and clears the template. */ - public resetTracker(): void { - this.tracker.dispose(); - } - - /** - * Resize the tracker's offscreen canvas to match the video element's display dimensions. - * Must be called whenever the displayed video size changes. - */ - public resizeTracker(width: number, height: number): void { - this.tracker.resize(width, height); - } - - /** - * Capture the tracking template from the current video frame within `region`. - * Resolves when the worker has processed the template and is ready to track. - */ - public async initTracker(video: HTMLVideoElement, region: TrackerRegion): Promise { - await this.tracker.initFromVideo(video, region); - } - - /** - * Match the stored template against the current video frame. - * Returns the center of the best match in video-pixel coordinates, or null. - */ - public async trackFrame(video: HTMLVideoElement): Promise<{ x: number; y: number } | null> { - return await this.tracker.track(video); - } - - // ── Track helpers ─────────────────────────────────────────────────────── - - /** - * Create a new track and immediately make it the active track. - * Does nothing if the track limit or symbol limit has been reached. - */ - public addTrackAndActivate(): void { - this.addTrack(); - const tracks = this.tracksProperty.value; - const newest = tracks[tracks.length - 1]; - if (newest) { - this.activeTrackIdProperty.value = newest.id; - } - } - // ── Video source activation ───────────────────────────────────────────── - // Each method sets all affected model properties in one call so that no + // Each method sets all affected sub-model properties atomically so that no // intermediate state is visible to subscribers. - /** - * Activate a webcam recording as the current video source. - * Sets all related properties atomically. - */ + /** Activate a webcam recording as the current video source. */ public activateRecording(recording: WebcamRecording): void { - this.isWebcamVideoProperty.value = true; - this.frameRateProperty.value = recording.fps; - this.totalFrameCountProperty.value = 0; - this.currentWebcamBlobProperty.value = recording.blob; + this.sources.isWebcamVideoProperty.value = true; + this.playback.frameRateProperty.value = recording.fps; + this.playback.totalFrameCountProperty.value = 0; + this.sources.currentWebcamBlobProperty.value = recording.blob; } - /** - * Activate an uploaded video as the current video source. - * Sets all related properties atomically. - */ + /** Activate an uploaded video as the current video source. */ public activateUpload(upload: UploadedVideo): void { - this.isWebcamVideoProperty.value = true; - this.frameRateProperty.value = upload.fps; - this.totalFrameCountProperty.value = upload.frameCount ?? 0; - this.currentWebcamBlobProperty.value = upload.blob; + this.sources.isWebcamVideoProperty.value = true; + this.playback.frameRateProperty.value = upload.fps; + this.playback.totalFrameCountProperty.value = upload.frameCount ?? 0; + this.sources.currentWebcamBlobProperty.value = upload.blob; } - /** - * Activate a bundled (sample) video as the current video source. - * Sets all related properties atomically. - */ + /** Activate a bundled (sample) video as the current video source. */ public activateBundledVideo(frameCount: number, fps: number): void { - this.isWebcamVideoProperty.value = false; - this.currentWebcamBlobProperty.value = null; - this.totalFrameCountProperty.value = frameCount; - this.frameRateProperty.value = fps; - } - - public addWebcamRecording(blob: Blob, duration: number, fps: number): WebcamRecording { - const num = this.nextRecordingNumber; - this.nextRecordingNumber++; - const recording: WebcamRecording = { - id: `recording-${num}`, - blob, - num, - duration, - fps, - timestamp: Date.now(), - }; - this.webcamRecordingsProperty.value = [...this.webcamRecordingsProperty.value, recording]; - return recording; - } - - public addUploadedVideo( - blob: Blob, - name: string, - duration: number, - fps = DEFAULT_FRAME_RATE, - frameCount?: number, - ): UploadedVideo { - const num = this.nextUploadNumber; - this.nextUploadNumber++; - const upload: UploadedVideo = { - id: `upload-${num}`, - blob, - num, - name, - duration, - fps, - ...(frameCount !== undefined ? { frameCount } : {}), - timestamp: Date.now(), - }; - this.uploadedVideosProperty.value = [...this.uploadedVideosProperty.value, upload]; - return upload; + this.sources.isWebcamVideoProperty.value = false; + this.sources.currentWebcamBlobProperty.value = null; + this.playback.totalFrameCountProperty.value = frameCount; + this.playback.frameRateProperty.value = fps; } public reset(): void { this.prevModelViewTransform = null; - this.kinematicsCache.clear(); - this.isPlayingProperty.reset(); - this.currentTimeProperty.reset(); - this.durationProperty.reset(); - this.frameRateProperty.reset(); - this.totalFrameCountProperty.reset(); - this.isWebcamVideoProperty.reset(); - this.webcamRecordingsProperty.value = []; - this.currentWebcamBlobProperty.value = null; - this.nextRecordingNumber = 1; - this.uploadedVideosProperty.value = []; - this.nextUploadNumber = 1; - this.playbackRateProperty.reset(); - this.videoScaleProperty.reset(); - this.videoOffsetProperty.reset(); - this.tracksProperty.value = []; - this.activeTrackIdProperty.value = null; - this.nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; - this.tracker.dispose(); + this.playback.reset(); + this.sources.reset(); + this.tracking.reset(); this.overlayTools.reset(); } public step(_dt: number): void { - // video playback is driven by the HTML video element; no model stepping needed + // Video playback is driven by the HTML video element; no model stepping needed. } } diff --git a/src/screen-name/model/TrackingModel.ts b/src/screen-name/model/TrackingModel.ts new file mode 100644 index 0000000..0cf86be --- /dev/null +++ b/src/screen-name/model/TrackingModel.ts @@ -0,0 +1,222 @@ +/** + * TrackingModel.ts + * + * Reactive state for particle track management and the OpenCV tracker facade. + * Extracted from SimModel so that track digitizing, kinematics caching, and + * auto-tracking logic are independent of video playback and source management. + * + * Track point coordinates are expressed in the model coordinate system defined + * by OverlayToolsModel.modelViewTransformProperty. When that transform changes, + * SimModel calls retransformTrackPoints() to keep every point anchored to the + * same pixel on the video. + */ + +import { DerivedProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { type Transform3, Vector2 } from "scenerystack/dot"; +import { TRACK_COLORS } from "../../TrackLabColors.js"; +import { + MAX_TRACKS, + TRACK_SYMBOL_FIRST_CODE, + TRACK_SYMBOL_LAST_CODE, + VIDEO_HEIGHT, + VIDEO_WIDTH, +} from "../../TrackLabConstants.js"; +import trackLab from "../../TrackLabNamespace.js"; +import { OpenCVTracker, type TrackerRegion } from "../../tracking/OpenCVTracker.js"; +import { computeTrackKinematics } from "./KinematicsComputer.js"; +import type { Track, TrackKinematics, TrackPoint } from "./Track.js"; + +/** + * Owns all reactive state for particle tracks, kinematics caching, and the + * OpenCV template-matching tracker facade. + */ +export class TrackingModel { + // ── Manual particle tracks ──────────────────────────────────────────── + // INVARIANT: every TrackPoint's (x, y) is expressed in the coordinate + // system defined by the *current* overlayTools.modelViewTransformProperty. + // Whenever the MVT changes, SimModel calls retransformTrackPoints() to + // re-express every stored point in the new coordinate system so that each + // point remains visually anchored to the same pixel on the video. + public readonly tracksProperty = new Property([]); + public readonly activeTrackIdProperty = new Property(null); + public readonly canAddTrackProperty: TReadOnlyProperty = new DerivedProperty( + [this.tracksProperty], + (tracks) => tracks.length < MAX_TRACKS, + ); + + // ── Derived kinematics for all tracks ─────────────────────────────────── + // Cache keyed by track ID; only recomputes kinematics for tracks whose + // point array reference has changed since the last derivation. + // + // CACHE INVARIANT: validity is determined by object identity + // (`cached.points === track.points`). This is correct because every + // mutation path (addPointToTrack, retransformTrackPoints) replaces the + // entire Track object and its points array, so a stale entry always has a + // different reference. If track IDs were ever reused the cache could return + // a stale entry; current code assigns symbols A–Z monotonically and never + // reuses them, so this cannot happen in practice. + private readonly kinematicsCache = new Map(); + + public readonly trackKinematicsProperty: TReadOnlyProperty = new DerivedProperty( + [this.tracksProperty], + (tracks) => + tracks.map((track) => { + const cached = this.kinematicsCache.get(track.id); + if (cached && cached.points === track.points) { + return cached.kinematics; + } + const kinematics = computeTrackKinematics(track); + this.kinematicsCache.set(track.id, { points: track.points, kinematics }); + return kinematics; + }), + ); + + // Symbols are assigned sequentially (A → Z) and intentionally not reused + // after a track is removed. + private nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; + + // ── OpenCV Tracker (computational service) ──────────────────────────── + private readonly tracker = new OpenCVTracker(VIDEO_WIDTH, VIDEO_HEIGHT); + + // ── Track mutation methods ──────────────────────────────────────────── + + /** + * Create a new track labelled with the next available letter (A–Z) and a + * unique color index. Does nothing if the track limit or symbol limit is reached. + */ + public addTrack(): void { + if (this.tracksProperty.value.length >= MAX_TRACKS || this.nextSymbolCode > TRACK_SYMBOL_LAST_CODE) { + return; + } + const symbol = String.fromCharCode(this.nextSymbolCode); + const colorIndex = (this.nextSymbolCode - TRACK_SYMBOL_FIRST_CODE) % TRACK_COLORS.length; + this.nextSymbolCode++; + + const track: Track = { + id: `track-${symbol}`, + symbol, + colorIndex, + points: [], + }; + + const tracks = [...this.tracksProperty.value, track]; + tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0)); + this.tracksProperty.value = tracks; + } + + /** + * Remove the track with the given `id`. If that track is currently active, + * `activeTrackIdProperty` is cleared to null first. + */ + public removeTrack(id: string): void { + if (this.activeTrackIdProperty.value === id) { + this.activeTrackIdProperty.value = null; + } + this.tracksProperty.value = this.tracksProperty.value.filter((t) => t.id !== id); + } + + /** + * Record a digitized position for `frame` on the track identified by `id`. + */ + public addPointToTrack(id: string, frame: number, time: number, x: number, y: number): void { + const tracks = this.tracksProperty.value.map((track) => { + if (track.id !== id) { + return track; + } + + const existingIndex = track.points.findIndex((p) => p.frame === frame); + if (existingIndex !== -1) { + const updatedPoints = [...track.points]; + updatedPoints[existingIndex] = { frame, time, x, y }; + return { ...track, points: updatedPoints }; + } + + const point: TrackPoint = { frame, time, x, y }; + const updated: Track = { ...track, points: [...track.points, point] }; + return updated; + }); + this.tracksProperty.value = tracks; + } + + /** + * Create a new track and immediately make it the active track. + * Does nothing if the track limit or symbol limit has been reached. + */ + public addTrackAndActivate(): void { + this.addTrack(); + const tracks = this.tracksProperty.value; + const newest = tracks[tracks.length - 1]; + if (newest) { + this.activeTrackIdProperty.value = newest.id; + } + } + + /** + * Re-expresses every stored track point in the coordinate system of `newMVT`, + * preserving the pixel-space position of each point on the video. + * Called by SimModel whenever the model-view transform changes. + */ + public retransformTrackPoints(prevMvt: Transform3, newMvt: Transform3): void { + const tracks = this.tracksProperty.value; + if (tracks.length === 0) { + return; + } + + this.tracksProperty.value = tracks.map((track) => ({ + ...track, + points: track.points.map((pt) => { + const pixelPos = prevMvt.transformPosition2(new Vector2(pt.x, pt.y)); + const newModelPt = newMvt.inversePosition2(pixelPos); + return { ...pt, x: newModelPt.x, y: newModelPt.y }; + }), + })); + } + + // ── Tracker facade ────────────────────────────────────────────────────── + // Views interact with the tracker exclusively through these methods so that + // the tracker implementation stays encapsulated inside the model layer. + + /** True once a template has been captured and frame-to-frame tracking can begin. */ + public get isTrackerReady(): boolean { + return this.tracker.ready; + } + + /** Reset tracking state. Cancels any in-flight operation and clears the template. */ + public resetTracker(): void { + this.tracker.dispose(); + } + + /** + * Resize the tracker's offscreen canvas to match the video element's display dimensions. + * Must be called whenever the displayed video size changes. + */ + public resizeTracker(width: number, height: number): void { + this.tracker.resize(width, height); + } + + /** + * Capture the tracking template from the current video frame within `region`. + * Resolves when the worker has processed the template and is ready to track. + */ + public async initTracker(video: HTMLVideoElement, region: TrackerRegion): Promise { + await this.tracker.initFromVideo(video, region); + } + + /** + * Match the stored template against the current video frame. + * Returns the center of the best match in video-pixel coordinates, or null. + */ + public async trackFrame(video: HTMLVideoElement): Promise<{ x: number; y: number } | null> { + return await this.tracker.track(video); + } + + public reset(): void { + this.kinematicsCache.clear(); + this.tracksProperty.value = []; + this.activeTrackIdProperty.value = null; + this.nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; + this.tracker.dispose(); + } +} + +trackLab.register("TrackingModel", TrackingModel); diff --git a/src/screen-name/model/VideoPlaybackModel.ts b/src/screen-name/model/VideoPlaybackModel.ts new file mode 100644 index 0000000..cf880b0 --- /dev/null +++ b/src/screen-name/model/VideoPlaybackModel.ts @@ -0,0 +1,96 @@ +/** + * VideoPlaybackModel.ts + * + * Reactive state for video playback: current time, duration, frame rate, + * playback speed, display dimensions, and the video-layer transform + * (translate + uniform scale). Extracted from SimModel to keep playback + * concerns separate from track management and video source management. + */ + +import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { Dimension2, Matrix3, Range, Vector2 } from "scenerystack/dot"; +import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; +import trackLab from "../../TrackLabNamespace.js"; + +// ── Frame rate options ───────────────────────────────────────────────────── +export const FRAME_RATE_OPTIONS = [15, 24, 25, 29.97, 30, 50, 60] as const; +export const DEFAULT_FRAME_RATE = 30; +export const FRAME_RATE_RANGE = new Range(1, 120); + +// ── Playback speed multiplier ────────────────────────────────────────────── +// Stores the actual rate multiplier (1 = normal, 0.5 = slow, 2 = fast). +// The view maps a TimeSpeed enum to one of these values; the model never +// imports scenery-phet, so it only sees the numeric rate. +export const DEFAULT_PLAYBACK_RATE = 1; +export const PLAYBACK_RATE_RANGE = new Range(0.1, 4); + +/** + * Owns all reactive state related to video playback: timing, frame rate, + * playback speed, display dimensions, and the video-layer transform. + */ +export class VideoPlaybackModel { + public readonly isPlayingProperty = new BooleanProperty(false); + public readonly currentTimeProperty = new NumberProperty(0, { + range: new Range(0, Number.MAX_VALUE), + }); + public readonly durationProperty = new Property(0); + + // ── Frame rate (user-settable, default 30 fps) ───────────────────────── + public readonly frameRateProperty = new NumberProperty(DEFAULT_FRAME_RATE, { + range: FRAME_RATE_RANGE, + }); + + // ── Playback speed multiplier (1 = normal, 0.5 = slow, 2 = fast) ──────── + public readonly playbackRateProperty = new NumberProperty(DEFAULT_PLAYBACK_RATE, { + range: PLAYBACK_RATE_RANGE, + }); + + // ── Exact frame count when known (0 = unknown; derive from duration × fps) ── + public readonly totalFrameCountProperty = new NumberProperty(0, { + range: new Range(0, Number.MAX_VALUE), + }); + + // Derived frame duration for convenience + public readonly frameDurationProperty: TReadOnlyProperty = new DerivedProperty( + [this.frameRateProperty], + (fps) => 1 / fps, + ); + + // ── Actual display dimensions of the loaded video ───────────────────── + // Updated in VideoPlayerNode once loadedmetadata fires. Starts at the + // max dimensions; overlays and the OpenCV canvas react to changes. + public readonly videoDimensionsProperty = new Property(new Dimension2(VIDEO_WIDTH, VIDEO_HEIGHT)); + + // ── Video display transform (translate + uniform scale) ─────────────── + // Applied to the video content layer so the video and all overlays + // (tools, digitized points) can be dragged and magnified together while + // keeping the same aspect ratio. + public readonly videoScaleProperty = new NumberProperty(1, { + range: new Range(0.5, 4), + }); + public readonly videoOffsetProperty = new Property(Vector2.ZERO); + public readonly videoTransformProperty: TReadOnlyProperty = new DerivedProperty( + [this.videoScaleProperty, this.videoOffsetProperty], + (scale, offset) => Matrix3.translationFromVector(offset).timesMatrix(Matrix3.scaling(scale)), + ); + + // ── Video loaded (true once a finite-duration video is loaded) ─────────── + public readonly videoLoadedProperty: TReadOnlyProperty = new DerivedProperty( + [this.durationProperty], + (d) => d > 0, + ); + + public reset(): void { + this.isPlayingProperty.reset(); + this.currentTimeProperty.reset(); + this.durationProperty.reset(); + this.frameRateProperty.reset(); + this.playbackRateProperty.reset(); + this.totalFrameCountProperty.reset(); + this.videoDimensionsProperty.reset(); + this.videoScaleProperty.reset(); + this.videoOffsetProperty.reset(); + } +} + +trackLab.register("VideoPlaybackModel", VideoPlaybackModel); diff --git a/src/screen-name/model/VideoSourceModel.ts b/src/screen-name/model/VideoSourceModel.ts new file mode 100644 index 0000000..f517a71 --- /dev/null +++ b/src/screen-name/model/VideoSourceModel.ts @@ -0,0 +1,104 @@ +/** + * VideoSourceModel.ts + * + * Reactive state for video source management: webcam recordings, uploaded + * videos, the currently active video blob, and whether the active video is + * user-provided. Extracted from SimModel to keep source management concerns + * separate from playback and track management. + */ + +import { BooleanProperty, Property } from "scenerystack/axon"; +import trackLab from "../../TrackLabNamespace.js"; +import { DEFAULT_FRAME_RATE } from "./VideoPlaybackModel.js"; + +// ── Webcam recording entry ──────────────────────────────────────────────── +export type WebcamRecording = { + id: string; + blob: Blob; + num: number; + duration: number; + fps: number; + timestamp: number; +}; + +// ── Uploaded video entry ───────────────────────────────────────────────── +export type UploadedVideo = { + id: string; + blob: Blob; + num: number; + name: string; + duration: number; + fps: number; + /** Actual frame count from countWebmFrames / getAnimatedWebPInfo, or undefined if unknown. */ + frameCount?: number; + timestamp: number; +}; + +/** + * Owns all reactive state for video source management: the list of webcam + * recordings, the list of uploaded videos, the currently active blob, and + * the flag that indicates whether the current video is user-provided. + */ +export class VideoSourceModel { + // Track whether the current video is from webcam/upload (enables FPS editing + // and shows the download button). + public readonly isWebcamVideoProperty = new BooleanProperty(false); + + // ── Webcam recordings storage ────────────────────────────────────────── + public readonly webcamRecordingsProperty = new Property([]); + public readonly currentWebcamBlobProperty = new Property(null); + private nextRecordingNumber = 1; + + // ── Uploaded videos storage ──────────────────────────────────────────── + public readonly uploadedVideosProperty = new Property([]); + private nextUploadNumber = 1; + + public addWebcamRecording(blob: Blob, duration: number, fps: number): WebcamRecording { + const num = this.nextRecordingNumber; + this.nextRecordingNumber++; + const recording: WebcamRecording = { + id: `recording-${num}`, + blob, + num, + duration, + fps, + timestamp: Date.now(), + }; + this.webcamRecordingsProperty.value = [...this.webcamRecordingsProperty.value, recording]; + return recording; + } + + public addUploadedVideo( + blob: Blob, + name: string, + duration: number, + fps = DEFAULT_FRAME_RATE, + frameCount?: number, + ): UploadedVideo { + const num = this.nextUploadNumber; + this.nextUploadNumber++; + const upload: UploadedVideo = { + id: `upload-${num}`, + blob, + num, + name, + duration, + fps, + ...(frameCount !== undefined ? { frameCount } : {}), + timestamp: Date.now(), + }; + this.uploadedVideosProperty.value = [...this.uploadedVideosProperty.value, upload]; + return upload; + } + + public reset(): void { + this.isWebcamVideoProperty.reset(); + this.webcamRecordingsProperty.value = []; + this.currentWebcamBlobProperty.value = null; + this.nextRecordingNumber = 1; + this.uploadedVideosProperty.value = []; + this.nextUploadNumber = 1; + } +} + +trackLab.register("VideoSourceModel", VideoSourceModel); diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 54816df..9623af2 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -171,7 +171,7 @@ export class AutoTrackerNode extends Node { this.trailSize = 0; // Bump version so any in-flight initTracker call is discarded when it resolves. this.initVersion++; - this.model.resetTracker(); + this.model.tracking.resetTracker(); this.setCrosshairVisible(false); this.trailPath.shape = null; this.trailPath.visible = false; @@ -216,8 +216,8 @@ export class AutoTrackerNode extends Node { if (region.w > MIN_REGION_SIZE && region.h > MIN_REGION_SIZE) { // Auto-create a track if none is active - if (!this.model.activeTrackIdProperty.value) { - this.model.addTrackAndActivate(); + if (!this.model.tracking.activeTrackIdProperty.value) { + this.model.tracking.addTrackAndActivate(); } // initTracker is async (loads WASM on first call); tracking begins @@ -225,23 +225,23 @@ export class AutoTrackerNode extends Node { // 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 + this.model.tracking .initTracker(videoElement, region) .then(() => { if (this.initVersion !== capturedVersion) { // A newer drag has already started; discard this result. - this.model.resetTracker(); + this.model.tracking.resetTracker(); return; } // Guard against the race condition where the user removes the // active track while WASM was loading. If the track no longer // exists, abort tracking so the crosshair doesn't appear with // nowhere to record points. - const activeId = this.model.activeTrackIdProperty.value; + const activeId = this.model.tracking.activeTrackIdProperty.value; const trackStillExists = - activeId !== null && this.model.tracksProperty.value.some((t) => t.id === activeId); + activeId !== null && this.model.tracking.tracksProperty.value.some((t) => t.id === activeId); if (!trackStillExists) { - this.model.resetTracker(); + this.model.tracking.resetTracker(); this.hintText.visible = true; } }) @@ -269,7 +269,7 @@ export class AutoTrackerNode extends Node { hitArea.setRect(0, 0, dims.width, dims.height); centeredLabels.center = new Vector2(dims.width / 2, dims.height / 2); }; - model.videoDimensionsProperty.link(videoDimensionsListener); + model.playback.videoDimensionsProperty.link(videoDimensionsListener); // ── Track on every video frame ──────────────────────────────────────── // OpenCV template matching (track()) is a heavy synchronous operation. @@ -278,14 +278,14 @@ export class AutoTrackerNode extends Node { // event callbacks from piling up and freezing the main thread. const processFrame = async () => { this.pendingFrameId = 0; - if (!(this.visible && this.model.isTrackerReady)) { + if (!(this.visible && this.model.tracking.isTrackerReady)) { return; } this.trackInProgress = true; let pt: { x: number; y: number } | null = null; try { - pt = await this.model.trackFrame(videoElement); + pt = await this.model.tracking.trackFrame(videoElement); } catch { // Tracker was disposed mid-flight (e.g. new selection started); skip frame. return; @@ -306,21 +306,21 @@ export class AutoTrackerNode extends Node { this.updateTrackerVisuals(pt); // ── Record position to model if a track is active ───────────────── - const activeId = model.activeTrackIdProperty.value; + const activeId = model.tracking.activeTrackIdProperty.value; if (activeId) { const time = videoElement.currentTime; // 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); + const frame = Math.round(time * model.playback.frameRateProperty.value); // O(1) duplicate-frame check via Set (vs O(n) linear scan). if (!this.recordedFrames.has(frame)) { // Convert video-local pixel coords directly to model coords. // The MVT operates in video-local space, matching these coordinates. const modelPt = model.pixelToModelCoords(new Vector2(pt.x, pt.y)); - model.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); + model.tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); this.recordedFrames.add(frame); } } @@ -338,7 +338,7 @@ export class AutoTrackerNode extends Node { // track so frames from the previous track don't suppress recording on the // new one. const clearRecordedFrames = () => this.recordedFrames.clear(); - model.activeTrackIdProperty.lazyLink(clearRecordedFrames); + model.tracking.activeTrackIdProperty.lazyLink(clearRecordedFrames); // ── Show/hide based on combined "video loaded && autoTracking" ──────── const autoTrackingShownListener = (shown: boolean) => { @@ -357,10 +357,10 @@ export class AutoTrackerNode extends Node { videoElement.removeEventListener("timeupdate", onFrame); videoElement.removeEventListener("seeked", onFrame); this.cancelPendingFrame(); - model.activeTrackIdProperty.unlink(clearRecordedFrames); + model.tracking.activeTrackIdProperty.unlink(clearRecordedFrames); autoTrackingShownProperty.unlink(autoTrackingShownListener); - model.videoDimensionsProperty.unlink(videoDimensionsListener); - this.model.resetTracker(); + model.playback.videoDimensionsProperty.unlink(videoDimensionsListener); + this.model.tracking.resetTracker(); }; } @@ -403,7 +403,7 @@ export class AutoTrackerNode extends Node { public reset(): void { this.cancelPendingFrame(); this.trackInProgress = false; - this.model.resetTracker(); + this.model.tracking.resetTracker(); this.trailHead = 0; this.trailSize = 0; this.recordedFrames.clear(); diff --git a/src/screen-name/view/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index 166555c..6959209 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -289,12 +289,12 @@ export class CalibrationToolNode extends Node { this.pickable = !isDigitizing; this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; - model.activeTrackIdProperty.link(onActiveTrackChange); + model.tracking.activeTrackIdProperty.link(onActiveTrackChange); this.disposeCalibrationToolNode = () => { calibMultilink.dispose(); videoLoadedProperty.unlink(onVideoLoaded); - model.activeTrackIdProperty.unlink(onActiveTrackChange); + model.tracking.activeTrackIdProperty.unlink(onActiveTrackChange); rangePatternProperty.dispose(); buttonLabelProperty.dispose(); }; diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index 24e72ef..28ed332 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -265,13 +265,13 @@ export class CoordinateSystemNode extends Node { this.pickable = !isDigitizing; this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; - model.activeTrackIdProperty.link(onActiveTrackChange); + model.tracking.activeTrackIdProperty.link(onActiveTrackChange); this.disposeCoordinateSystemNode = () => { model.overlayTools.coordOriginProperty.unlink(onOriginChange); model.overlayTools.coordAngleProperty.unlink(onAngleChange); videoLoadedProperty.unlink(onVideoLoaded); - model.activeTrackIdProperty.unlink(onActiveTrackChange); + model.tracking.activeTrackIdProperty.unlink(onActiveTrackChange); }; } diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index d9d0570..474b10f 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -394,7 +394,7 @@ export class DataTableNode extends Panel { accessibleName: a11yStrings.exportCSVStringProperty, baseColor: TrackLabColors.exportButtonProperty, listener: () => { - const tracks = model.tracksProperty.value; + const tracks = model.tracking.tracksProperty.value; const unit = unitProperty.value; const csv = generateCsv(tracks, unit, getLabels()); @@ -489,7 +489,7 @@ export class DataTableNode extends Panel { // ~30 times/s, so avoiding unnecessary full DOM rebuilds is critical. // biome-ignore lint/complexity/noExcessiveCognitiveComplexity: intentionally complex — must handle structural and incremental updates efficiently const rebuildTable = () => { - const tracks = model.tracksProperty.value; + const tracks = model.tracking.tracksProperty.value; const unit = unitProperty.value; const trackIds = tracks.map((t) => t.id); @@ -564,7 +564,7 @@ export class DataTableNode extends Panel { // ── Reactive updates ───────────────────────────────────────────────────── const tracksListener = () => rebuildTable(); - model.tracksProperty.link(tracksListener); + model.tracking.tracksProperty.link(tracksListener); const unitListener = () => rebuildTable(); unitProperty.link(unitListener); @@ -572,7 +572,7 @@ export class DataTableNode extends Panel { // Colour profile and locale changes require a full rebuild because cell // colours and label strings are baked into the DOM; they are not captured // by the track-ID / unit structural-change check above. - const fullRebuild = () => doFullRebuild(model.tracksProperty.value, unitProperty.value); + const fullRebuild = () => doFullRebuild(model.tracking.tracksProperty.value, unitProperty.value); const tableHeaderBgListener = () => fullRebuild(); TrackLabColors.tableHeaderBackgroundProperty.lazyLink(tableHeaderBgListener); @@ -684,7 +684,7 @@ export class DataTableNode extends Panel { // Store cleanup function this.disposeDataTable = () => { resizeObserver.disconnect(); - model.tracksProperty.unlink(tracksListener); + model.tracking.tracksProperty.unlink(tracksListener); unitProperty.unlink(unitListener); TrackLabColors.tableHeaderBackgroundProperty.unlink(tableHeaderBgListener); dataTableStrings.frameStringProperty.unlink(frameStringListener); diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index 0588bd3..503c0e9 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -236,13 +236,13 @@ export class DigitizingOverlayNode extends Node { const videoDimensionsListener = (dims: Dimension2) => { digitizingOverlay.setRect(0, 0, dims.width, dims.height); }; - model.videoDimensionsProperty.link(videoDimensionsListener); + model.playback.videoDimensionsProperty.link(videoDimensionsListener); const updateMagnifierAtLastPt = () => { if (!(lastLocalPt && magnifierNode.visible)) { return; } - const { width: overlayW, height: overlayH } = model.videoDimensionsProperty.value; + const { width: overlayW, height: overlayH } = model.playback.videoDimensionsProperty.value; const magX = Math.max(0, Math.min(lastLocalPt.x - MAG_SIZE / 2, overlayW - MAG_SIZE)); const magY = Math.max(0, Math.min(lastLocalPt.y - MAG_SIZE / 2, overlayH - MAG_SIZE)); const crosshairX = lastLocalPt.x - magX; @@ -257,7 +257,7 @@ export class DigitizingOverlayNode extends Node { cursorNode.translation = localPt; cursorNode.visible = true; - const { width: overlayW, height: overlayH } = model.videoDimensionsProperty.value; + const { width: overlayW, height: overlayH } = model.playback.videoDimensionsProperty.value; const magX = Math.max(0, Math.min(localPt.x - MAG_SIZE / 2, overlayW - MAG_SIZE)); const magY = Math.max(0, Math.min(localPt.y - MAG_SIZE / 2, overlayH - MAG_SIZE)); magnifierNode.x = magX; @@ -286,10 +286,10 @@ export class DigitizingOverlayNode extends Node { const trackPaths = new Map(); // track id → Path const rebuildMarks = () => { - const frameDuration = model.frameDurationProperty.value; - const currentFrame = Math.round(model.currentTimeProperty.value / frameDuration); + const frameDuration = model.playback.frameDurationProperty.value; + const currentFrame = Math.round(model.playback.currentTimeProperty.value / frameDuration); const mvt = model.overlayTools.modelViewTransformProperty.value; - const tracks = model.tracksProperty.value; + const tracks = model.tracking.tracksProperty.value; const activeTrackIds = new Set(tracks.map((t) => t.id)); // Update or create one Path per track. @@ -324,16 +324,16 @@ export class DigitizingOverlayNode extends Node { rebuildMarks(); updateMagnifierAtLastPt(); }; - model.currentTimeProperty.link(currentTimeListener); + model.playback.currentTimeProperty.link(currentTimeListener); const tracksListener = () => rebuildMarks(); - model.tracksProperty.link(tracksListener); + model.tracking.tracksProperty.link(tracksListener); const mvtListener = () => rebuildMarks(); model.overlayTools.modelViewTransformProperty.link(mvtListener); const frameRateListener = () => rebuildMarks(); - model.frameRateProperty.link(frameRateListener); + model.playback.frameRateProperty.link(frameRateListener); const activeTrackListener = (activeId: string | null) => { digitizingOverlay.visible = activeId !== null; @@ -341,7 +341,7 @@ export class DigitizingOverlayNode extends Node { cursorNode.visible = false; } }; - model.activeTrackIdProperty.link(activeTrackListener); + model.tracking.activeTrackIdProperty.link(activeTrackListener); const magnifyListener = (magnify: boolean) => { if (!magnify) { @@ -356,24 +356,24 @@ export class DigitizingOverlayNode extends Node { if (!event) { return; } - const activeId = model.activeTrackIdProperty.value; + const activeId = model.tracking.activeTrackIdProperty.value; if (!activeId) { return; } - const track = model.tracksProperty.value.find((t) => t.id === activeId); + const track = model.tracking.tracksProperty.value.find((t) => t.id === activeId); if (!track) { return; } const localPt = digitizingOverlay.globalToLocalPoint(event.pointer.point); - const time = model.currentTimeProperty.value; - const frame = Math.round(time * model.frameRateProperty.value); + const time = model.playback.currentTimeProperty.value; + const frame = Math.round(time * model.playback.frameRateProperty.value); const modelPt = model.pixelToModelCoords(localPt); - model.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); + model.tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); onPointAdded(); }, tandem: Tandem.OPT_OUT, @@ -389,12 +389,12 @@ export class DigitizingOverlayNode extends Node { TrackLabColors.digitizingMagnifierBorderProperty.unlink(magBorderListener); TrackLabColors.digitizingMagnifierCrosshairProperty.unlink(magCrosshairListener); TrackLabColors.digitizingMagnifierShadowProperty.unlink(magShadowListener); - model.videoDimensionsProperty.unlink(videoDimensionsListener); - model.currentTimeProperty.unlink(currentTimeListener); - model.tracksProperty.unlink(tracksListener); + model.playback.videoDimensionsProperty.unlink(videoDimensionsListener); + model.playback.currentTimeProperty.unlink(currentTimeListener); + model.tracking.tracksProperty.unlink(tracksListener); model.overlayTools.modelViewTransformProperty.unlink(mvtListener); - model.frameRateProperty.unlink(frameRateListener); - model.activeTrackIdProperty.unlink(activeTrackListener); + model.playback.frameRateProperty.unlink(frameRateListener); + model.tracking.activeTrackIdProperty.unlink(activeTrackListener); model.overlayTools.magnifyVideoProperty.unlink(magnifyListener); for (const path of trackPaths.values()) { path.dispose(); diff --git a/src/screen-name/view/KinematicsGraphNode.ts b/src/screen-name/view/KinematicsGraphNode.ts index 78ae997..c9a6973 100644 --- a/src/screen-name/view/KinematicsGraphNode.ts +++ b/src/screen-name/view/KinematicsGraphNode.ts @@ -126,14 +126,14 @@ export class KinematicsGraphNode extends Node { // Build new checkbox panel with updated tracks this.rebuildTrackCheckboxes(); }; - model.tracksProperty.link(tracksListener); + model.tracking.tracksProperty.link(tracksListener); // Update graph when selected tracks change or kinematics update const selectedTracksListener = () => this.updateGraph(); this.selectedTracksProperty.link(selectedTracksListener); const kinematicsListener = () => this.updateGraph(); - model.trackKinematicsProperty.link(kinematicsListener); + model.tracking.trackKinematicsProperty.link(kinematicsListener); // Update graph when axis selection changes const xPropertyListener = () => this.updateGraph(); @@ -164,7 +164,7 @@ export class KinematicsGraphNode extends Node { const videoLoadedListener = (loaded: boolean) => { this.visible = loaded; }; - model.videoLoadedProperty.link(videoLoadedListener); + model.playback.videoLoadedProperty.link(videoLoadedListener); // Update checkbox positions when graph bounds change (e.g., after resize) const graphBoundsListener = () => { @@ -174,15 +174,15 @@ export class KinematicsGraphNode extends Node { // Store cleanup function this.disposeKinematicsGraph = () => { - model.tracksProperty.unlink(tracksListener); + model.tracking.tracksProperty.unlink(tracksListener); this.selectedTracksProperty.unlink(selectedTracksListener); - model.trackKinematicsProperty.unlink(kinematicsListener); + model.tracking.trackKinematicsProperty.unlink(kinematicsListener); this.graph.getXPropertyProperty().unlink(xPropertyListener); this.graph.getYPropertyProperty().unlink(yPropertyListener); model.overlayTools.calibUnitProperty.unlink(distanceUnitListener); preferencesModel.showVelocityInGraphProperty.unlink(velocityPrefListener); preferencesModel.showAccelerationInGraphProperty.unlink(accelerationPrefListener); - model.videoLoadedProperty.unlink(videoLoadedListener); + model.playback.videoLoadedProperty.unlink(videoLoadedListener); this.graph.localBoundsProperty.unlink(graphBoundsListener); for (const [, { checkbox, property }] of this.trackCheckboxes) { checkbox.dispose(); @@ -204,7 +204,7 @@ export class KinematicsGraphNode extends Node { * Positions it in the upper right corner of the graph. */ private rebuildTrackCheckboxes(): void { - const tracks = this.model.tracksProperty.value; + const tracks = this.model.tracking.tracksProperty.value; if (tracks.length === 0) { this.trackCheckboxPanel.children = []; @@ -291,8 +291,8 @@ export class KinematicsGraphNode extends Node { return; } - const kinematics = this.model.trackKinematicsProperty.value; - const tracks = this.model.tracksProperty.value; + const kinematics = this.model.tracking.trackKinematicsProperty.value; + const tracks = this.model.tracking.tracksProperty.value; // Plot each selected track separately with its own color for (const trackId of selectedIds) { diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 4a33007..cf827ad 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -68,7 +68,7 @@ export class PlaybackControlsNode extends HBox { // view → model const onSpeedChange = (speed: TimeSpeed) => { - model.playbackRateProperty.value = speedMap.get(speed) ?? SPEED_NORMAL; + model.playback.playbackRateProperty.value = speedMap.get(speed) ?? SPEED_NORMAL; }; timeSpeedProperty.link(onSpeedChange); @@ -76,14 +76,14 @@ export class PlaybackControlsNode extends HBox { const onRateChange = (rate: number) => { timeSpeedProperty.value = rateToSpeed.get(rate) ?? TimeSpeed.NORMAL; }; - model.playbackRateProperty.lazyLink(onRateChange); + model.playback.playbackRateProperty.lazyLink(onRateChange); // ── TimeControlNode: play/pause + step back + step forward + speed ───── - const timeControlNode = new TimeControlNode(model.isPlayingProperty, { + const timeControlNode = new TimeControlNode(model.playback.isPlayingProperty, { timeSpeedProperty: timeSpeedProperty, timeSpeeds: [TimeSpeed.NORMAL, TimeSpeed.SLOW], speedRadioButtonGroupPlacement: "left", - enabledProperty: model.videoLoadedProperty, + enabledProperty: model.playback.videoLoadedProperty, tandem: Tandem.OPT_OUT, playPauseStepButtonOptions: { includeStepBackwardButton: true, @@ -103,7 +103,7 @@ export class PlaybackControlsNode extends HBox { // ── Scrubber ─────────────────────────────────────────────────────────── // Create mutable range that will be updated when duration changes - const initDuration = model.durationProperty.value; + const initDuration = model.playback.durationProperty.value; this.scrubberRange = new Range(0, Number.isFinite(initDuration) && initDuration > 0 ? initDuration : 1); /** @@ -127,7 +127,7 @@ export class PlaybackControlsNode extends HBox { // Helper to create/recreate the scrubber with tick marks const createScrubber = (): HSlider => { - const newScrubber = new HSlider(model.currentTimeProperty, this.scrubberRange, { + const newScrubber = new HSlider(model.playback.currentTimeProperty, this.scrubberRange, { trackSize: new Dimension2(SCRUBBER_TRACK_WIDTH, SCRUBBER_TRACK_HEIGHT), thumbSize: new Dimension2(SCRUBBER_THUMB_WIDTH, SCRUBBER_THUMB_HEIGHT), thumbTouchAreaXDilation: 6, @@ -146,16 +146,16 @@ export class PlaybackControlsNode extends HBox { endDrag: () => { this.isScrubbing = false; }, - enabledProperty: model.videoLoadedProperty, + enabledProperty: model.playback.videoLoadedProperty, accessibleName: a11yStrings.videoScrubberStringProperty, }); // Add tick marks based on calculated intervals - const duration = model.durationProperty.value; - const frameRate = model.frameRateProperty.value; + const duration = model.playback.durationProperty.value; + const frameRate = model.playback.frameRateProperty.value; if (Number.isFinite(duration) && duration > 0 && frameRate > 0) { - const knownCount = model.totalFrameCountProperty.value; + const knownCount = model.playback.totalFrameCountProperty.value; const totalFrames = knownCount > 0 ? knownCount : Math.round(duration * frameRate); const { majorInterval, minorInterval } = calculateTickInterval(totalFrames); @@ -187,26 +187,26 @@ export class PlaybackControlsNode extends HBox { // Recreate scrubber with new tick marks this.replaceScrubber(createScrubber()); }; - model.durationProperty.link(durationListener); + model.playback.durationProperty.link(durationListener); // Recreate scrubber when frame rate changes const frameRateListener = () => { this.replaceScrubber(createScrubber()); }; - model.frameRateProperty.lazyLink(frameRateListener); + model.playback.frameRateProperty.lazyLink(frameRateListener); // Recreate scrubber when the exact frame count becomes known const totalFrameCountListener = () => { this.replaceScrubber(createScrubber()); }; - model.totalFrameCountProperty.lazyLink(totalFrameCountListener); + model.playback.totalFrameCountProperty.lazyLink(totalFrameCountListener); const onTimeChange = (time: number) => { if (this.isScrubbing) { videoElement.currentTime = time; } }; - model.currentTimeProperty.lazyLink(onTimeChange); + model.playback.currentTimeProperty.lazyLink(onTimeChange); // ── Time and frame info display ──────────────────────────────────────── const formatDuration = (seconds: number): string => { @@ -216,12 +216,17 @@ export class PlaybackControlsNode extends HBox { return `${seconds.toFixed(2)} ${playbackStrings.secondsUnitStringProperty.value}`; }; - const totalTimeTextProperty = new DerivedProperty([model.durationProperty], (duration: number) => + const totalTimeTextProperty = new DerivedProperty([model.playback.durationProperty], (duration: number) => formatDuration(duration), ); const frameCountTextProperty = new DerivedProperty( - [model.currentTimeProperty, model.durationProperty, model.frameRateProperty, model.totalFrameCountProperty], + [ + model.playback.currentTimeProperty, + model.playback.durationProperty, + model.playback.frameRateProperty, + model.playback.totalFrameCountProperty, + ], (time: number, duration: number, frameRate: number, totalFrameCount: number) => { if (duration <= 0) { return "0/0"; @@ -272,11 +277,11 @@ export class PlaybackControlsNode extends HBox { enabledAppearanceStrategy: (enabled: boolean, button: import("scenerystack/scenery").Node) => { button.opacity = enabled ? 1 : 0.45; }, - enabledProperty: model.videoLoadedProperty, + enabledProperty: model.playback.videoLoadedProperty, accessibleName: a11yStrings.rewindToStartStringProperty, listener: () => { - model.isPlayingProperty.value = false; - model.currentTimeProperty.value = 0; + model.playback.isPlayingProperty.value = false; + model.playback.currentTimeProperty.value = 0; videoElement.currentTime = 0; }, }, @@ -286,11 +291,11 @@ export class PlaybackControlsNode extends HBox { this.disposePlaybackControlsNode = () => { timeSpeedProperty.unlink(onSpeedChange); - model.playbackRateProperty.unlink(onRateChange); - model.currentTimeProperty.unlink(onTimeChange); - model.durationProperty.unlink(durationListener); - model.frameRateProperty.unlink(frameRateListener); - model.totalFrameCountProperty.unlink(totalFrameCountListener); + model.playback.playbackRateProperty.unlink(onRateChange); + model.playback.currentTimeProperty.unlink(onTimeChange); + model.playback.durationProperty.unlink(durationListener); + model.playback.frameRateProperty.unlink(frameRateListener); + model.playback.totalFrameCountProperty.unlink(totalFrameCountListener); timeSpeedProperty.dispose(); totalTimeTextProperty.dispose(); frameCountTextProperty.dispose(); diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 6e92f21..c51b216 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -53,19 +53,19 @@ export class SimScreenView extends ScreenView { // Combined visibility: video loaded AND user-toggled model flag. const axesShownProperty = new DerivedProperty( - [model.videoLoadedProperty, model.overlayTools.axesVisibleProperty], + [model.playback.videoLoadedProperty, model.overlayTools.axesVisibleProperty], (loaded, visible) => loaded && visible, ); const calibrationShownProperty = new DerivedProperty( - [model.videoLoadedProperty, model.overlayTools.calibrationVisibleProperty], + [model.playback.videoLoadedProperty, model.overlayTools.calibrationVisibleProperty], (loaded, visible) => loaded && visible, ); const measuringTapeShownProperty = new DerivedProperty( - [model.videoLoadedProperty, model.overlayTools.measuringTapeVisibleProperty], + [model.playback.videoLoadedProperty, model.overlayTools.measuringTapeVisibleProperty], (loaded, visible) => loaded && visible, ); const angleToolShownProperty = new DerivedProperty( - [model.videoLoadedProperty, model.overlayTools.angleToolVisibleProperty], + [model.playback.videoLoadedProperty, model.overlayTools.angleToolVisibleProperty], (loaded, visible) => loaded && visible, ); @@ -76,7 +76,7 @@ export class SimScreenView extends ScreenView { this.addChild(controlPanel); // ── Track list panel (beneath control panel) ───────────────────────── - const trackListPanel = new TrackListPanel(model, model.videoLoadedProperty); + const trackListPanel = new TrackListPanel(model, model.playback.videoLoadedProperty); this.addChild(trackListPanel); // Reactively reposition whenever controlPanel resizes (e.g. auto-tracking row toggles). controlPanel.boundsProperty.link(() => { @@ -108,7 +108,11 @@ export class SimScreenView extends ScreenView { this.videoPlayerNode.addVideoOverlay(angleToolNode); // ── Data table (top right, shifts left when window is wider than layoutBounds) ─ - const dataTableNode = new DataTableNode(model, model.videoLoadedProperty, model.overlayTools.calibUnitProperty); + const dataTableNode = new DataTableNode( + model, + model.playback.videoLoadedProperty, + model.overlayTools.calibUnitProperty, + ); this.addChild(dataTableNode); dataTableNode.top = this.layoutBounds.top + SCREEN_TOP_MARGIN; diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index 97c16db..df120a5 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -14,13 +14,14 @@ */ import { BooleanProperty, DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; -import { Circle, Line, Node, Rectangle, Text, VBox } from "scenerystack/scenery"; +import { Circle, Node, Rectangle, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { Checkbox, Panel } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import { createTrackLabButton } from "../../TrackLabButton.js"; import TrackLabColors, { getTrackColor } from "../../TrackLabColors.js"; +import { makePlusIcon, makeTrashIcon } from "../../TrackLabIcons.js"; const a11yStrings = StringManager.getInstance().getA11y(); @@ -49,63 +50,6 @@ const PANEL_Y_MARGIN = 8; // reduced from 10 const HEADER_FONT = new PhetFont({ size: 11, weight: "bold" }); // reduced from 13 const SYMBOL_FONT = new PhetFont({ size: 12, weight: "bold" }); // reduced from 15 -// ── Plus icon ─────────────────────────────────────────────────────────────── - -function makePlusIcon(): Node { - const size = 12; // size of the plus icon - const lw = 2; // line width - const half = size / 2; - - const horizontal = new Line(-half, 0, half, 0, { - stroke: TrackLabColors.textOnDarkProperty, - lineWidth: lw, - }); - const vertical = new Line(0, -half, 0, half, { - stroke: TrackLabColors.textOnDarkProperty, - lineWidth: lw, - }); - - return new Node({ children: [horizontal, vertical] }); -} - -// ── Trash-can icon ────────────────────────────────────────────────────────── - -function makeTrashIcon(): Node { - const lw = 1.2; // reduced from 1.5 - const bw = 8; // reduced from 10 - const bh = 9; // reduced from 11 - - const body = new Rectangle(0, 0, bw, bh, 1, 1, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: lw, - fill: null, - }); - const lid = new Rectangle(-1.5, -3.5, bw + 3, 3, 0, 0, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: lw, - fill: null, - }); - const handle = new Rectangle(2.5, -7, 5, 3.5, 1, 1, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: lw, - fill: null, - }); - const l1 = new Line(bw / 4, 2, bw / 4, bh - 2, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: 1, - }); - const l2 = new Line(bw / 2, 2, bw / 2, bh - 2, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: 1, - }); - const l3 = new Line((bw * 3) / 4, 2, (bw * 3) / 4, bh - 2, { - stroke: TrackLabColors.trashIconProperty, - lineWidth: 1, - }); - - return new Node({ children: [handle, lid, body, l1, l2, l3] }); -} - // ── Individual track row ──────────────────────────────────────────────────── class TrackRowNode extends Node { @@ -141,21 +85,21 @@ class TrackRowNode extends Node { symbolLabel.centerY = ROW_CY; // ── Checkbox: activates this track for video digitizing ─────────────── - const isDigitizingProperty = new BooleanProperty(model.activeTrackIdProperty.value === track.id); + const isDigitizingProperty = new BooleanProperty(model.tracking.activeTrackIdProperty.value === track.id); // Sync checkbox from model (when another track becomes active, uncheck this one). // Axon Properties deduplicate same-value writes, so no infinite loop can occur. const activeTrackListener = (activeId: string | null) => { isDigitizingProperty.value = activeId === track.id; }; - model.activeTrackIdProperty.link(activeTrackListener); + model.tracking.activeTrackIdProperty.link(activeTrackListener); // Sync model from checkbox const digitizingListener = (isDigitizing: boolean) => { if (isDigitizing) { - model.activeTrackIdProperty.value = track.id; - } else if (model.activeTrackIdProperty.value === track.id) { - model.activeTrackIdProperty.value = null; + model.tracking.activeTrackIdProperty.value = track.id; + } else if (model.tracking.activeTrackIdProperty.value === track.id) { + model.tracking.activeTrackIdProperty.value = null; } }; isDigitizingProperty.lazyLink(digitizingListener); @@ -171,7 +115,7 @@ class TrackRowNode extends Node { // ── Trash button (right side) ───────────────────────────────────────── const trashButton = createTrackLabButton(makeTrashIcon(), { baseColor: TrackLabColors.trashButtonBaseProperty, - listener: () => model.removeTrack(track.id), + listener: () => model.tracking.removeTrack(track.id), accessibleName: a11yStrings.removeTrackStringProperty.value.replace("{{symbol}}", track.symbol), }); trashButton.centerY = ROW_CY; @@ -185,7 +129,7 @@ class TrackRowNode extends Node { // Store cleanup function this.disposeTrackRowNode = () => { - model.activeTrackIdProperty.unlink(activeTrackListener); + model.tracking.activeTrackIdProperty.unlink(activeTrackListener); isDigitizingProperty.unlink(digitizingListener); checkbox.dispose(); trashButton.dispose(); @@ -217,13 +161,13 @@ export class TrackListPanel extends Panel { // ── "Add Track" button ──────────────────────────────────────────────── const addButtonEnabledProperty = new DerivedProperty( - [videoLoadedProperty, model.canAddTrackProperty], + [videoLoadedProperty, model.tracking.canAddTrackProperty], (loaded, canAdd) => loaded && canAdd, ); const addButton = createTrackLabButton(makePlusIcon(), { enabledProperty: addButtonEnabledProperty, - listener: () => model.addTrack(), + listener: () => model.tracking.addTrack(), accessibleName: trackListStrings.addTrackStringProperty, }); @@ -279,12 +223,12 @@ export class TrackListPanel extends Panel { } trackListVBox.children = tracks.map((track) => new TrackRowNode(track, model)); }; - model.tracksProperty.link(tracksListener); + model.tracking.tracksProperty.link(tracksListener); // Store cleanup function this.disposeTrackListPanel = () => { videoLoadedProperty.unlink(videoLoadedListener); - model.tracksProperty.unlink(tracksListener); + model.tracking.tracksProperty.unlink(tracksListener); // Dispose all track rows for (const child of trackListVBox.children) { if (child instanceof TrackRowNode) { diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 59d4b81..2d8c5e5 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -76,12 +76,12 @@ export class VideoPlayerNode extends Node { const updateDuration = () => { const d = this.videoElement.duration; if (d > 0) { - model.durationProperty.value = d; + model.playback.durationProperty.value = d; } }; const onLoadedMetadata = () => { - model.currentTimeProperty.value = 0; + model.playback.currentTimeProperty.value = 0; updateDuration(); // WebM files from MediaRecorder report Infinity until seeked to the end. // Trigger that seek here; durationchange (already listened to) will fire @@ -101,13 +101,13 @@ export class VideoPlayerNode extends Node { this.videoElement.addEventListener("durationchange", updateDuration); const onEnded = () => { - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; }; this.videoElement.addEventListener("ended", onEnded); // ── Auto-tracking overlay ────────────────────────────────────────────── const autoTrackingShownProperty = new DerivedProperty( - [model.videoLoadedProperty, model.overlayTools.autoTrackingProperty], + [model.playback.videoLoadedProperty, model.overlayTools.autoTrackingProperty], (loaded, tracking) => loaded && tracking, ); const autoTrackerNode = new AutoTrackerNode(this.videoElement, autoTrackingShownProperty, model); @@ -136,25 +136,25 @@ export class VideoPlayerNode extends Node { if (err.name === "NotAllowedError") { // Autoplay blocked (no user gesture). Silently reset so the UI // stays consistent; expected during fuzz testing. - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; return; } } // biome-ignore lint/suspicious/noConsole: error logging for video playback failure console.error("Video playback failed:", err); - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; }); } else { this.videoElement.pause(); } }; - model.isPlayingProperty.lazyLink(isPlayingListener); + model.playback.isPlayingProperty.lazyLink(isPlayingListener); // ── Playback rate (applies model rate to the video element) ────────── const playbackRateListener = (rate: number) => { this.videoElement.playbackRate = rate; }; - model.playbackRateProperty.link(playbackRateListener); + model.playback.playbackRateProperty.link(playbackRateListener); // ── Playback controls (positioned by SimScreenView at screen bottom) ── this.playbackControlsNode = new PlaybackControlsNode( @@ -183,19 +183,19 @@ export class VideoPlayerNode extends Node { const displayH = Math.round(intrinsicH * scale); this.videoElement.width = displayW; this.videoElement.height = displayH; - model.videoDimensionsProperty.value = new Dimension2(displayW, displayH); + model.playback.videoDimensionsProperty.value = new Dimension2(displayW, displayH); // Keep content layer bounds in sync so layout doesn't shift. this.videoContentLayer.localBounds = new Bounds2(0, 0, displayW, displayH); this.videoSourceControlNode.centerX = displayW / 2; this.playbackControlsNode.preferredWidth = displayW; - model.resizeTracker(displayW, displayH); + model.tracking.resizeTracker(displayW, displayH); }; this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded); // Sync model time from video during playback (event-driven, not polled) const onTimeUpdate = () => { if (!this.playbackControlsNode.scrubbing) { - model.currentTimeProperty.value = this.videoElement.currentTime; + model.playback.currentTimeProperty.value = this.videoElement.currentTime; } }; this.videoElement.addEventListener("timeupdate", onTimeUpdate); @@ -205,12 +205,12 @@ export class VideoPlayerNode extends Node { model, listParent, (url) => { - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; autoTrackerNode.reset(); this.loadUrl(url); }, (blob, duration) => { - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; autoTrackerNode.reset(); // Revoke the previous blob URL before creating a new one to prevent // the browser from holding the recorded video in memory indefinitely. @@ -221,7 +221,7 @@ export class VideoPlayerNode extends Node { this.videoElement.src = this.currentBlobUrl; this.videoElement.load(); if (duration > 0) { - model.durationProperty.value = duration; + model.playback.durationProperty.value = duration; } }, ); @@ -244,7 +244,7 @@ export class VideoPlayerNode extends Node { const videoTransformListener = (matrix: import("scenerystack/dot").Matrix3) => { this.videoContentLayer.matrix = matrix; }; - model.videoTransformProperty.link(videoTransformListener); + model.playback.videoTransformProperty.link(videoTransformListener); // ── Keyboard shortcuts ───────────────────────────────────────────────── const onKeyDown = this.createKeyboardHandler(model); @@ -254,9 +254,9 @@ export class VideoPlayerNode extends Node { this.disposeVideoPlayer = () => { document.removeEventListener("keydown", onKeyDown); TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener); - model.isPlayingProperty.unlink(isPlayingListener); - model.playbackRateProperty.unlink(playbackRateListener); - model.videoTransformProperty.unlink(videoTransformListener); + model.playback.isPlayingProperty.unlink(isPlayingListener); + model.playback.playbackRateProperty.unlink(playbackRateListener); + model.playback.videoTransformProperty.unlink(videoTransformListener); this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata); this.videoElement.removeEventListener("loadedmetadata", onDimensionsLoaded); this.videoElement.removeEventListener("durationchange", updateDuration); @@ -288,7 +288,7 @@ export class VideoPlayerNode extends Node { if (e.target instanceof HTMLInputElement || e.target instanceof HTMLTextAreaElement) { return; } - if (e.key === "Home" && model.videoLoadedProperty.value) { + if (e.key === "Home" && model.playback.videoLoadedProperty.value) { this.rewindToStart(); } }; @@ -309,8 +309,8 @@ export class VideoPlayerNode extends Node { /** Pause playback and seek to the very beginning of the video. */ private rewindToStart(): void { - this.model.isPlayingProperty.value = false; - this.model.currentTimeProperty.value = 0; + this.model.playback.isPlayingProperty.value = false; + this.model.playback.currentTimeProperty.value = 0; this.videoElement.currentTime = 0; } @@ -320,18 +320,18 @@ export class VideoPlayerNode extends Node { } private seekByFrames(direction: number): void { - this.model.isPlayingProperty.value = false; + this.model.playback.isPlayingProperty.value = false; const duration = this.videoElement.duration; if (!(duration > 0)) { return; } - const frameDuration = this.model.frameDurationProperty.value; + const frameDuration = this.model.playback.frameDurationProperty.value; const raw = this.videoElement.currentTime + direction * frameDuration; // Math.min(raw, Infinity) === raw, so this clamp works for both finite and // Infinity durations (WebM files often report Infinity until fully loaded). const clamped = Math.max(0, Math.min(raw, duration)); this.videoElement.currentTime = clamped; - this.model.currentTimeProperty.value = clamped; + this.model.playback.currentTimeProperty.value = clamped; } private loadUrl(url: string): void { diff --git a/src/screen-name/view/VideoSourceControlNode.ts b/src/screen-name/view/VideoSourceControlNode.ts index a1e94c7..1c20db0 100644 --- a/src/screen-name/view/VideoSourceControlNode.ts +++ b/src/screen-name/view/VideoSourceControlNode.ts @@ -192,7 +192,7 @@ export class VideoSourceControlNode extends HBox { } // Check webcam recordings - const recording = model.webcamRecordingsProperty.value.find((r) => r.id === value); + const recording = model.sources.webcamRecordingsProperty.value.find((r) => r.id === value); if (recording) { this.lastLoadedValue = value; model.activateRecording(recording); @@ -201,7 +201,7 @@ export class VideoSourceControlNode extends HBox { } // Check uploaded videos - const upload = model.uploadedVideosProperty.value.find((u) => u.id === value); + const upload = model.sources.uploadedVideosProperty.value.find((u) => u.id === value); if (upload) { this.lastLoadedValue = value; model.activateUpload(upload); @@ -323,8 +323,8 @@ export class VideoSourceControlNode extends HBox { }; const rebuildComboBox = (): void => { - const recordings = model.webcamRecordingsProperty.value; - const uploads = model.uploadedVideosProperty.value; + const recordings = model.sources.webcamRecordingsProperty.value; + const uploads = model.sources.uploadedVideosProperty.value; const oldBox = videoComboBox; videoComboBox = buildComboBox(recordings, uploads); @@ -342,14 +342,14 @@ export class VideoSourceControlNode extends HBox { videoComboBox = buildComboBox([], []); // Rebuild when either list changes - model.webcamRecordingsProperty.lazyLink(() => rebuildComboBox()); - model.uploadedVideosProperty.lazyLink(() => rebuildComboBox()); + model.sources.webcamRecordingsProperty.lazyLink(() => rebuildComboBox()); + model.sources.uploadedVideosProperty.lazyLink(() => rebuildComboBox()); // ── Download button (visible for user-provided videos) ──────────────── const downloadButton = createTrackLabButton(makeDownloadIcon(), { accessibleName: videoSourceStrings.downloadVideoStringProperty, listener: () => { - const blob = model.currentWebcamBlobProperty.value; + const blob = model.sources.currentWebcamBlobProperty.value; if (!blob) { return; } @@ -363,7 +363,7 @@ export class VideoSourceControlNode extends HBox { }, }); downloadButton.visible = false; - model.isWebcamVideoProperty.link((isUserVideo) => { + model.sources.isWebcamVideoProperty.link((isUserVideo) => { downloadButton.visible = isUserVideo; }); @@ -390,7 +390,13 @@ export class VideoSourceControlNode extends HBox { const duration = info?.duration ?? 0; const fps = info?.fps ?? DEFAULT_FRAME_RATE; const frameCount = info?.frameCount ?? 0; - const upload = model.addUploadedVideo(blob, file.name, duration, fps, frameCount > 0 ? frameCount : undefined); + const upload = model.sources.addUploadedVideo( + blob, + file.name, + duration, + fps, + frameCount > 0 ? frameCount : undefined, + ); // Setting selectedVideoProperty triggers the lazyLink which calls // model.activateUpload(upload) and onWebcamReady atomically. selectedVideoProperty.value = upload.id; @@ -398,7 +404,7 @@ export class VideoSourceControlNode extends HBox { } const storeAndLoad = (duration: number, fps?: number, frameCount?: number) => { - const upload = model.addUploadedVideo(blob, file.name, duration, fps, frameCount); + const upload = model.sources.addUploadedVideo(blob, file.name, duration, fps, frameCount); // Setting selectedVideoProperty triggers the lazyLink which calls // model.activateUpload(upload) and onWebcamReady atomically. selectedVideoProperty.value = upload.id; @@ -429,7 +435,7 @@ export class VideoSourceControlNode extends HBox { const uploadButton = createTrackLabButton(makeUploadIcon(), { accessibleName: videoSourceStrings.openVideoFileStringProperty, listener: () => { - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; fileInput.click(); }, }); @@ -440,7 +446,7 @@ export class VideoSourceControlNode extends HBox { onVideoReady: (blob, duration) => { this.webcamPanel.visible = false; // Store the recording in the model (this triggers a ComboBox rebuild). - const recording = model.addWebcamRecording(blob, duration, model.frameRateProperty.value); + const recording = model.sources.addWebcamRecording(blob, duration, model.playback.frameRateProperty.value); // Setting selectedVideoProperty triggers the lazyLink which calls // model.activateRecording(recording) and onWebcamReady atomically. selectedVideoProperty.value = recording.id; @@ -464,7 +470,7 @@ export class VideoSourceControlNode extends HBox { tandem: Tandem.OPT_OUT, accessibleName: videoSourceStrings.recordWebcamStringProperty, listener: async () => { - model.isPlayingProperty.value = false; + model.playback.isPlayingProperty.value = false; this.webcamPanel.visible = true; try { await this.webcamPanel.open(); diff --git a/src/screen-name/view/WebcamPanel.ts b/src/screen-name/view/WebcamPanel.ts index 5cc7cdc..ac5e20a 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -187,7 +187,7 @@ export class WebcamPanel extends Node { fill: TrackLabColors.textMutedProperty, }); - const fpsPicker = new NumberPicker(this.model.frameRateProperty, new Property(FRAME_RATE_RANGE), { + const fpsPicker = new NumberPicker(this.model.playback.frameRateProperty, new Property(FRAME_RATE_RANGE), { font: SMALL_FONT, scale: FPS_PICKER_SCALE, touchAreaXDilation: 10, @@ -390,7 +390,7 @@ export class WebcamPanel extends Node { } this.updateFPSEstimateDisplay(); // Set the estimated FPS as the initial value - this.model.frameRateProperty.value = this.fpsEstimate.fps; + this.model.playback.frameRateProperty.value = this.fpsEstimate.fps; } catch (_error) { this.fpsEstimateText.string = ""; }