Skip to content

Commit 2922217

Browse files
authored
Merge pull request #13 from veillette/claude/refactor-model-view-separation-JkHj2
Refactor constants and playback rate handling
2 parents cd37086 + 044ba2b commit 2922217

10 files changed

Lines changed: 91 additions & 101 deletions

src/TrackLabConstants.ts

Lines changed: 16 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,22 @@
1010
// Shared corner radius used by the main side panels.
1111
export const PANEL_CORNER_RADIUS = 8;
1212

13+
// ── Video display dimensions ───────────────────────────────────────────────────
14+
// The video element is always rendered at this fixed pixel size.
15+
// Both the OpenCV tracker and all overlay nodes depend on these values.
16+
export const VIDEO_WIDTH = 640;
17+
export const VIDEO_HEIGHT = 360;
18+
19+
// ── Video position in screen (layout) coordinates ────────────────────────────
20+
// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618).
21+
// The video element is centered at layoutBounds.center + (0, VIDEO_PLAYER_Y_OFFSET).
22+
export const VIDEO_CENTER_X = 512; // 1024 / 2
23+
export const VIDEO_CENTER_Y = 289; // 618 / 2 + VIDEO_PLAYER_Y_OFFSET (309 - 20)
24+
25+
// ── Initial calibration tool geometry ─────────────────────────────────────────
26+
// Half-length of the default calibration segment (pixels from centre to each endpoint).
27+
export const CALIB_HALF_LENGTH = 100;
28+
1329
// ── Screen layout offsets ─────────────────────────────────────────────────────
1430
// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618).
1531
export const VIDEO_PLAYER_Y_OFFSET = -20; // video center offset below layout center

src/screen-name/graph/ConfigurableGraph.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -632,8 +632,9 @@ export default class ConfigurableGraph extends Node {
632632
* Add a new data point based on current property values
633633
*/
634634
public addDataPoint(): void {
635-
const xValue = this.xPropertyProperty.value.property.value;
636-
const yValue = this.yPropertyProperty.value.property.value;
635+
const xValue = this.xPropertyProperty.value.property?.value;
636+
const yValue = this.yPropertyProperty.value.property?.value;
637+
if (xValue === undefined || yValue === undefined) return;
637638

638639
this.dataManager.addDataPoint(xValue, yValue);
639640
}
@@ -697,7 +698,7 @@ export default class ConfigurableGraph extends Node {
697698
}
698699
// For properties without sub-step data, fall back to current property value.
699700
// This handles derived properties like energy, RMS values, etc.
700-
return axisProperty.property.value;
701+
return axisProperty.property?.value ?? null;
701702
}
702703

