diff --git a/src/screen-name/graph/kinematics-plottable-properties.ts b/src/screen-name/graph/kinematics-plottable-properties.ts index 3679b8c..32900e0 100644 --- a/src/screen-name/graph/kinematics-plottable-properties.ts +++ b/src/screen-name/graph/kinematics-plottable-properties.ts @@ -22,7 +22,7 @@ */ import type { TReadOnlyProperty } from "scenerystack/axon"; -import type { SimModel } from "../model/SimModel.js"; +import type { OverlayToolsModel } from "../model/OverlayToolsModel.js"; import type { PlottableProperty } from "./PlottableProperty.js"; function createPlottableProperty( @@ -55,7 +55,7 @@ export type KinematicsPlottableGroups = { * @param model - Provides the reactive unit-string properties so that axis * labels update automatically when the user changes the calibration unit. */ -export function buildKinematicsPlottableGroups(model: SimModel): KinematicsPlottableGroups { +export function buildKinematicsPlottableGroups(overlayTools: OverlayToolsModel): KinematicsPlottableGroups { return { // ── Time ────────────────────────────────────────────────────────────── // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation @@ -64,29 +64,29 @@ export function buildKinematicsPlottableGroups(model: SimModel): KinematicsPlott // ── Position ────────────────────────────────────────────────────────── position: [ // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("x", model.overlayTools.calibUnitProperty, (pt) => pt["x"] ?? 0), + createPlottableProperty("x", overlayTools.calibUnitProperty, (pt) => pt["x"] ?? 0), // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("y", model.overlayTools.calibUnitProperty, (pt) => pt["y"] ?? 0), + createPlottableProperty("y", overlayTools.calibUnitProperty, (pt) => pt["y"] ?? 0), ], // ── Velocity ────────────────────────────────────────────────────────── velocity: [ // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("vx", model.overlayTools.velocityUnitProperty, (pt) => pt["vx"] ?? 0), + createPlottableProperty("vx", overlayTools.velocityUnitProperty, (pt) => pt["vx"] ?? 0), // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("vy", model.overlayTools.velocityUnitProperty, (pt) => pt["vy"] ?? 0), + createPlottableProperty("vy", overlayTools.velocityUnitProperty, (pt) => pt["vy"] ?? 0), // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("speed", model.overlayTools.velocityUnitProperty, (pt) => pt["speed"] ?? 0), + createPlottableProperty("speed", overlayTools.velocityUnitProperty, (pt) => pt["speed"] ?? 0), ], // ── Acceleration ────────────────────────────────────────────────────── acceleration: [ // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("ax", model.overlayTools.accelerationUnitProperty, (pt) => pt["ax"] ?? 0), + createPlottableProperty("ax", overlayTools.accelerationUnitProperty, (pt) => pt["ax"] ?? 0), // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("ay", model.overlayTools.accelerationUnitProperty, (pt) => pt["ay"] ?? 0), + createPlottableProperty("ay", overlayTools.accelerationUnitProperty, (pt) => pt["ay"] ?? 0), // biome-ignore lint/complexity/useLiteralKeys: TypeScript requires bracket notation - createPlottableProperty("|a|", model.overlayTools.accelerationUnitProperty, (pt) => pt["aMag"] ?? 0), + createPlottableProperty("|a|", overlayTools.accelerationUnitProperty, (pt) => pt["aMag"] ?? 0), ], }; } diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 9623af2..acd70a5 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -6,7 +6,7 @@ */ import type { TReadOnlyProperty } from "scenerystack/axon"; -import { type Dimension2, Vector2 } from "scenerystack/dot"; +import { type Dimension2, type Transform3, Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { DragListener, Line, Node, Path, Rectangle, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; @@ -15,7 +15,15 @@ import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { TrackingModel } from "../model/TrackingModel.js"; + +/** Narrowed dependencies passed to AutoTrackerNode at construction time. */ +type AutoTrackerNodeOptions = { + tracking: TrackingModel; + videoDimensionsProperty: TReadOnlyProperty; + frameRateProperty: TReadOnlyProperty; + modelViewTransformProperty: TReadOnlyProperty; +}; const MAX_TRAIL = 150; const CROSSHAIR_SIZE = 16; @@ -47,7 +55,7 @@ const LABELS_SPACING = 8; // vertical gap between hint text and error text * DOM node at position (0,0). */ export class AutoTrackerNode extends Node { - private readonly model: SimModel; + private readonly tracking: TrackingModel; // ── Trail: O(1) ring buffer ──────────────────────────────────────────── // Using a fixed-size circular buffer instead of a plain array so that the @@ -82,16 +90,18 @@ export class AutoTrackerNode extends Node { /** * @param videoElement - The video element used both for pixel capture and frame events. * @param autoTrackingShownProperty - Combined gate (video loaded AND toggle on); controls visibility. - * @param model - Provides active track state and receives recorded positions via addPointToTrack. + * @param options - Narrowed model dependencies: tracking sub-model, specific playback properties, + * and the model-view transform for converting pixel coordinates to model coordinates. */ public constructor( videoElement: HTMLVideoElement, autoTrackingShownProperty: TReadOnlyProperty, - model: SimModel, + options: AutoTrackerNodeOptions, ) { super({ visible: false }); - this.model = model; + const { tracking, videoDimensionsProperty, frameRateProperty, modelViewTransformProperty } = options; + this.tracking = tracking; const autoTrackerStrings = StringManager.getInstance().getAutoTracker(); @@ -171,7 +181,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.tracking.resetTracker(); + this.tracking.resetTracker(); this.setCrosshairVisible(false); this.trailPath.shape = null; this.trailPath.visible = false; @@ -216,8 +226,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.tracking.activeTrackIdProperty.value) { - this.model.tracking.addTrackAndActivate(); + if (!this.tracking.activeTrackIdProperty.value) { + this.tracking.addTrackAndActivate(); } // initTracker is async (loads WASM on first call); tracking begins @@ -225,23 +235,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.tracking + this.tracking .initTracker(videoElement, region) .then(() => { if (this.initVersion !== capturedVersion) { // A newer drag has already started; discard this result. - this.model.tracking.resetTracker(); + this.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.tracking.activeTrackIdProperty.value; + const activeId = this.tracking.activeTrackIdProperty.value; const trackStillExists = - activeId !== null && this.model.tracking.tracksProperty.value.some((t) => t.id === activeId); + activeId !== null && this.tracking.tracksProperty.value.some((t) => t.id === activeId); if (!trackStillExists) { - this.model.tracking.resetTracker(); + this.tracking.resetTracker(); this.hintText.visible = true; } }) @@ -269,7 +279,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.playback.videoDimensionsProperty.link(videoDimensionsListener); + videoDimensionsProperty.link(videoDimensionsListener); // ── Track on every video frame ──────────────────────────────────────── // OpenCV template matching (track()) is a heavy synchronous operation. @@ -278,14 +288,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.tracking.isTrackerReady)) { + if (!(this.visible && this.tracking.isTrackerReady)) { return; } this.trackInProgress = true; let pt: { x: number; y: number } | null = null; try { - pt = await this.model.tracking.trackFrame(videoElement); + pt = await this.tracking.trackFrame(videoElement); } catch { // Tracker was disposed mid-flight (e.g. new selection started); skip frame. return; @@ -306,21 +316,21 @@ export class AutoTrackerNode extends Node { this.updateTrackerVisuals(pt); // ── Record position to model if a track is active ───────────────── - const activeId = model.tracking.activeTrackIdProperty.value; + const activeId = 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.playback.frameRateProperty.value); + const frame = Math.round(time * 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.tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); + const modelPt = modelViewTransformProperty.value.inversePosition2(new Vector2(pt.x, pt.y)); + tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); this.recordedFrames.add(frame); } } @@ -338,7 +348,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.tracking.activeTrackIdProperty.lazyLink(clearRecordedFrames); + tracking.activeTrackIdProperty.lazyLink(clearRecordedFrames); // ── Show/hide based on combined "video loaded && autoTracking" ──────── const autoTrackingShownListener = (shown: boolean) => { @@ -357,10 +367,10 @@ export class AutoTrackerNode extends Node { videoElement.removeEventListener("timeupdate", onFrame); videoElement.removeEventListener("seeked", onFrame); this.cancelPendingFrame(); - model.tracking.activeTrackIdProperty.unlink(clearRecordedFrames); + tracking.activeTrackIdProperty.unlink(clearRecordedFrames); autoTrackingShownProperty.unlink(autoTrackingShownListener); - model.playback.videoDimensionsProperty.unlink(videoDimensionsListener); - this.model.tracking.resetTracker(); + videoDimensionsProperty.unlink(videoDimensionsListener); + this.tracking.resetTracker(); }; } @@ -403,7 +413,7 @@ export class AutoTrackerNode extends Node { public reset(): void { this.cancelPendingFrame(); this.trackInProgress = false; - this.model.tracking.resetTracker(); + this.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 6959209..e7163af 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -23,8 +23,7 @@ import { OVERLAY_SHIFT_DRAG_SPEED, } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import { CALIBRATION_UNITS } from "../model/OverlayToolsModel.js"; -import type { SimModel } from "../model/SimModel.js"; +import { CALIBRATION_UNITS, type OverlayToolsModel } from "../model/OverlayToolsModel.js"; const FONT = new PhetFont(14); const WARNING_FONT = new PhetFont({ size: 11, weight: "bold" }); @@ -62,9 +61,15 @@ export class CalibrationToolNode extends Node { /** * @param videoLoadedProperty - Controls visibility; node is hidden until a video is loaded. * @param listParent - Scene-graph node used as the popup list parent for the unit ComboBox. - * @param model - Provides calibration properties and receives user-entered values. + * @param overlayTools - Provides calibration properties and receives user-entered values. + * @param activeTrackIdProperty - When non-null, dims and locks the tool to prevent accidental moves while digitizing. */ - public constructor(videoLoadedProperty: TReadOnlyProperty, listParent: Node, model: SimModel) { + public constructor( + videoLoadedProperty: TReadOnlyProperty, + listParent: Node, + overlayTools: OverlayToolsModel, + activeTrackIdProperty: TReadOnlyProperty, + ) { super(); const calibrationStrings = StringManager.getInstance().getCalibration(); @@ -135,14 +140,14 @@ export class CalibrationToolNode extends Node { }); // Pattern shown inside the dialog as "Range: {{min}} – {{max}} " const rangePatternProperty = new DerivedProperty( - [model.overlayTools.calibUnitProperty], + [overlayTools.calibUnitProperty], (unit) => `{{min}} – {{max}} ${unit}`, ); // ── Midpoint panel ──────────────────────────────────────────────────── // Button showing current value + unit; clicking it opens the keypad. const buttonLabelProperty = new DerivedProperty( - [model.overlayTools.calibDistanceProperty, model.overlayTools.calibUnitProperty], + [overlayTools.calibDistanceProperty, overlayTools.calibUnitProperty], (dist, unit) => `${dist.toFixed(CALIBRATION_DECIMAL_PLACES)} ${unit}`, ); @@ -156,9 +161,9 @@ export class CalibrationToolNode extends Node { listener: () => { keypadDialog.beginEdit( (value: number) => { - model.overlayTools.calibDistanceProperty.value = value; + overlayTools.calibDistanceProperty.value = value; }, - model.overlayTools.calibDistanceProperty.range, + overlayTools.calibDistanceProperty.range, rangePatternProperty, () => { /* no-op: keypad close callback not needed */ @@ -178,7 +183,7 @@ export class CalibrationToolNode extends Node { }), tandemName: `${unit}Item`, })); - const unitComboBox = new ComboBox(model.overlayTools.calibUnitProperty, unitItems, listParent, { + const unitComboBox = new ComboBox(overlayTools.calibUnitProperty, unitItems, listParent, { buttonFill: TrackLabColors.comboBoxButtonFillProperty, listFill: TrackLabColors.comboBoxListFillProperty, highlightFill: TrackLabColors.comboBoxHighlightFillProperty, @@ -213,8 +218,8 @@ export class CalibrationToolNode extends Node { // ── Update geometry when endpoints move ─────────────────────────────── const updateGeometry = () => { - const p1 = model.overlayTools.calibPoint1Property.value; - const p2 = model.overlayTools.calibPoint2Property.value; + const p1 = overlayTools.calibPoint1Property.value; + const p2 = overlayTools.calibPoint2Property.value; // Update both shadow and main lines calibrationLineShadow.setLine(p1.x, p1.y, p2.x, p2.y); @@ -249,14 +254,14 @@ export class CalibrationToolNode extends Node { // is rebuilt once per change event regardless of which endpoint moved, // and disposal is managed in one place. const calibMultilink = Multilink.multilink( - [model.overlayTools.calibPoint1Property, model.overlayTools.calibPoint2Property], + [overlayTools.calibPoint1Property, overlayTools.calibPoint2Property], updateGeometry, ); // ── Drag listeners for endpoints ────────────────────────────────────── endpoint1.addInputListener( new RichDragListener({ - positionProperty: model.overlayTools.calibPoint1Property, + positionProperty: overlayTools.calibPoint1Property, keyboardDragListenerOptions: { dragSpeed: OVERLAY_DRAG_SPEED, shiftDragSpeed: OVERLAY_SHIFT_DRAG_SPEED, @@ -266,7 +271,7 @@ export class CalibrationToolNode extends Node { ); endpoint2.addInputListener( new RichDragListener({ - positionProperty: model.overlayTools.calibPoint2Property, + positionProperty: overlayTools.calibPoint2Property, keyboardDragListenerOptions: { dragSpeed: OVERLAY_DRAG_SPEED, shiftDragSpeed: OVERLAY_SHIFT_DRAG_SPEED, @@ -289,12 +294,12 @@ export class CalibrationToolNode extends Node { this.pickable = !isDigitizing; this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; - model.tracking.activeTrackIdProperty.link(onActiveTrackChange); + activeTrackIdProperty.link(onActiveTrackChange); this.disposeCalibrationToolNode = () => { calibMultilink.dispose(); videoLoadedProperty.unlink(onVideoLoaded); - model.tracking.activeTrackIdProperty.unlink(onActiveTrackChange); + activeTrackIdProperty.unlink(onActiveTrackChange); rangePatternProperty.dispose(); buttonLabelProperty.dispose(); }; diff --git a/src/screen-name/view/ControlPanel.ts b/src/screen-name/view/ControlPanel.ts index 3b74619..1990efa 100644 --- a/src/screen-name/view/ControlPanel.ts +++ b/src/screen-name/view/ControlPanel.ts @@ -19,7 +19,7 @@ import { PANEL_CORNER_RADIUS, } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { OverlayToolsModel } from "../model/OverlayToolsModel.js"; const ICON_ARROW_HEAD_SIZE = 5; // headWidth and headHeight for icon arrows const ICON_ARROW_TAIL_WIDTH = 1.5; @@ -177,15 +177,15 @@ function makeRow(icon: Node, property: import("scenerystack/axon").BooleanProper */ export class ControlPanel extends Panel { /** - * @param model - Provides the boolean visibility properties bound to each checkbox. + * @param overlayTools - Provides the boolean visibility properties bound to each checkbox. * @param trackLabPreferences - Determines whether the auto-tracking checkbox is shown. */ - public constructor(model: SimModel, trackLabPreferences: TrackLabPreferencesModel) { + public constructor(overlayTools: OverlayToolsModel, trackLabPreferences: TrackLabPreferencesModel) { const a11yStrings = StringManager.getInstance().getA11y(); const autoTrackingCheckbox = makeRow( trackingIcon(), - model.overlayTools.autoTrackingProperty, + overlayTools.autoTrackingProperty, a11yStrings.toggleAutoTrackingStringProperty.value, ); // The auto-tracking checkbox is only visible if the preference allows it. @@ -194,17 +194,13 @@ export class ControlPanel extends Panel { const rows = new VBox({ children: [ - makeRow(axesIcon(), model.overlayTools.axesVisibleProperty, a11yStrings.toggleAxesStringProperty.value), + makeRow(axesIcon(), overlayTools.axesVisibleProperty, a11yStrings.toggleAxesStringProperty.value), makeRow( calibrationIcon(), - model.overlayTools.calibrationVisibleProperty, + overlayTools.calibrationVisibleProperty, a11yStrings.toggleCalibrationStringProperty.value, ), - makeRow( - magnifyIcon(), - model.overlayTools.magnifyVideoProperty, - a11yStrings.toggleMagnifierStringProperty.value, - ), + makeRow(magnifyIcon(), overlayTools.magnifyVideoProperty, a11yStrings.toggleMagnifierStringProperty.value), autoTrackingCheckbox, ], spacing: CONTROL_PANEL_ROWS_SPACING, diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index 28ed332..066839a 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -15,7 +15,7 @@ import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import { DIGITIZING_DIM_OPACITY } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { OverlayToolsModel } from "../model/OverlayToolsModel.js"; const ARROW_LENGTH = 120; const HANDLE_FRACTION = 1 / 3; @@ -52,7 +52,7 @@ const DEG_TO_RAD = Math.PI / 180; * * The origin can be dragged anywhere on screen; a small disk on the X axis * controls rotation by pointer angle. Both gestures write directly to - * `model.overlayTools.coordOriginProperty` and `model.overlayTools.coordAngleProperty`, keeping the + * `overlayTools.coordOriginProperty` and `overlayTools.coordAngleProperty`, keeping the * model-view transform in sync. Keyboard drag is supported via RichDragListener. * Hidden until a video is loaded. */ @@ -61,9 +61,14 @@ export class CoordinateSystemNode extends Node { /** * @param videoLoadedProperty - Controls visibility; node is hidden until a video is loaded. - * @param model - Provides and receives coordOriginProperty / coordAngleProperty. + * @param overlayTools - Provides and receives coordOriginProperty / coordAngleProperty. + * @param activeTrackIdProperty - When non-null, dims and locks the axes to prevent accidental moves while digitizing. */ - public constructor(videoLoadedProperty: TReadOnlyProperty, model: SimModel) { + public constructor( + videoLoadedProperty: TReadOnlyProperty, + overlayTools: OverlayToolsModel, + activeTrackIdProperty: TReadOnlyProperty, + ) { super(); const coordStrings = StringManager.getInstance().getCoordSystem(); @@ -165,7 +170,7 @@ export class CoordinateSystemNode extends Node { lineWidth: ORIGIN_LINE_WIDTH, }); - // ── Position wrapper: translates with model.overlayTools.coordOriginProperty ─────── + // ── Position wrapper: translates with overlayTools.coordOriginProperty ─────── const positionNode = new Node({ children: [rotatingNode, originShadow, originMarker], cursor: "move", @@ -193,7 +198,7 @@ export class CoordinateSystemNode extends Node { const onOriginChange = (pos: Vector2) => { positionNode.translation = pos; }; - model.overlayTools.coordOriginProperty.link(onOriginChange); + overlayTools.coordOriginProperty.link(onOriginChange); const onAngleChange = (angle: number) => { rotatingNode.rotation = angle; @@ -203,7 +208,7 @@ export class CoordinateSystemNode extends Node { positionNode.touchArea = createAxisHitArea(AXIS_TOUCH_WIDTH, ORIGIN_TOUCH_DILATION).transformed(m); positionNode.mouseArea = createAxisHitArea(AXIS_MOUSE_WIDTH, ORIGIN_MOUSE_DILATION).transformed(m); }; - model.overlayTools.coordAngleProperty.link(onAngleChange); + overlayTools.coordAngleProperty.link(onAngleChange); // ── Drag: translate the entire coordinate system ────────────────────── positionNode.addInputListener( @@ -211,16 +216,16 @@ export class CoordinateSystemNode extends Node { dragListenerOptions: { drag: (_event, listener) => { const newPos = listener.parentPoint; - model.overlayTools.coordOriginProperty.value = model.overlayTools.clampCoordOrigin(newPos); + overlayTools.coordOriginProperty.value = overlayTools.clampCoordOrigin(newPos); }, }, keyboardDragListenerOptions: { dragSpeed: TRANSLATE_DRAG_SPEED, shiftDragSpeed: TRANSLATE_SHIFT_DRAG_SPEED, drag: (_event, listener) => { - const currentPos = model.overlayTools.coordOriginProperty.value; + const currentPos = overlayTools.coordOriginProperty.value; const newPos = currentPos.plus(listener.modelDelta); - model.overlayTools.coordOriginProperty.value = model.overlayTools.clampCoordOrigin(newPos); + overlayTools.coordOriginProperty.value = overlayTools.clampCoordOrigin(newPos); }, }, tandem: Tandem.OPT_OUT, @@ -235,7 +240,7 @@ export class CoordinateSystemNode extends Node { dragListenerOptions: { drag: (event) => { const p = positionNode.globalToLocalPoint(event.pointer.point); - model.overlayTools.coordAngleProperty.value = Math.atan2(p.y, p.x); + overlayTools.coordAngleProperty.value = Math.atan2(p.y, p.x); }, }, keyboardDragListenerOptions: { @@ -243,7 +248,7 @@ export class CoordinateSystemNode extends Node { dragSpeed: ROTATE_DRAG_SPEED, shiftDragSpeed: ROTATE_SHIFT_DRAG_SPEED, drag: (_event, listener) => { - model.overlayTools.coordAngleProperty.value += listener.modelDelta.x * DEG_TO_RAD; + overlayTools.coordAngleProperty.value += listener.modelDelta.x * DEG_TO_RAD; }, }, tandem: Tandem.OPT_OUT, @@ -265,13 +270,13 @@ export class CoordinateSystemNode extends Node { this.pickable = !isDigitizing; this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; - model.tracking.activeTrackIdProperty.link(onActiveTrackChange); + activeTrackIdProperty.link(onActiveTrackChange); this.disposeCoordinateSystemNode = () => { - model.overlayTools.coordOriginProperty.unlink(onOriginChange); - model.overlayTools.coordAngleProperty.unlink(onAngleChange); + overlayTools.coordOriginProperty.unlink(onOriginChange); + overlayTools.coordAngleProperty.unlink(onAngleChange); videoLoadedProperty.unlink(onVideoLoaded); - model.tracking.activeTrackIdProperty.unlink(onActiveTrackChange); + activeTrackIdProperty.unlink(onActiveTrackChange); }; } diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index 474b10f..ac9e7c9 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -21,9 +21,9 @@ import { createTrackLabButton, makeDownloadIcon } from "../../TrackLabButton.js" import TrackLabColors, { TRACK_COLORS } from "../../TrackLabColors.js"; import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; import { buildDataRows, type DataRow, generateCsv } from "../model/TrackExporter.js"; +import type { TrackingModel } from "../model/TrackingModel.js"; // ── Accessibility ───────────────────────────────────────────────────────────── // The HTML table gets a element for screen readers. The caption text @@ -335,7 +335,7 @@ export class DataTableNode extends Panel { private maxRenderedFrame: number = -Infinity; public constructor( - model: SimModel, + tracking: TrackingModel, videoLoadedProperty: TReadOnlyProperty, unitProperty: TReadOnlyProperty, ) { @@ -394,7 +394,7 @@ export class DataTableNode extends Panel { accessibleName: a11yStrings.exportCSVStringProperty, baseColor: TrackLabColors.exportButtonProperty, listener: () => { - const tracks = model.tracking.tracksProperty.value; + const tracks = 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.tracking.tracksProperty.value; + const tracks = 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.tracking.tracksProperty.link(tracksListener); + 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.tracking.tracksProperty.value, unitProperty.value); + const fullRebuild = () => doFullRebuild(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.tracking.tracksProperty.unlink(tracksListener); + 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 503c0e9..7370fc4 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -5,7 +5,8 @@ * magnified view to help users precisely click on particle positions. */ -import { type Dimension2, Vector2 } from "scenerystack/dot"; +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { type Dimension2, type Transform3, Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { DOM, FireListener, Node, Path, Rectangle } from "scenerystack/scenery"; import { Tandem } from "scenerystack/tandem"; @@ -13,7 +14,16 @@ import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors, { getTrackColor } from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { TrackingModel } from "../model/TrackingModel.js"; +import type { VideoPlaybackModel } from "../model/VideoPlaybackModel.js"; + +/** Narrowed dependencies passed to DigitizingOverlayNode at construction time. */ +type DigitizingOverlayNodeOptions = { + tracking: TrackingModel; + playback: VideoPlaybackModel; + magnifyVideoProperty: TReadOnlyProperty; + modelViewTransformProperty: TReadOnlyProperty; +}; const OUTER_R = 12; const INNER_R = 2; @@ -41,9 +51,11 @@ const MAG_SHADOW_BLUR = 8; // px, shadow blur radius export class DigitizingOverlayNode extends Node { private readonly disposeDigitizingOverlay: () => void; - public constructor(videoElement: HTMLVideoElement, model: SimModel, onPointAdded: () => void) { + public constructor(videoElement: HTMLVideoElement, options: DigitizingOverlayNodeOptions, onPointAdded: () => void) { super(); + const { tracking, playback, magnifyVideoProperty, modelViewTransformProperty } = options; + // Cached CSS color strings for canvas drawing (updated via property links) let magBorderColor = TrackLabColors.digitizingMagnifierBorderProperty.value.toCSS(); let magCrosshairColor = TrackLabColors.digitizingMagnifierCrosshairProperty.value.toCSS(); @@ -236,13 +248,13 @@ export class DigitizingOverlayNode extends Node { const videoDimensionsListener = (dims: Dimension2) => { digitizingOverlay.setRect(0, 0, dims.width, dims.height); }; - model.playback.videoDimensionsProperty.link(videoDimensionsListener); + playback.videoDimensionsProperty.link(videoDimensionsListener); const updateMagnifierAtLastPt = () => { if (!(lastLocalPt && magnifierNode.visible)) { return; } - const { width: overlayW, height: overlayH } = model.playback.videoDimensionsProperty.value; + const { width: overlayW, height: overlayH } = 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,13 +269,13 @@ export class DigitizingOverlayNode extends Node { cursorNode.translation = localPt; cursorNode.visible = true; - const { width: overlayW, height: overlayH } = model.playback.videoDimensionsProperty.value; + const { width: overlayW, height: overlayH } = 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; magnifierNode.y = magY; - if (model.overlayTools.magnifyVideoProperty.value) { + if (magnifyVideoProperty.value) { const crosshairX = localPt.x - magX; const crosshairY = localPt.y - magY; updateMagnifier(localPt.x, localPt.y, crosshairX, crosshairY); @@ -286,10 +298,10 @@ export class DigitizingOverlayNode extends Node { const trackPaths = new Map(); // track id → Path const rebuildMarks = () => { - const frameDuration = model.playback.frameDurationProperty.value; - const currentFrame = Math.round(model.playback.currentTimeProperty.value / frameDuration); - const mvt = model.overlayTools.modelViewTransformProperty.value; - const tracks = model.tracking.tracksProperty.value; + const frameDuration = playback.frameDurationProperty.value; + const currentFrame = Math.round(playback.currentTimeProperty.value / frameDuration); + const mvt = modelViewTransformProperty.value; + const tracks = tracking.tracksProperty.value; const activeTrackIds = new Set(tracks.map((t) => t.id)); // Update or create one Path per track. @@ -324,16 +336,16 @@ export class DigitizingOverlayNode extends Node { rebuildMarks(); updateMagnifierAtLastPt(); }; - model.playback.currentTimeProperty.link(currentTimeListener); + playback.currentTimeProperty.link(currentTimeListener); const tracksListener = () => rebuildMarks(); - model.tracking.tracksProperty.link(tracksListener); + tracking.tracksProperty.link(tracksListener); const mvtListener = () => rebuildMarks(); - model.overlayTools.modelViewTransformProperty.link(mvtListener); + modelViewTransformProperty.link(mvtListener); const frameRateListener = () => rebuildMarks(); - model.playback.frameRateProperty.link(frameRateListener); + playback.frameRateProperty.link(frameRateListener); const activeTrackListener = (activeId: string | null) => { digitizingOverlay.visible = activeId !== null; @@ -341,14 +353,14 @@ export class DigitizingOverlayNode extends Node { cursorNode.visible = false; } }; - model.tracking.activeTrackIdProperty.link(activeTrackListener); + tracking.activeTrackIdProperty.link(activeTrackListener); const magnifyListener = (magnify: boolean) => { if (!magnify) { magnifierNode.visible = false; } }; - model.overlayTools.magnifyVideoProperty.link(magnifyListener); + magnifyVideoProperty.link(magnifyListener); digitizingOverlay.addInputListener( new FireListener({ @@ -356,24 +368,24 @@ export class DigitizingOverlayNode extends Node { if (!event) { return; } - const activeId = model.tracking.activeTrackIdProperty.value; + const activeId = tracking.activeTrackIdProperty.value; if (!activeId) { return; } - const track = model.tracking.tracksProperty.value.find((t) => t.id === activeId); + const track = tracking.tracksProperty.value.find((t) => t.id === activeId); if (!track) { return; } const localPt = digitizingOverlay.globalToLocalPoint(event.pointer.point); - const time = model.playback.currentTimeProperty.value; - const frame = Math.round(time * model.playback.frameRateProperty.value); + const time = playback.currentTimeProperty.value; + const frame = Math.round(time * playback.frameRateProperty.value); - const modelPt = model.pixelToModelCoords(localPt); + const modelPt = modelViewTransformProperty.value.inversePosition2(localPt); - model.tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); + tracking.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); onPointAdded(); }, tandem: Tandem.OPT_OUT, @@ -389,13 +401,13 @@ export class DigitizingOverlayNode extends Node { TrackLabColors.digitizingMagnifierBorderProperty.unlink(magBorderListener); TrackLabColors.digitizingMagnifierCrosshairProperty.unlink(magCrosshairListener); TrackLabColors.digitizingMagnifierShadowProperty.unlink(magShadowListener); - model.playback.videoDimensionsProperty.unlink(videoDimensionsListener); - model.playback.currentTimeProperty.unlink(currentTimeListener); - model.tracking.tracksProperty.unlink(tracksListener); - model.overlayTools.modelViewTransformProperty.unlink(mvtListener); - model.playback.frameRateProperty.unlink(frameRateListener); - model.tracking.activeTrackIdProperty.unlink(activeTrackListener); - model.overlayTools.magnifyVideoProperty.unlink(magnifyListener); + playback.videoDimensionsProperty.unlink(videoDimensionsListener); + playback.currentTimeProperty.unlink(currentTimeListener); + tracking.tracksProperty.unlink(tracksListener); + modelViewTransformProperty.unlink(mvtListener); + playback.frameRateProperty.unlink(frameRateListener); + tracking.activeTrackIdProperty.unlink(activeTrackListener); + 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 c9a6973..37e709e 100644 --- a/src/screen-name/view/KinematicsGraphNode.ts +++ b/src/screen-name/view/KinematicsGraphNode.ts @@ -6,7 +6,7 @@ * The velocity and acceleration groups can be hidden via user preferences. */ -import { Property } from "scenerystack/axon"; +import { Property, type TReadOnlyProperty } from "scenerystack/axon"; import { Node, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { Checkbox } from "scenerystack/sun"; @@ -17,8 +17,9 @@ import trackLab from "../../TrackLabNamespace.js"; import ConfigurableGraph from "../graph/ConfigurableGraph.js"; import { buildKinematicsPlottableGroups } from "../graph/kinematics-plottable-properties.js"; import type { PlottableProperty } from "../graph/PlottableProperty.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { OverlayToolsModel } from "../model/OverlayToolsModel.js"; import type { Track } from "../model/Track.js"; +import type { TrackingModel } from "../model/TrackingModel.js"; // Graph dimensions const GRAPH_WIDTH = 300; @@ -31,13 +32,19 @@ const TRACK_CHECKBOX_SPACING = 6; export class KinematicsGraphNode extends Node { private readonly graph: ConfigurableGraph; - private readonly model: SimModel; + private readonly tracking: TrackingModel; private readonly selectedTracksProperty: Property>; private readonly trackCheckboxPanel: Node; private readonly trackCheckboxes: Map }> = new Map(); private readonly disposeKinematicsGraph: () => void; - public constructor(model: SimModel, listParent: Node, preferencesModel: TrackLabPreferencesModel) { + public constructor( + tracking: TrackingModel, + overlayTools: OverlayToolsModel, + videoLoadedProperty: TReadOnlyProperty, + listParent: Node, + preferencesModel: TrackLabPreferencesModel, + ) { const a11yStrings = StringManager.getInstance().getA11y(); super({ visible: false, @@ -45,13 +52,13 @@ export class KinematicsGraphNode extends Node { accessibleName: a11yStrings.kinematicsGraphStringProperty, }); - this.model = model; + this.tracking = tracking; this.selectedTracksProperty = new Property>(new Set()); // Build the categorised groups of plottable quantities. // Object references are stable — the same PlottableProperty instances are // reused across filter updates so identity checks in setAvailableProperties work. - const groups = buildKinematicsPlottableGroups(model); + const groups = buildKinematicsPlottableGroups(overlayTools); /** Compute the current filtered list from preference state. */ const getFilteredProperties = (): PlottableProperty[] => { @@ -126,14 +133,14 @@ export class KinematicsGraphNode extends Node { // Build new checkbox panel with updated tracks this.rebuildTrackCheckboxes(); }; - model.tracking.tracksProperty.link(tracksListener); + 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.tracking.trackKinematicsProperty.link(kinematicsListener); + tracking.trackKinematicsProperty.link(kinematicsListener); // Update graph when axis selection changes const xPropertyListener = () => this.updateGraph(); @@ -147,7 +154,7 @@ export class KinematicsGraphNode extends Node { const distanceUnitListener = () => { this.graph.updateAxisLabels(); }; - model.overlayTools.calibUnitProperty.lazyLink(distanceUnitListener); + overlayTools.calibUnitProperty.lazyLink(distanceUnitListener); // When preferences change, rebuild the available properties in the graph selectors. const velocityPrefListener = () => { @@ -164,7 +171,7 @@ export class KinematicsGraphNode extends Node { const videoLoadedListener = (loaded: boolean) => { this.visible = loaded; }; - model.playback.videoLoadedProperty.link(videoLoadedListener); + videoLoadedProperty.link(videoLoadedListener); // Update checkbox positions when graph bounds change (e.g., after resize) const graphBoundsListener = () => { @@ -174,15 +181,15 @@ export class KinematicsGraphNode extends Node { // Store cleanup function this.disposeKinematicsGraph = () => { - model.tracking.tracksProperty.unlink(tracksListener); + tracking.tracksProperty.unlink(tracksListener); this.selectedTracksProperty.unlink(selectedTracksListener); - model.tracking.trackKinematicsProperty.unlink(kinematicsListener); + tracking.trackKinematicsProperty.unlink(kinematicsListener); this.graph.getXPropertyProperty().unlink(xPropertyListener); this.graph.getYPropertyProperty().unlink(yPropertyListener); - model.overlayTools.calibUnitProperty.unlink(distanceUnitListener); + overlayTools.calibUnitProperty.unlink(distanceUnitListener); preferencesModel.showVelocityInGraphProperty.unlink(velocityPrefListener); preferencesModel.showAccelerationInGraphProperty.unlink(accelerationPrefListener); - model.playback.videoLoadedProperty.unlink(videoLoadedListener); + videoLoadedProperty.unlink(videoLoadedListener); this.graph.localBoundsProperty.unlink(graphBoundsListener); for (const [, { checkbox, property }] of this.trackCheckboxes) { checkbox.dispose(); @@ -204,7 +211,7 @@ export class KinematicsGraphNode extends Node { * Positions it in the upper right corner of the graph. */ private rebuildTrackCheckboxes(): void { - const tracks = this.model.tracking.tracksProperty.value; + const tracks = this.tracking.tracksProperty.value; if (tracks.length === 0) { this.trackCheckboxPanel.children = []; @@ -291,8 +298,8 @@ export class KinematicsGraphNode extends Node { return; } - const kinematics = this.model.tracking.trackKinematicsProperty.value; - const tracks = this.model.tracking.tracksProperty.value; + const kinematics = this.tracking.trackKinematicsProperty.value; + const tracks = this.tracking.tracksProperty.value; // Plot each selected track separately with its own color for (const trackId of selectedIds) { diff --git a/src/screen-name/view/MeasurementToolsPanel.ts b/src/screen-name/view/MeasurementToolsPanel.ts index 27b963f..c1c0da4 100644 --- a/src/screen-name/view/MeasurementToolsPanel.ts +++ b/src/screen-name/view/MeasurementToolsPanel.ts @@ -23,7 +23,7 @@ import { PANEL_CORNER_RADIUS, } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { OverlayToolsModel } from "../model/OverlayToolsModel.js"; // ── Icon geometry constants ────────────────────────────────────────────────── const ICON_EDGE_INSET = 1; @@ -128,19 +128,19 @@ function makeRow(icon: Node, property: import("scenerystack/axon").BooleanProper * Positioned above the Info button by SimScreenView. */ export class MeasurementToolsPanel extends Panel { - public constructor(model: SimModel) { + public constructor(overlayTools: OverlayToolsModel) { const a11yStrings = StringManager.getInstance().getA11y(); const rows = new VBox({ children: [ makeRow( measuringTapeIcon(), - model.overlayTools.measuringTapeVisibleProperty, + overlayTools.measuringTapeVisibleProperty, a11yStrings.toggleMeasuringTapeStringProperty.value, ), makeRow( angleToolIcon(), - model.overlayTools.angleToolVisibleProperty, + overlayTools.angleToolVisibleProperty, a11yStrings.toggleAngleToolStringProperty.value, ), ], diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index cf827ad..f595125 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -20,7 +20,7 @@ const a11yStrings = StringManager.getInstance().getA11y(); import trackLab from "../../TrackLabNamespace.js"; import GraphDataManager from "../graph/GraphDataManager.js"; -import type { SimModel } from "../model/SimModel.js"; +import type { VideoPlaybackModel } from "../model/VideoPlaybackModel.js"; const LABEL_FONT = new PhetFont(14); const CONTROLS_SPACING = 16; // gap between info display, time control, and scrubber @@ -45,7 +45,7 @@ export class PlaybackControlsNode extends HBox { private readonly disposePlaybackControlsNode: () => void; public constructor( - model: SimModel, + playback: VideoPlaybackModel, videoElement: HTMLVideoElement, onStepBackward: () => void, onStepForward: () => void, @@ -68,7 +68,7 @@ export class PlaybackControlsNode extends HBox { // view → model const onSpeedChange = (speed: TimeSpeed) => { - model.playback.playbackRateProperty.value = speedMap.get(speed) ?? SPEED_NORMAL; + 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.playback.playbackRateProperty.lazyLink(onRateChange); + playback.playbackRateProperty.lazyLink(onRateChange); // ── TimeControlNode: play/pause + step back + step forward + speed ───── - const timeControlNode = new TimeControlNode(model.playback.isPlayingProperty, { + const timeControlNode = new TimeControlNode(playback.isPlayingProperty, { timeSpeedProperty: timeSpeedProperty, timeSpeeds: [TimeSpeed.NORMAL, TimeSpeed.SLOW], speedRadioButtonGroupPlacement: "left", - enabledProperty: model.playback.videoLoadedProperty, + enabledProperty: 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.playback.durationProperty.value; + const initDuration = 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.playback.currentTimeProperty, this.scrubberRange, { + const newScrubber = new HSlider(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.playback.videoLoadedProperty, + enabledProperty: playback.videoLoadedProperty, accessibleName: a11yStrings.videoScrubberStringProperty, }); // Add tick marks based on calculated intervals - const duration = model.playback.durationProperty.value; - const frameRate = model.playback.frameRateProperty.value; + const duration = playback.durationProperty.value; + const frameRate = playback.frameRateProperty.value; if (Number.isFinite(duration) && duration > 0 && frameRate > 0) { - const knownCount = model.playback.totalFrameCountProperty.value; + const knownCount = 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.playback.durationProperty.link(durationListener); + playback.durationProperty.link(durationListener); // Recreate scrubber when frame rate changes const frameRateListener = () => { this.replaceScrubber(createScrubber()); }; - model.playback.frameRateProperty.lazyLink(frameRateListener); + playback.frameRateProperty.lazyLink(frameRateListener); // Recreate scrubber when the exact frame count becomes known const totalFrameCountListener = () => { this.replaceScrubber(createScrubber()); }; - model.playback.totalFrameCountProperty.lazyLink(totalFrameCountListener); + playback.totalFrameCountProperty.lazyLink(totalFrameCountListener); const onTimeChange = (time: number) => { if (this.isScrubbing) { videoElement.currentTime = time; } }; - model.playback.currentTimeProperty.lazyLink(onTimeChange); + playback.currentTimeProperty.lazyLink(onTimeChange); // ── Time and frame info display ──────────────────────────────────────── const formatDuration = (seconds: number): string => { @@ -216,16 +216,16 @@ export class PlaybackControlsNode extends HBox { return `${seconds.toFixed(2)} ${playbackStrings.secondsUnitStringProperty.value}`; }; - const totalTimeTextProperty = new DerivedProperty([model.playback.durationProperty], (duration: number) => + const totalTimeTextProperty = new DerivedProperty([playback.durationProperty], (duration: number) => formatDuration(duration), ); const frameCountTextProperty = new DerivedProperty( [ - model.playback.currentTimeProperty, - model.playback.durationProperty, - model.playback.frameRateProperty, - model.playback.totalFrameCountProperty, + playback.currentTimeProperty, + playback.durationProperty, + playback.frameRateProperty, + playback.totalFrameCountProperty, ], (time: number, duration: number, frameRate: number, totalFrameCount: number) => { if (duration <= 0) { @@ -277,11 +277,11 @@ export class PlaybackControlsNode extends HBox { enabledAppearanceStrategy: (enabled: boolean, button: import("scenerystack/scenery").Node) => { button.opacity = enabled ? 1 : 0.45; }, - enabledProperty: model.playback.videoLoadedProperty, + enabledProperty: playback.videoLoadedProperty, accessibleName: a11yStrings.rewindToStartStringProperty, listener: () => { - model.playback.isPlayingProperty.value = false; - model.playback.currentTimeProperty.value = 0; + playback.isPlayingProperty.value = false; + playback.currentTimeProperty.value = 0; videoElement.currentTime = 0; }, }, @@ -291,11 +291,11 @@ export class PlaybackControlsNode extends HBox { this.disposePlaybackControlsNode = () => { timeSpeedProperty.unlink(onSpeedChange); - 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); + playback.playbackRateProperty.unlink(onRateChange); + playback.currentTimeProperty.unlink(onTimeChange); + playback.durationProperty.unlink(durationListener); + playback.frameRateProperty.unlink(frameRateListener); + 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 c51b216..c6562eb 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -70,13 +70,13 @@ export class SimScreenView extends ScreenView { ); // ── Control panel / tool checkboxes (upper left) ─────────────────────── - const controlPanel = new ControlPanel(model, trackLabPreferences); + const controlPanel = new ControlPanel(model.overlayTools, trackLabPreferences); controlPanel.left = this.layoutBounds.left + CONTROL_PANEL_LEFT_MARGIN; controlPanel.top = this.layoutBounds.top + SCREEN_TOP_MARGIN; this.addChild(controlPanel); // ── Track list panel (beneath control panel) ───────────────────────── - const trackListPanel = new TrackListPanel(model, model.playback.videoLoadedProperty); + const trackListPanel = new TrackListPanel(model.tracking, model.playback.videoLoadedProperty); this.addChild(trackListPanel); // Reactively reposition whenever controlPanel resizes (e.g. auto-tracking row toggles). controlPanel.boundsProperty.link(() => { @@ -95,10 +95,19 @@ export class SimScreenView extends ScreenView { // ── Overlay tools (children of the video content layer, video-local coords) ── // All overlay tools are added via addVideoOverlay() so they share the same // video-local coordinate space and transform with the video. - const coordinateSystemNode = new CoordinateSystemNode(axesShownProperty, model); + const coordinateSystemNode = new CoordinateSystemNode( + axesShownProperty, + model.overlayTools, + model.tracking.activeTrackIdProperty, + ); this.videoPlayerNode.addVideoOverlay(coordinateSystemNode); - const calibrationToolNode = new CalibrationToolNode(calibrationShownProperty, this, model); + const calibrationToolNode = new CalibrationToolNode( + calibrationShownProperty, + this, + model.overlayTools, + model.tracking.activeTrackIdProperty, + ); this.videoPlayerNode.addVideoOverlay(calibrationToolNode); const measuringTapeNode = new MeasuringTapeNode(measuringTapeShownProperty, model.overlayTools); @@ -109,7 +118,7 @@ export class SimScreenView extends ScreenView { // ── Data table (top right, shifts left when window is wider than layoutBounds) ─ const dataTableNode = new DataTableNode( - model, + model.tracking, model.playback.videoLoadedProperty, model.overlayTools.calibUnitProperty, ); @@ -136,7 +145,13 @@ export class SimScreenView extends ScreenView { }); // ── Kinematics graph (bottom right, above reset all) ───────────────── - const kinematicsGraph = new KinematicsGraphNode(model, this, trackLabPreferences); + const kinematicsGraph = new KinematicsGraphNode( + model.tracking, + model.overlayTools, + model.playback.videoLoadedProperty, + this, + trackLabPreferences, + ); this.addChild(kinematicsGraph); // ── Info dialog (explains digitizing workflow) ──────────────────────────── @@ -154,7 +169,7 @@ export class SimScreenView extends ScreenView { this.addChild(infoButton); // ── Measurement tools panel (above the info button, preference-gated) ─ - const measurementToolsPanel = new MeasurementToolsPanel(model); + const measurementToolsPanel = new MeasurementToolsPanel(model.overlayTools); measurementToolsPanel.visibleProperty = trackLabPreferences.enableMeasurementToolsProperty; this.addChild(measurementToolsPanel); diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index df120a5..0aff77d 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -27,8 +27,8 @@ const a11yStrings = StringManager.getInstance().getA11y(); import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; -import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; +import type { TrackingModel } from "../model/TrackingModel.js"; // ── Layout constants ──────────────────────────────────────────────────────── const PANEL_WIDTH = 110; // inner content width (reduced from 165) @@ -55,7 +55,7 @@ const SYMBOL_FONT = new PhetFont({ size: 12, weight: "bold" }); // reduced from class TrackRowNode extends Node { private readonly disposeTrackRowNode: () => void; - public constructor(track: Track, model: SimModel) { + public constructor(track: Track, tracking: TrackingModel) { super(); // colorIndex is always in range 0..TRACK_COLORS.length-1 by construction @@ -85,21 +85,21 @@ class TrackRowNode extends Node { symbolLabel.centerY = ROW_CY; // ── Checkbox: activates this track for video digitizing ─────────────── - const isDigitizingProperty = new BooleanProperty(model.tracking.activeTrackIdProperty.value === track.id); + const isDigitizingProperty = new BooleanProperty(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.tracking.activeTrackIdProperty.link(activeTrackListener); + tracking.activeTrackIdProperty.link(activeTrackListener); // Sync model from checkbox const digitizingListener = (isDigitizing: boolean) => { if (isDigitizing) { - model.tracking.activeTrackIdProperty.value = track.id; - } else if (model.tracking.activeTrackIdProperty.value === track.id) { - model.tracking.activeTrackIdProperty.value = null; + tracking.activeTrackIdProperty.value = track.id; + } else if (tracking.activeTrackIdProperty.value === track.id) { + tracking.activeTrackIdProperty.value = null; } }; isDigitizingProperty.lazyLink(digitizingListener); @@ -115,7 +115,7 @@ class TrackRowNode extends Node { // ── Trash button (right side) ───────────────────────────────────────── const trashButton = createTrackLabButton(makeTrashIcon(), { baseColor: TrackLabColors.trashButtonBaseProperty, - listener: () => model.tracking.removeTrack(track.id), + listener: () => tracking.removeTrack(track.id), accessibleName: a11yStrings.removeTrackStringProperty.value.replace("{{symbol}}", track.symbol), }); trashButton.centerY = ROW_CY; @@ -129,7 +129,7 @@ class TrackRowNode extends Node { // Store cleanup function this.disposeTrackRowNode = () => { - model.tracking.activeTrackIdProperty.unlink(activeTrackListener); + tracking.activeTrackIdProperty.unlink(activeTrackListener); isDigitizingProperty.unlink(digitizingListener); checkbox.dispose(); trashButton.dispose(); @@ -148,7 +148,7 @@ class TrackRowNode extends Node { export class TrackListPanel extends Panel { private readonly disposeTrackListPanel: () => void; - public constructor(model: SimModel, videoLoadedProperty: TReadOnlyProperty) { + public constructor(tracking: TrackingModel, videoLoadedProperty: TReadOnlyProperty) { const trackListStrings = StringManager.getInstance().getTrackList(); // Width enforcer: invisible rectangle keeps the panel wide even when the @@ -161,13 +161,13 @@ export class TrackListPanel extends Panel { // ── "Add Track" button ──────────────────────────────────────────────── const addButtonEnabledProperty = new DerivedProperty( - [videoLoadedProperty, model.tracking.canAddTrackProperty], + [videoLoadedProperty, tracking.canAddTrackProperty], (loaded, canAdd) => loaded && canAdd, ); const addButton = createTrackLabButton(makePlusIcon(), { enabledProperty: addButtonEnabledProperty, - listener: () => model.tracking.addTrack(), + listener: () => tracking.addTrack(), accessibleName: trackListStrings.addTrackStringProperty, }); @@ -221,14 +221,14 @@ export class TrackListPanel extends Panel { child.dispose(); } } - trackListVBox.children = tracks.map((track) => new TrackRowNode(track, model)); + trackListVBox.children = tracks.map((track) => new TrackRowNode(track, tracking)); }; - model.tracking.tracksProperty.link(tracksListener); + tracking.tracksProperty.link(tracksListener); // Store cleanup function this.disposeTrackListPanel = () => { videoLoadedProperty.unlink(videoLoadedListener); - model.tracking.tracksProperty.unlink(tracksListener); + 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 2d8c5e5..4bb6e5a 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -11,6 +11,7 @@ import { DOM, Node } from "scenerystack/scenery"; import TrackLabColors from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; +import type { VideoPlaybackModel } from "../model/VideoPlaybackModel.js"; const MAIN_CONTENT_SPACING = 10; // VBox gap between source control, video layer, and playback @@ -41,7 +42,7 @@ export class VideoPlayerNode extends Node { * should be added via addVideoOverlay() rather than accessing this layer directly. */ private readonly videoContentLayer: Node; - private readonly model: SimModel; + private readonly playback: VideoPlaybackModel; private readonly disposeVideoPlayer: () => void; /** Tracks the current blob URL so it can be revoked when a new one is loaded. */ private currentBlobUrl: string | null = null; @@ -54,7 +55,7 @@ export class VideoPlayerNode extends Node { */ public constructor(model: SimModel, listParent: Node) { super(); - this.model = model; + this.playback = model.playback; const a11yStrings = StringManager.getInstance().getA11y(); @@ -110,10 +111,24 @@ export class VideoPlayerNode extends Node { [model.playback.videoLoadedProperty, model.overlayTools.autoTrackingProperty], (loaded, tracking) => loaded && tracking, ); - const autoTrackerNode = new AutoTrackerNode(this.videoElement, autoTrackingShownProperty, model); + const autoTrackerNode = new AutoTrackerNode(this.videoElement, autoTrackingShownProperty, { + tracking: model.tracking, + videoDimensionsProperty: model.playback.videoDimensionsProperty, + frameRateProperty: model.playback.frameRateProperty, + modelViewTransformProperty: model.overlayTools.modelViewTransformProperty, + }); // ── Manual digitizing overlay ───────────────────────────────────────── - const digitizingOverlayNode = new DigitizingOverlayNode(this.videoElement, model, () => this.stepForward()); + const digitizingOverlayNode = new DigitizingOverlayNode( + this.videoElement, + { + tracking: model.tracking, + playback: model.playback, + magnifyVideoProperty: model.overlayTools.magnifyVideoProperty, + modelViewTransformProperty: model.overlayTools.modelViewTransformProperty, + }, + () => this.stepForward(), + ); this.videoContentLayer = new Node({ children: [videoNode, autoTrackerNode, digitizingOverlayNode], @@ -158,7 +173,7 @@ export class VideoPlayerNode extends Node { // ── Playback controls (positioned by SimScreenView at screen bottom) ── this.playbackControlsNode = new PlaybackControlsNode( - model, + model.playback, this.videoElement, () => this.seekByFrames(-1), () => this.seekByFrames(1), @@ -202,7 +217,10 @@ export class VideoPlayerNode extends Node { // ── Video source controls (webcam panel is added to SimScreenView for z-order) ─ this.videoSourceControlNode = new VideoSourceControlNode( + model.sources, + model.playback.isPlayingProperty, model, + model.playback.frameRateProperty, listParent, (url) => { model.playback.isPlayingProperty.value = false; @@ -309,8 +327,8 @@ export class VideoPlayerNode extends Node { /** Pause playback and seek to the very beginning of the video. */ private rewindToStart(): void { - this.model.playback.isPlayingProperty.value = false; - this.model.playback.currentTimeProperty.value = 0; + this.playback.isPlayingProperty.value = false; + this.playback.currentTimeProperty.value = 0; this.videoElement.currentTime = 0; } @@ -320,18 +338,18 @@ export class VideoPlayerNode extends Node { } private seekByFrames(direction: number): void { - this.model.playback.isPlayingProperty.value = false; + this.playback.isPlayingProperty.value = false; const duration = this.videoElement.duration; if (!(duration > 0)) { return; } - const frameDuration = this.model.playback.frameDurationProperty.value; + const frameDuration = this.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.playback.currentTimeProperty.value = clamped; + this.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 1c20db0..0f34748 100644 --- a/src/screen-name/view/VideoSourceControlNode.ts +++ b/src/screen-name/view/VideoSourceControlNode.ts @@ -5,7 +5,7 @@ * accessing the webcam recording dialog. */ -import type { TReadOnlyProperty } from "scenerystack/axon"; +import type { NumberProperty, TProperty, TReadOnlyProperty } from "scenerystack/axon"; import { Property } from "scenerystack/axon"; import { HBox, type Node, Text } from "scenerystack/scenery"; import { CameraButton, PhetFont } from "scenerystack/scenery-phet"; @@ -17,9 +17,21 @@ import TrackLabColors from "../../TrackLabColors.js"; import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, MOUSE_AREA_DILATION, TOUCH_AREA_DILATION } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; import { countWebmFrames, getAnimatedWebPInfo } from "../../webcam.js"; -import { DEFAULT_FRAME_RATE, type SimModel, type UploadedVideo, type WebcamRecording } from "../model/SimModel.js"; +import { DEFAULT_FRAME_RATE, type UploadedVideo, type WebcamRecording } from "../model/SimModel.js"; +import type { VideoSourceModel } from "../model/VideoSourceModel.js"; import { WebcamPanel } from "./WebcamPanel.js"; +/** + * The three atomic activation methods from SimModel that VideoSourceControlNode + * needs to call when the user switches video source. Using a structural interface + * rather than the full SimModel keeps this node decoupled from the overall model. + */ +export type VideoSourceActivation = { + activateRecording: (recording: WebcamRecording) => void; + activateUpload: (upload: UploadedVideo) => void; + activateBundledVideo: (frameCount: number, fps: number) => void; +}; + const LABEL_FONT = new PhetFont(14); const HEADER_FONT = new PhetFont({ size: 12, style: "italic" }); const CONTROLS_SPACING = 12; @@ -69,7 +81,10 @@ export class VideoSourceControlNode extends HBox { private lastLoadedValue: string | null = null; public constructor( - model: SimModel, + sources: VideoSourceModel, + isPlayingProperty: TProperty, + activation: VideoSourceActivation, + frameRateProperty: NumberProperty, listParent: Node, onVideoSelected: VideoSelectedCallback, onWebcamReady: WebcamReadyCallback, @@ -192,19 +207,19 @@ export class VideoSourceControlNode extends HBox { } // Check webcam recordings - const recording = model.sources.webcamRecordingsProperty.value.find((r) => r.id === value); + const recording = sources.webcamRecordingsProperty.value.find((r) => r.id === value); if (recording) { this.lastLoadedValue = value; - model.activateRecording(recording); + activation.activateRecording(recording); onWebcamReady(recording.blob, recording.duration); return; } // Check uploaded videos - const upload = model.sources.uploadedVideosProperty.value.find((u) => u.id === value); + const upload = sources.uploadedVideosProperty.value.find((u) => u.id === value); if (upload) { this.lastLoadedValue = value; - model.activateUpload(upload); + activation.activateUpload(upload); onWebcamReady(upload.blob, upload.duration); return; } @@ -213,7 +228,7 @@ export class VideoSourceControlNode extends HBox { const videoInfo = VIDEO_FILES.find((v) => v.filename === value); if (videoInfo) { this.lastLoadedValue = value; - model.activateBundledVideo(videoInfo.frameCount, videoInfo.fps); + activation.activateBundledVideo(videoInfo.frameCount, videoInfo.fps); onVideoSelected(`./videos/${value}`); } }); @@ -323,8 +338,8 @@ export class VideoSourceControlNode extends HBox { }; const rebuildComboBox = (): void => { - const recordings = model.sources.webcamRecordingsProperty.value; - const uploads = model.sources.uploadedVideosProperty.value; + const recordings = sources.webcamRecordingsProperty.value; + const uploads = sources.uploadedVideosProperty.value; const oldBox = videoComboBox; videoComboBox = buildComboBox(recordings, uploads); @@ -342,14 +357,14 @@ export class VideoSourceControlNode extends HBox { videoComboBox = buildComboBox([], []); // Rebuild when either list changes - model.sources.webcamRecordingsProperty.lazyLink(() => rebuildComboBox()); - model.sources.uploadedVideosProperty.lazyLink(() => rebuildComboBox()); + sources.webcamRecordingsProperty.lazyLink(() => rebuildComboBox()); + sources.uploadedVideosProperty.lazyLink(() => rebuildComboBox()); // ── Download button (visible for user-provided videos) ──────────────── const downloadButton = createTrackLabButton(makeDownloadIcon(), { accessibleName: videoSourceStrings.downloadVideoStringProperty, listener: () => { - const blob = model.sources.currentWebcamBlobProperty.value; + const blob = sources.currentWebcamBlobProperty.value; if (!blob) { return; } @@ -363,7 +378,7 @@ export class VideoSourceControlNode extends HBox { }, }); downloadButton.visible = false; - model.sources.isWebcamVideoProperty.link((isUserVideo) => { + sources.isWebcamVideoProperty.link((isUserVideo) => { downloadButton.visible = isUserVideo; }); @@ -390,7 +405,7 @@ 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.sources.addUploadedVideo( + const upload = sources.addUploadedVideo( blob, file.name, duration, @@ -398,15 +413,15 @@ export class VideoSourceControlNode extends HBox { frameCount > 0 ? frameCount : undefined, ); // Setting selectedVideoProperty triggers the lazyLink which calls - // model.activateUpload(upload) and onWebcamReady atomically. + // activation.activateUpload(upload) and onWebcamReady atomically. selectedVideoProperty.value = upload.id; return; } const storeAndLoad = (duration: number, fps?: number, frameCount?: number) => { - const upload = model.sources.addUploadedVideo(blob, file.name, duration, fps, frameCount); + const upload = sources.addUploadedVideo(blob, file.name, duration, fps, frameCount); // Setting selectedVideoProperty triggers the lazyLink which calls - // model.activateUpload(upload) and onWebcamReady atomically. + // activation.activateUpload(upload) and onWebcamReady atomically. selectedVideoProperty.value = upload.id; }; @@ -435,20 +450,20 @@ export class VideoSourceControlNode extends HBox { const uploadButton = createTrackLabButton(makeUploadIcon(), { accessibleName: videoSourceStrings.openVideoFileStringProperty, listener: () => { - model.playback.isPlayingProperty.value = false; + isPlayingProperty.value = false; fileInput.click(); }, }); // ── Webcam panel and button ─────────────────────────────────────────── this.webcamPanel = new WebcamPanel({ - model: model, + frameRateProperty: frameRateProperty, onVideoReady: (blob, duration) => { this.webcamPanel.visible = false; // Store the recording in the model (this triggers a ComboBox rebuild). - const recording = model.sources.addWebcamRecording(blob, duration, model.playback.frameRateProperty.value); + const recording = sources.addWebcamRecording(blob, duration, frameRateProperty.value); // Setting selectedVideoProperty triggers the lazyLink which calls - // model.activateRecording(recording) and onWebcamReady atomically. + // activation.activateRecording(recording) and onWebcamReady atomically. selectedVideoProperty.value = recording.id; }, onCancel: () => { @@ -470,7 +485,7 @@ export class VideoSourceControlNode extends HBox { tandem: Tandem.OPT_OUT, accessibleName: videoSourceStrings.recordWebcamStringProperty, listener: async () => { - model.playback.isPlayingProperty.value = false; + 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 ac5e20a..eb5237e 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -5,6 +5,7 @@ * live preview, recording controls, and frame rate configuration. */ +import type { NumberProperty } from "scenerystack/axon"; import { Property } from "scenerystack/axon"; import { Shape } from "scenerystack/kite"; import { DOM, HBox, Node, Path, Text, VBox } from "scenerystack/scenery"; @@ -17,7 +18,7 @@ import TrackLabColors from "../../TrackLabColors.js"; import { WEBCAM_PREVIEW_HEIGHT, WEBCAM_PREVIEW_WIDTH } from "../../TrackLabConstants.js"; import trackLab from "../../TrackLabNamespace.js"; import { estimateVideoFrameRate, type FPSEstimate, fixWebmDuration, WebcamRecorder } from "../../webcam.js"; -import { FRAME_RATE_RANGE, type SimModel } from "../model/SimModel.js"; +import { FRAME_RATE_RANGE } from "../model/SimModel.js"; const FONT = new PhetFont(14); const SMALL_FONT = new PhetFont(12); @@ -41,8 +42,8 @@ const SECONDS_PER_MINUTE = 60; // conversion factor for mm:ss timer formatting /** Configuration passed to WebcamPanel at construction time. */ type WebcamPanelOptions = { - /** Simulation model; used to write the detected frame rate. */ - model: SimModel; + /** Writable frame-rate property; updated with the detected FPS after recording. */ + frameRateProperty: NumberProperty; /** Called with the recorded blob and its duration when the user confirms the video. */ onVideoReady: (blob: Blob, duration: number) => void; /** Called when the user dismisses the panel without confirming a recording. */ @@ -69,7 +70,7 @@ export class WebcamPanel extends Node { private readonly fpsEstimateText: Text; private readonly previewLayer: Node; private readonly reviewLayer: Node; - private readonly model: SimModel; + private readonly frameRateProperty: NumberProperty; private recordedBlob: Blob | null = null; private timerInterval: ReturnType | null = null; @@ -78,7 +79,7 @@ export class WebcamPanel extends Node { public constructor(options: WebcamPanelOptions) { super(); - this.model = options.model; + this.frameRateProperty = options.frameRateProperty; this.webcamStrings = StringManager.getInstance().getWebcam(); // ── Camera select ───────────────────────────────────────────────────── @@ -187,7 +188,7 @@ export class WebcamPanel extends Node { fill: TrackLabColors.textMutedProperty, }); - const fpsPicker = new NumberPicker(this.model.playback.frameRateProperty, new Property(FRAME_RATE_RANGE), { + const fpsPicker = new NumberPicker(this.frameRateProperty, new Property(FRAME_RATE_RANGE), { font: SMALL_FONT, scale: FPS_PICKER_SCALE, touchAreaXDilation: 10, @@ -390,7 +391,7 @@ export class WebcamPanel extends Node { } this.updateFPSEstimateDisplay(); // Set the estimated FPS as the initial value - this.model.playback.frameRateProperty.value = this.fpsEstimate.fps; + this.frameRateProperty.value = this.fpsEstimate.fps; } catch (_error) { this.fpsEstimateText.string = ""; }