703704
/**

src/screen-name/graph/PlottableProperty.ts

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,8 +15,11 @@ export type PlottableProperty = {
1515
// The name to display in the selector (can be a string or a localized string property)
1616
name: string | TReadOnlyProperty<string>;
1717

18-
// The property to read values from
19-
property: TReadOnlyProperty<number>;
18+
// The property to read values from.
19+
// Required when subStepAccessor is absent; may be omitted when subStepAccessor
20+
// covers all usage paths (e.g. kinematic variables that are always pushed via
21+
// addDataPointsFromSubSteps rather than polled with addDataPoint).
22+
property?: TReadOnlyProperty<number>;
2023

2124
// Optional unit string for axis label (e.g., "m", "m/s", "J")
2225
// Can be a static string or a dynamic property for units that depend on calibration

src/screen-name/model/SimModel.ts

Lines changed: 23 additions & 15 deletions
Original file line numberDiff line numberDiff line change
@@ -8,10 +8,15 @@ import {
88
import { Matrix3, Range, Transform3, Vector2 } from "scenerystack/dot";
99
import { TRACK_COLORS } from "../../TrackLabColors.js";
1010
import {
11+
CALIB_HALF_LENGTH,
1112
MIN_CALIB_DISTANCE,
1213
MIN_PIXEL_DISTANCE,
1314
TRACK_SYMBOL_FIRST_CODE,
1415
TRACK_SYMBOL_LAST_CODE,
16+
VIDEO_CENTER_X,
17+
VIDEO_CENTER_Y,
18+
VIDEO_HEIGHT,
19+
VIDEO_WIDTH,
1520
} from "../../TrackLabConstants.js";
1621
import { OpenCVTracker } from "../../tracking/OpenCVTracker.js";
1722
import type {
@@ -21,10 +26,6 @@ import type {
2126
TrackPoint,
2227
} from "./Track.js";
2328

24-
// Video display dimensions (used by tracker and views)
25-
export const VIDEO_WIDTH = 640;
26-
export const VIDEO_HEIGHT = 360;
27-
2829
// ── Calibration unit type ──────────────────────────────────────────────────
2930
export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const;
3031
export type CalibrationUnit = (typeof CALIBRATION_UNITS)[number];
@@ -35,16 +36,15 @@ export const FRAME_RATE_OPTIONS = [15, 24, 25, 29.97, 30, 50, 60] as const;
3536
export const DEFAULT_FRAME_RATE = 30;
3637
export const FRAME_RATE_RANGE = new Range(1, 120);
3738

38-
// ── Layout constants ───────────────────────────────────────────────────────
39-
// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618).
40-
// The VideoPlayerNode is centered at layoutBounds.center + (0, -20).
41-
const LAYOUT_CENTER_X = 512; // 1024 / 2
42-
const LAYOUT_CENTER_Y = 309; // 618 / 2
43-
export const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512
44-
export const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289
45-
const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint
39+
// ── Playback speed multiplier ──────────────────────────────────────────────
40+
// Stores the actual rate multiplier (1 = normal, 0.5 = slow, 2 = fast).
41+
// The view maps a TimeSpeed enum to one of these values; the model never
42+
// imports scenery-phet, so it only sees the numeric rate.
43+
export const DEFAULT_PLAYBACK_RATE = 1;
44+
export const PLAYBACK_RATE_RANGE = new Range(0.1, 4);
4645

47-
// Initial tool positions (view / pixel space)
46+
// ── Initial tool positions (view / pixel space) ───────────────────────────
47+
// These default positions are computed from the shared video layout constants.
4848
const COORD_ORIGIN_INITIAL = new Vector2(
4949
VIDEO_CENTER_X - VIDEO_WIDTH / 4,
5050
VIDEO_CENTER_Y,
@@ -53,8 +53,8 @@ const CALIB_CENTER_INITIAL = new Vector2(
5353
VIDEO_CENTER_X,
5454
VIDEO_CENTER_Y + VIDEO_HEIGHT / 4,
5555
);
56-
const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY(-CALIB_HALF_LEN, 0);
57-
const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY(CALIB_HALF_LEN, 0);
56+
const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY(-CALIB_HALF_LENGTH, 0);
57+
const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY(CALIB_HALF_LENGTH, 0);
5858

5959
// ── Model-view transform builder ───────────────────────────────────────────
6060
/**
@@ -292,6 +292,13 @@ export class SimModel {
292292
range: FRAME_RATE_RANGE,
293293
});
294294

295+
// ── Playback speed multiplier (1 = normal, 0.5 = slow, 2 = fast) ────────
296+
// The view maps its TimeSpeed enum to this value; the model stays free of
297+
// any scenery-phet dependency.
298+
public readonly playbackRateProperty = new NumberProperty(DEFAULT_PLAYBACK_RATE, {
299+
range: PLAYBACK_RATE_RANGE,
300+
});
301+
295302
// Derived frame duration for convenience
296303
public readonly frameDurationProperty: TReadOnlyProperty<number> =
297304
new DerivedProperty([this.frameRateProperty], (fps) => 1 / fps);
@@ -460,6 +467,7 @@ export class SimModel {
460467
this.currentTimeProperty.reset();
461468
this.durationProperty.reset();
462469
this.frameRateProperty.reset();
470+
this.playbackRateProperty.reset();
463471
this.axesVisibleProperty.reset();
464472
this.calibrationVisibleProperty.reset();
465473
this.magnifyVideoProperty.reset();

src/screen-name/view/AutoTrackerNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,8 @@ import { PhetFont } from "scenerystack/scenery-phet";
1313
import { Tandem } from "scenerystack/tandem";
1414
import { StringManager } from "../../i18n/StringManager.js";
1515
import TrackLabColors from "../../TrackLabColors.js";
16-
import { type SimModel, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";
16+
import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
17+
import type { SimModel } from "../model/SimModel.js";
1718

1819
const MAX_TRAIL = 150;
1920
const CROSSHAIR_SIZE = 16;

src/screen-name/view/CoordinateSystemNode.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -6,8 +6,8 @@ import { ArrowNode, PhetFont } from "scenerystack/scenery-phet";
66
import { Tandem } from "scenerystack/tandem";
77
import { StringManager } from "../../i18n/StringManager.js";
88
import TrackLabColors from "../../TrackLabColors.js";
9+
import { VIDEO_CENTER_X, VIDEO_CENTER_Y, VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
910
import type { SimModel } from "../model/SimModel.js";
10-
import { VIDEO_CENTER_X, VIDEO_CENTER_Y, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";
1111

1212
const ARROW_LENGTH = 120;
1313

src/screen-name/view/DigitizingOverlayNode.ts

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -10,7 +10,8 @@ import {
1010
} from "scenerystack/scenery";
1111
import { Tandem } from "scenerystack/tandem";
1212
import TrackLabColors from "../../TrackLabColors.js";
13-
import { type SimModel, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";
13+
import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
14+
import type { SimModel } from "../model/SimModel.js";
1415

1516
const OUTER_R = 12;
1617
const INNER_R = 2;

src/screen-name/view/KinematicsGraphNode.ts

Lines changed: 14 additions & 75 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,6 @@
66
*/
77

88
import {
9-
NumberProperty,
109
Property,
1110
type TReadOnlyProperty,
1211
} from "scenerystack/axon";
@@ -26,17 +25,17 @@ const GRAPH_HEIGHT = 200;
2625
const MAX_DATA_POINTS = 5000;
2726

2827
/**
29-
* Creates a PlottableProperty for a kinematic variable with a subStepAccessor.
28+
* Creates a PlottableProperty for a kinematic variable driven entirely by
29+
* subStepAccessor. No backing Property is needed because KinematicsGraphNode
30+
* always feeds data via addDataPointsFromSubSteps rather than addDataPoint.
3031
*/
3132
function createPlottableProperty(
3233
name: string,
3334
unit: string | TReadOnlyProperty<string>,
34-
dummyProperty: NumberProperty,
3535
accessor: (point: SubStepDataPoint) => number,
3636
): PlottableProperty {
3737
return {
3838
name,
39-
property: dummyProperty,
4039
unit,
4140
subStepAccessor: accessor,
4241
};
@@ -51,17 +50,6 @@ export class KinematicsGraphNode extends VBox {
5150
private currentComboBox: ComboBox<string | null> | null = null;
5251
private readonly disposeKinematicsGraph: () => void;
5352

54-
// Dummy properties for the graph (values aren't used directly, we push data manually)
55-
private readonly tProperty = new NumberProperty(0);
56-
private readonly xProperty = new NumberProperty(0);
57-
private readonly yProperty = new NumberProperty(0);
58-
private readonly vxProperty = new NumberProperty(0);
59-
private readonly vyProperty = new NumberProperty(0);
60-
private readonly speedProperty = new NumberProperty(0);
61-
private readonly axProperty = new NumberProperty(0);
62-
private readonly ayProperty = new NumberProperty(0);
63-
private readonly aMagProperty = new NumberProperty(0);
64-
6553
public constructor(model: SimModel, listParent: Node) {
6654
super({
6755
spacing: 8,
@@ -72,58 +60,18 @@ export class KinematicsGraphNode extends VBox {
7260
this.listParent = listParent;
7361
this.selectedTrackProperty = new Property<string | null>(null);
7462

75-
// Create plottable properties using unit properties from the model
76-
// Accessor functions return 0 for undefined values (filtered out later by NaN check)
63+
// Create plottable properties using unit properties from the model.
64+
// Accessor functions return 0 for undefined values (filtered out later by NaN check).
7765
const plottableProperties: PlottableProperty[] = [
78-
createPlottableProperty("t", "s", this.tProperty, (pt) => pt.t ?? 0),
79-
createPlottableProperty(
80-
"x",
81-
model.distanceUnitProperty,
82-
this.xProperty,
83-
(pt) => pt.x ?? 0,
84-
),
85-
createPlottableProperty(
86-
"y",
87-
model.distanceUnitProperty,
88-
this.yProperty,
89-
(pt) => pt.y ?? 0,
90-
),
91-
createPlottableProperty(
92-
"vx",
93-
model.velocityUnitProperty,
94-
this.vxProperty,
95-
(pt) => pt.vx ?? 0,
96-
),
97-
createPlottableProperty(
98-
"vy",
99-
model.velocityUnitProperty,
100-
this.vyProperty,
101-
(pt) => pt.vy ?? 0,
102-
),
103-
createPlottableProperty(
104-
"speed",
105-
model.velocityUnitProperty,
106-
this.speedProperty,
107-
(pt) => pt.speed ?? 0,
108-
),
109-
createPlottableProperty(
110-
"ax",
111-
model.accelerationUnitProperty,
112-
this.axProperty,
113-
(pt) => pt.ax ?? 0,
114-
),
115-
createPlottableProperty(
116-
"ay",
117-
model.accelerationUnitProperty,
118-
this.ayProperty,
119-
(pt) => pt.ay ?? 0,
120-
),
121-
createPlottableProperty(
122-
"|a|",
123-
model.accelerationUnitProperty,
124-
this.aMagProperty,
125-
(pt) => pt.aMag ?? 0,
126-
),
66+
createPlottableProperty("t", "s", (pt) => pt.t ?? 0),
67+
createPlottableProperty("x", model.distanceUnitProperty, (pt) => pt.x ?? 0),
68+
createPlottableProperty("y", model.distanceUnitProperty, (pt) => pt.y ?? 0),
69+
createPlottableProperty("vx", model.velocityUnitProperty, (pt) => pt.vx ?? 0),
70+
createPlottableProperty("vy", model.velocityUnitProperty, (pt) => pt.vy ?? 0),
71+
createPlottableProperty("speed", model.velocityUnitProperty, (pt) => pt.speed ?? 0),
72+
createPlottableProperty("ax", model.accelerationUnitProperty, (pt) => pt.ax ?? 0),
73+
createPlottableProperty("ay", model.accelerationUnitProperty, (pt) => pt.ay ?? 0),
74+
createPlottableProperty("|a|", model.accelerationUnitProperty, (pt) => pt.aMag ?? 0),
12775
];
12876

12977
// Default: plot y vs x (trajectory)
@@ -216,15 +164,6 @@ export class KinematicsGraphNode extends VBox {
216164
}
217165
this.selectedTrackProperty.dispose();
218166
this.graph.dispose();
219-
this.tProperty.dispose();
220-
this.xProperty.dispose();
221-
this.yProperty.dispose();
222-
this.vxProperty.dispose();
223-
this.vyProperty.dispose();
224-
this.speedProperty.dispose();
225-
this.axProperty.dispose();
226-
this.ayProperty.dispose();
227-
this.aMagProperty.dispose();
228167
};
229168
}
230169

src/screen-name/view/PlaybackControlsNode.ts

Lines changed: 15 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -44,14 +44,27 @@ export class PlaybackControlsNode extends HBox {
4444
const uiStrings = StringManager.getInstance().getUI();
4545

4646
// ── Playback rate via TimeSpeed ────────────────────────────────────────
47-
const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL);
47+
// timeSpeedProperty is view-local (the TimeSpeed enum is a scenery-phet type
48+
// that cannot live in the model). It syncs bidirectionally with the numeric
49+
// model.playbackRateProperty so that model.reset() resets the radio buttons.
4850
const speedMap = new Map([
4951
[TimeSpeed.FAST, SPEED_FAST],
5052
[TimeSpeed.NORMAL, SPEED_NORMAL],
5153
[TimeSpeed.SLOW, SPEED_SLOW],
5254
]);
55+
const rateToSpeed = new Map(
56+
Array.from(speedMap.entries()).map(([k, v]) => [v, k]),
57+
);
58+
const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL);
59+
60+
// view → model
5361
timeSpeedProperty.link((speed) => {
54-
videoElement.playbackRate = speedMap.get(speed) ?? SPEED_NORMAL;
62+
model.playbackRateProperty.value = speedMap.get(speed) ?? SPEED_NORMAL;
63+
});
64+
65+
// model → view (handles reset and any future programmatic rate changes)
66+
model.playbackRateProperty.lazyLink((rate: number) => {
67+
timeSpeedProperty.value = rateToSpeed.get(rate) ?? TimeSpeed.NORMAL;
5568
});
5669

5770
// ── TimeControlNode: play/pause + step back + step forward + speed ─────

src/screen-name/view/VideoPlayerNode.ts

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,8 @@
11
import { DerivedProperty } from "scenerystack/axon";
22
import { DOM, Node, VBox } from "scenerystack/scenery";
33
import TrackLabColors from "../../TrackLabColors.js";
4-
import { type SimModel, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js";
4+
import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js";
5+
import type { SimModel } from "../model/SimModel.js";
56

67
const MAIN_CONTENT_SPACING = 10; // VBox gap between source control, video layer, and playback
78

@@ -90,6 +91,12 @@ export class VideoPlayerNode extends Node {
9091
};
9192
model.isPlayingProperty.lazyLink(isPlayingListener);
9293

94+
// ── Playback rate (applies model rate to the video element) ──────────
95+
const playbackRateListener = (rate: number) => {
96+
this.videoElement.playbackRate = rate;
97+
};
98+
model.playbackRateProperty.link(playbackRateListener);
99+
93100
// ── Playback controls ─────────────────────────────────────────────────
94101
const playbackControlsNode = new PlaybackControlsNode(
95102
model,
@@ -147,6 +154,7 @@ export class VideoPlayerNode extends Node {
147154
this.disposeVideoPlayer = () => {
148155
TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener);
149156
model.isPlayingProperty.unlink(isPlayingListener);
157+
model.playbackRateProperty.unlink(playbackRateListener);
150158
this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata);
151159
this.videoElement.removeEventListener("durationchange", updateDuration);
152160
this.videoElement.removeEventListener("ended", onEnded);

0 commit comments

Comments
 (0)