From 6229fbadbefdd59456752aa55c6caf14b4313676 Mon Sep 17 00:00:00 2001 From: Claude Date: Thu, 19 Feb 2026 11:59:32 +0000 Subject: [PATCH] hoist magic numbers to TrackLabConstants and named local constants MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Create src/TrackLabConstants.ts for cross-cutting shared constants (panel corner radius, screen layout offsets, track symbol ASCII codes, model-view transform thresholds, webcam dimensions). In every view/model file, replace inline numeric literals with named module-level constants — arrow dimensions, drag speeds, font sizes, line widths, dash patterns, spacing values, button margins, precision thresholds, and more. https://claude.ai/code/session_01Pj5aaDUhUVCGbKAqPh8SaF --- src/TrackLabConstants.ts | 35 ++++++++ src/screen-name/model/SimModel.ts | 20 +++-- src/screen-name/view/AutoTrackerNode.ts | 25 +++--- src/screen-name/view/CalibrationToolNode.ts | 42 +++++++--- src/screen-name/view/ControlPanel.ts | 80 ++++++++++++++----- src/screen-name/view/CoordinateSystemNode.ts | 49 ++++++++---- src/screen-name/view/DataTableNode.ts | 57 +++++++++---- src/screen-name/view/DigitizingOverlayNode.ts | 8 +- src/screen-name/view/PlaybackControlsNode.ts | 34 +++++--- src/screen-name/view/SimScreenView.ts | 22 +++-- src/screen-name/view/TrackListPanel.ts | 57 +++++++++---- src/screen-name/view/VideoPlayerNode.ts | 4 +- .../view/VideoSourceControlNode.ts | 25 +++--- src/screen-name/view/WebcamPanel.ts | 68 ++++++++++------ 14 files changed, 369 insertions(+), 157 deletions(-) create mode 100644 src/TrackLabConstants.ts diff --git a/src/TrackLabConstants.ts b/src/TrackLabConstants.ts new file mode 100644 index 0000000..63f206c --- /dev/null +++ b/src/TrackLabConstants.ts @@ -0,0 +1,35 @@ +/** + * TrackLabConstants.ts + * + * Central repository for numeric constants shared across the application. + * Component-specific constants that are only used within a single file live + * at the top of that file instead. + */ + +// ── Panel styling ───────────────────────────────────────────────────────────── +// Shared corner radius used by the main side panels. +export const PANEL_CORNER_RADIUS = 8; + +// ── Screen layout offsets ───────────────────────────────────────────────────── +// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618). +export const VIDEO_PLAYER_Y_OFFSET = -20; // video center offset below layout center +export const CONTROL_PANEL_LEFT_MARGIN = 10; // control panel inset from layout left edge +export const TRACK_LIST_LEFT_SPACING = 12; // gap between video right edge and track list +export const DATA_TABLE_TOP_SPACING = 8; // gap between track list bottom and data table +export const RESET_BUTTON_MARGIN = 10; // reset button inset from layout right/bottom edges + +// ── Track symbol limits ─────────────────────────────────────────────────────── +// Tracks are labelled A–Z using ASCII codes. These bounds are used both when +// creating a new track and when resetting the session. +export const TRACK_SYMBOL_FIRST_CODE = 65; // ASCII 'A' +export const TRACK_SYMBOL_LAST_CODE = 90; // ASCII 'Z' + +// ── Model-view transform precision thresholds ───────────────────────────────── +// Guards against degenerate (zero-length) calibration or pixel distances that +// would produce a singular transform matrix. +export const MIN_PIXEL_DISTANCE = 1e-6; // minimum pixel distance between calibration points +export const MIN_CALIB_DISTANCE = 1e-9; // minimum real-world calibration distance + +// ── Webcam panel ────────────────────────────────────────────────────────────── +export const WEBCAM_PREVIEW_WIDTH = 480; // width of the preview and review video elements +export const WEBCAM_PREVIEW_HEIGHT = 270; // height of the preview and review video elements diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 41fb646..fb3571f 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -7,6 +7,12 @@ import { } from "scenerystack/axon"; import { Matrix3, Range, Transform3, Vector2 } from "scenerystack/dot"; import { TRACK_COLORS } from "../../TrackLabColors.js"; +import { + MIN_CALIB_DISTANCE, + MIN_PIXEL_DISTANCE, + TRACK_SYMBOL_FIRST_CODE, + TRACK_SYMBOL_LAST_CODE, +} from "../../TrackLabConstants.js"; import { OpenCVTracker } from "../../tracking/OpenCVTracker.js"; import type { Track, TrackPoint } from "./Track.js"; @@ -66,7 +72,7 @@ function buildModelViewTransform( dist: number, ): Transform3 { const pixelDist = p1.distance(p2); - if (pixelDist < 1e-6 || dist < 1e-9) { + if (pixelDist < MIN_PIXEL_DISTANCE || dist < MIN_CALIB_DISTANCE) { return new Transform3(Matrix3.IDENTITY); } const s = pixelDist / dist; // pixels per model unit @@ -150,15 +156,17 @@ export class SimModel { // after a track is removed. Stable, unique symbols matter for data export // and user recognition: re-issuing "A" to a new track after the original "A" // is deleted would be confusing. The practical limit is 26 tracks per session. - private nextSymbolCode = 65; // ASCII code for 'A' + private nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; public addTrack(): void { - if (this.nextSymbolCode > 90) return; // 'Z' is the last allowed symbol + if (this.nextSymbolCode > TRACK_SYMBOL_LAST_CODE) return; // 'Z' is the last allowed symbol const symbol = String.fromCharCode(this.nextSymbolCode); const color = - TRACK_COLORS[(this.nextSymbolCode - 65) % TRACK_COLORS.length]; + TRACK_COLORS[ + (this.nextSymbolCode - TRACK_SYMBOL_FIRST_CODE) % TRACK_COLORS.length + ]; this.nextSymbolCode++; - this.canAddTrackProperty.value = this.nextSymbolCode <= 90; + this.canAddTrackProperty.value = this.nextSymbolCode <= TRACK_SYMBOL_LAST_CODE; const track: Track = { id: `track-${symbol}`, @@ -216,7 +224,7 @@ export class SimModel { this.tracksProperty.value = []; this.activeTrackIdProperty.value = null; this.canAddTrackProperty.value = true; - this.nextSymbolCode = 65; + this.nextSymbolCode = TRACK_SYMBOL_FIRST_CODE; this.tracker.dispose(); } diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index f8955d9..37c16a2 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -16,6 +16,13 @@ import { type SimModel, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js"; const MAX_TRAIL = 150; const CROSSHAIR_SIZE = 16; +const HINT_FONT_SIZE = 15; +const SELECTION_LINE_WIDTH = 2; +const SELECTION_LINE_DASH: number[] = [6, 3]; +const CROSSHAIR_LINE_WIDTH = 2; +const CROSSHAIR_CIRCLE_RADIUS = 6; // small filled circle at crosshair centre +const MIN_REGION_SIZE = 4; // minimum pixel width/height to begin tracking +const TRAIL_DOT_RADIUS = 3; // radius of each past-position dot in the trail /** * Transparent SceneryStack overlay that sits directly on top of the video element. @@ -72,7 +79,7 @@ export class AutoTrackerNode extends Node { // ── Hint text ──────────────────────────────────────────────────────── this.hintText = new Text("Drag on video to select object to track", { - font: new PhetFont({ size: 15, weight: "bold" }), + font: new PhetFont({ size: HINT_FONT_SIZE, weight: "bold" }), fill: TrackLabColors.trackerHintFillProperty, }); this.hintText.center = new Vector2(VIDEO_WIDTH / 2, VIDEO_HEIGHT / 2); @@ -81,8 +88,8 @@ export class AutoTrackerNode extends Node { // ── Selection rectangle ─────────────────────────────────────────────── this.selectionRect = new Rectangle(0, 0, 0, 0, { stroke: TrackLabColors.trackerSelectionStrokeProperty, - lineWidth: 2, - lineDash: [6, 3], + lineWidth: SELECTION_LINE_WIDTH, + lineDash: SELECTION_LINE_DASH, fill: TrackLabColors.trackerSelectionFillProperty, visible: false, }); @@ -98,17 +105,17 @@ export class AutoTrackerNode extends Node { const crosshairStroke = TrackLabColors.trackerCrosshairStrokeProperty; this.crosshairH = new Line(-CROSSHAIR_SIZE, 0, CROSSHAIR_SIZE, 0, { stroke: crosshairStroke, - lineWidth: 2, + lineWidth: CROSSHAIR_LINE_WIDTH, visible: false, }); this.crosshairV = new Line(0, -CROSSHAIR_SIZE, 0, CROSSHAIR_SIZE, { stroke: crosshairStroke, - lineWidth: 2, + lineWidth: CROSSHAIR_LINE_WIDTH, visible: false, }); - this.crosshairCircle = new Path(Shape.circle(0, 0, 6), { + this.crosshairCircle = new Path(Shape.circle(0, 0, CROSSHAIR_CIRCLE_RADIUS), { stroke: crosshairStroke, - lineWidth: 2, + lineWidth: CROSSHAIR_LINE_WIDTH, visible: false, }); this.addChild(this.trailPath); @@ -158,7 +165,7 @@ export class AutoTrackerNode extends Node { h: Math.abs(p.y - this.selStart.y), }; - if (region.w > 4 && region.h > 4) { + if (region.w > MIN_REGION_SIZE && region.h > MIN_REGION_SIZE) { // initFromVideo is async (loads WASM on first call); tracking begins // automatically once `ready` becomes true. this.model.tracker.initFromVideo(videoElement, region).catch((err) => { @@ -231,7 +238,7 @@ export class AutoTrackerNode extends Node { private updateTrackerVisuals(pt: { x: number; y: number }): void { const shape = new Shape(); for (const p of this.trail) { - shape.circle(p.x, p.y, 3); + shape.circle(p.x, p.y, TRAIL_DOT_RADIUS); } this.trailPath.shape = shape; this.trailPath.visible = true; diff --git a/src/screen-name/view/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index 676bcfa..82dbae4 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -24,6 +24,18 @@ import { CALIBRATION_UNITS } from "../model/SimModel.js"; const FONT = new PhetFont(14); const ENDPOINT_RADIUS = 8; +const LINE_WIDTH = 2; +const LINE_DASH: number[] = [8, 4]; +const ENDPOINT_LINE_WIDTH = 1.5; +const MAX_KEYPAD_DECIMALS = 4; +const MIDPOINT_PANEL_SCALE = 0.5; +const MIDPOINT_PANEL_CORNER_RADIUS = 6; +const MIDPOINT_PANEL_X_MARGIN = 8; +const MIDPOINT_PANEL_Y_MARGIN = 6; +const MIDPOINT_PANEL_SPACING = 8; +const MIDPOINT_Y_OFFSET = 12; // pixels above midpoint where the panel sits +const ENDPOINT_DRAG_SPEED = 200; // pixels/s for normal keyboard drag +const ENDPOINT_SHIFT_DRAG_SPEED = 40; // pixels/s for shift-key keyboard drag export class CalibrationToolNode extends Node { public constructor( @@ -36,8 +48,8 @@ export class CalibrationToolNode extends Node { // ── Connecting line ──────────────────────────────────────────────────── const calibrationLine = new Line(0, 0, 0, 0, { stroke: TrackLabColors.calibrationStrokeProperty, - lineWidth: 2, - lineDash: [8, 4], + lineWidth: LINE_WIDTH, + lineDash: LINE_DASH, }); this.addChild(calibrationLine); @@ -46,7 +58,7 @@ export class CalibrationToolNode extends Node { new Circle(ENDPOINT_RADIUS, { fill: TrackLabColors.calibrationFillProperty, stroke: TrackLabColors.textOnDarkProperty, - lineWidth: 1.5, + lineWidth: ENDPOINT_LINE_WIDTH, cursor: "crosshair", tagName: "div", focusable: true, @@ -62,7 +74,7 @@ export class CalibrationToolNode extends Node { keypadLayout: Keypad.PositiveDecimalLayout, keypadOptions: { accumulatorOptions: { - maxDigitsRightOfMantissa: 4, + maxDigitsRightOfMantissa: MAX_KEYPAD_DECIMALS, }, }, tandem: Tandem.OPT_OUT, @@ -117,18 +129,18 @@ export class CalibrationToolNode extends Node { const midpointPanel = new Panel( new HBox({ children: [distanceButton, unitComboBox], - spacing: 8, + spacing: MIDPOINT_PANEL_SPACING, align: "center", }), { fill: TrackLabColors.panelFillProperty, stroke: TrackLabColors.panelStrokeLightProperty, - cornerRadius: 6, - xMargin: 8, - yMargin: 6, + cornerRadius: MIDPOINT_PANEL_CORNER_RADIUS, + xMargin: MIDPOINT_PANEL_X_MARGIN, + yMargin: MIDPOINT_PANEL_Y_MARGIN, }, ); - midpointPanel.setScaleMagnitude(0.5); + midpointPanel.setScaleMagnitude(MIDPOINT_PANEL_SCALE); this.addChild(midpointPanel); // ── Update geometry when endpoints move ─────────────────────────────── @@ -140,7 +152,7 @@ export class CalibrationToolNode extends Node { endpoint2.translation = p2; const mid = p1.blend(p2, 0.5); midpointPanel.centerX = mid.x; - midpointPanel.bottom = mid.y - 12; + midpointPanel.bottom = mid.y - MIDPOINT_Y_OFFSET; }; model.calibPoint1Property.link(updateGeometry); model.calibPoint2Property.link(updateGeometry); @@ -149,14 +161,20 @@ export class CalibrationToolNode extends Node { endpoint1.addInputListener( new RichDragListener({ positionProperty: model.calibPoint1Property, - keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 }, + keyboardDragListenerOptions: { + dragSpeed: ENDPOINT_DRAG_SPEED, + shiftDragSpeed: ENDPOINT_SHIFT_DRAG_SPEED, + }, tandem: Tandem.OPT_OUT, }), ); endpoint2.addInputListener( new RichDragListener({ positionProperty: model.calibPoint2Property, - keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 }, + keyboardDragListenerOptions: { + dragSpeed: ENDPOINT_DRAG_SPEED, + shiftDragSpeed: ENDPOINT_SHIFT_DRAG_SPEED, + }, tandem: Tandem.OPT_OUT, }), ); diff --git a/src/screen-name/view/ControlPanel.ts b/src/screen-name/view/ControlPanel.ts index fe19285..e57b10f 100644 --- a/src/screen-name/view/ControlPanel.ts +++ b/src/screen-name/view/ControlPanel.ts @@ -2,9 +2,21 @@ import { Circle, Line, Node, VBox } from "scenerystack/scenery"; import { ArrowNode } from "scenerystack/scenery-phet"; import { Checkbox, Panel } from "scenerystack/sun"; import TrackLabColors from "../../TrackLabColors.js"; +import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; const ICON_SIZE = 20; // bounding box each icon targets +const ICON_ARROW_HEAD_SIZE = 5; // headWidth and headHeight for icon arrows +const ICON_ARROW_TAIL_WIDTH = 1.5; +const ICON_LINE_WIDTH_THICK = 1.5; +const ICON_LINE_WIDTH_THIN = 1; +const ICON_LINE_WIDTH_MAGNIFIER = 2; // handle line in magnifier icon +const ICON_DOT_RADIUS = 3; // calibration endpoint dots +const ICON_CENTER_DOT_RADIUS = 2; // centre dot in tracking icon +const ICON_LINE_DASH: number[] = [3, 2]; +const PANEL_ROWS_SPACING = 12; +const PANEL_X_MARGIN = 12; +const PANEL_Y_MARGIN = 12; // ── Icons ───────────────────────────────────────────────────────────────── @@ -18,17 +30,17 @@ function axesIcon(): Node { { fill: TrackLabColors.axisXColorProperty, stroke: null, - headWidth: 5, - headHeight: 5, - tailWidth: 1.5, + headWidth: ICON_ARROW_HEAD_SIZE, + headHeight: ICON_ARROW_HEAD_SIZE, + tailWidth: ICON_ARROW_TAIL_WIDTH, }, ); const yArrow = new ArrowNode(0, ICON_SIZE * 0.7, 0, ICON_SIZE * 0.05, { fill: TrackLabColors.axisYColorProperty, stroke: null, - headWidth: 5, - headHeight: 5, - tailWidth: 1.5, + headWidth: ICON_ARROW_HEAD_SIZE, + headHeight: ICON_ARROW_HEAD_SIZE, + tailWidth: ICON_ARROW_TAIL_WIDTH, }); return new Node({ children: [xArrow, yArrow] }); } @@ -43,11 +55,11 @@ function calibrationIcon(): Node { children: [ new Line(cx - half, cy, cx + half, cy, { stroke: calColor, - lineWidth: 1.5, - lineDash: [3, 2], + lineWidth: ICON_LINE_WIDTH_THICK, + lineDash: ICON_LINE_DASH, }), - new Circle(3, { fill: calColor, x: cx - half, y: cy }), - new Circle(3, { fill: calColor, x: cx + half, y: cy }), + new Circle(ICON_DOT_RADIUS, { fill: calColor, x: cx - half, y: cy }), + new Circle(ICON_DOT_RADIUS, { fill: calColor, x: cx + half, y: cy }), ], }); } @@ -60,10 +72,16 @@ function magnifyIcon(): Node { const gray = TrackLabColors.iconGrayProperty; return new Node({ children: [ - new Circle(r, { stroke: gray, lineWidth: 1.5, fill: null, x: cx, y: cy }), + new Circle(r, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THICK, + fill: null, + x: cx, + y: cy, + }), new Line(cx + r * 0.7, cy + r * 0.7, ICON_SIZE - 1, ICON_SIZE - 1, { stroke: gray, - lineWidth: 2, + lineWidth: ICON_LINE_WIDTH_MAGNIFIER, }), ], }); @@ -78,12 +96,30 @@ function trackingIcon(): Node { const gray = TrackLabColors.iconGrayProperty; return new Node({ children: [ - new Circle(r, { stroke: gray, lineWidth: 1.5, fill: null, x: cx, y: cy }), - new Circle(2, { fill: gray, x: cx, y: cy }), - new Line(cx, cy - r - gap, cx, cy - gap, { stroke: gray, lineWidth: 1 }), - new Line(cx, cy + gap, cx, cy + r + gap, { stroke: gray, lineWidth: 1 }), - new Line(cx - r - gap, cy, cx - gap, cy, { stroke: gray, lineWidth: 1 }), - new Line(cx + gap, cy, cx + r + gap, cy, { stroke: gray, lineWidth: 1 }), + new Circle(r, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THICK, + fill: null, + x: cx, + y: cy, + }), + new Circle(ICON_CENTER_DOT_RADIUS, { fill: gray, x: cx, y: cy }), + new Line(cx, cy - r - gap, cx, cy - gap, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THIN, + }), + new Line(cx, cy + gap, cx, cy + r + gap, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THIN, + }), + new Line(cx - r - gap, cy, cx - gap, cy, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THIN, + }), + new Line(cx + gap, cy, cx + r + gap, cy, { + stroke: gray, + lineWidth: ICON_LINE_WIDTH_THIN, + }), ], }); } @@ -111,16 +147,16 @@ export class ControlPanel extends Panel { makeRow(magnifyIcon(), model.magnifyVideoProperty), makeRow(trackingIcon(), model.autoTrackingProperty), ], - spacing: 12, + spacing: PANEL_ROWS_SPACING, align: "left", }); super(rows, { fill: TrackLabColors.panelFillProperty, stroke: TrackLabColors.panelStrokeProperty, - cornerRadius: 8, - xMargin: 12, - yMargin: 12, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, }); } } diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index 7efaaee..74a2e4c 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -9,6 +9,21 @@ const ARROW_LENGTH = 120; const HANDLE_FRACTION = 1 / 3; const FONT = new PhetFont({ size: 14, weight: "bold" }); +const ARROW_HEAD_WIDTH = 12; +const ARROW_HEAD_HEIGHT = 10; +const ARROW_TAIL_WIDTH = 3; +const LABEL_OFFSET_X = 6; // gap between arrow tip and axis label +const LABEL_OFFSET_Y = 4; // gap between arrow tip and axis label +const HANDLE_RADIUS = 8; // rotation handle disk radius +const HANDLE_LINE_WIDTH = 1.5; +const ORIGIN_RADIUS = 5; // origin marker circle radius +const ORIGIN_LINE_WIDTH = 1; +const TRANSLATE_DRAG_SPEED = 300; // pixels/s for normal keyboard drag +const TRANSLATE_SHIFT_DRAG_SPEED = 50; // pixels/s for shift-key keyboard drag +const ROTATE_DRAG_SPEED = 100; // degrees/s (converted to radians) for keyboard rotation +const ROTATE_SHIFT_DRAG_SPEED = 20; +const DEG_TO_RAD = Math.PI / 180; + export class CoordinateSystemNode extends Node { public constructor( videoLoadedProperty: TReadOnlyProperty, @@ -24,9 +39,9 @@ export class CoordinateSystemNode extends Node { new ArrowNode(0, 0, ARROW_LENGTH, 0, { fill: TrackLabColors.axisXColorProperty, stroke: null, - headWidth: 12, - headHeight: 10, - tailWidth: 3, + headWidth: ARROW_HEAD_WIDTH, + headHeight: ARROW_HEAD_HEIGHT, + tailWidth: ARROW_TAIL_WIDTH, }), ); @@ -35,9 +50,9 @@ export class CoordinateSystemNode extends Node { new ArrowNode(0, 0, 0, -ARROW_LENGTH, { fill: TrackLabColors.axisYColorProperty, stroke: null, - headWidth: 12, - headHeight: 10, - tailWidth: 3, + headWidth: ARROW_HEAD_WIDTH, + headHeight: ARROW_HEAD_HEIGHT, + tailWidth: ARROW_TAIL_WIDTH, }), ); @@ -45,7 +60,7 @@ export class CoordinateSystemNode extends Node { new Text("x", { font: FONT, fill: TrackLabColors.axisXColorProperty, - left: ARROW_LENGTH + 6, + left: ARROW_LENGTH + LABEL_OFFSET_X, centerY: 0, }), ); @@ -55,17 +70,17 @@ export class CoordinateSystemNode extends Node { font: FONT, fill: TrackLabColors.axisYColorProperty, centerX: 0, - bottom: -ARROW_LENGTH - 4, + bottom: -ARROW_LENGTH - LABEL_OFFSET_Y, }), ); // Small disk at 1/3 of the way along the x-axis; dragging it rotates the system - const handleDisk = new Circle(8, { + const handleDisk = new Circle(HANDLE_RADIUS, { x: ARROW_LENGTH * HANDLE_FRACTION, y: 0, fill: TrackLabColors.calibrationHandleProperty, stroke: TrackLabColors.textOnDarkProperty, - lineWidth: 1.5, + lineWidth: HANDLE_LINE_WIDTH, cursor: "crosshair", tagName: "div", focusable: true, @@ -74,10 +89,10 @@ export class CoordinateSystemNode extends Node { rotatingNode.addChild(handleDisk); // ── Origin marker ───────────────────────────────────────────────────── - const originMarker = new Circle(5, { + const originMarker = new Circle(ORIGIN_RADIUS, { fill: TrackLabColors.originFillProperty, stroke: TrackLabColors.originStrokeProperty, - lineWidth: 1, + lineWidth: ORIGIN_LINE_WIDTH, }); // ── Position wrapper: translates with model.coordOriginProperty ─────── @@ -103,8 +118,8 @@ export class CoordinateSystemNode extends Node { new RichDragListener({ positionProperty: model.coordOriginProperty, keyboardDragListenerOptions: { - dragSpeed: 300, - shiftDragSpeed: 50, + dragSpeed: TRANSLATE_DRAG_SPEED, + shiftDragSpeed: TRANSLATE_SHIFT_DRAG_SPEED, }, tandem: Tandem.OPT_OUT, }), @@ -123,11 +138,11 @@ export class CoordinateSystemNode extends Node { }, keyboardDragListenerOptions: { keyboardDragDirection: "leftRight", - dragSpeed: 100, - shiftDragSpeed: 20, + dragSpeed: ROTATE_DRAG_SPEED, + shiftDragSpeed: ROTATE_SHIFT_DRAG_SPEED, drag: (_event, listener) => { model.coordAngleProperty.value += - listener.modelDelta.x * (Math.PI / 180); + listener.modelDelta.x * DEG_TO_RAD; }, }, tandem: Tandem.OPT_OUT, diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index 7a5bb18..0b716c8 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -16,6 +16,7 @@ import { DOM, HBox, Node, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { ButtonNode, Panel, RectangularPushButton } from "scenerystack/sun"; import TrackLabColors from "../../TrackLabColors.js"; +import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; @@ -25,6 +26,22 @@ const MAX_TABLE_HEIGHT = 200; // ── Fonts ──────────────────────────────────────────────────────────────────── const TITLE_FONT = new PhetFont({ size: 12, weight: "bold" }); +const TABLE_FONT_SIZE = 11; // HTML table font size in px +const EXPORT_BUTTON_FONT_SIZE = 9; + +// ── Precision ───────────────────────────────────────────────────────────────── +const CSV_DECIMAL_PLACES = 4; // decimal places for CSV time and position columns +const CELL_DECIMAL_PLACES = 3; // decimal places shown in on-screen table cells +const MIN_EMPTY_COL_COUNT = 4; // minimum columns (Frame, Time, x, y) when no tracks exist + +// ── Panel layout ────────────────────────────────────────────────────────────── +const PANEL_X_MARGIN = 10; +const PANEL_Y_MARGIN = 10; +const CONTENT_SPACING = 6; // gap between title row and table DOM node +const TITLE_ROW_SPACING = 8; // gap between title label and export button +const EXPORT_BUTTON_X_MARGIN = 5; +const EXPORT_BUTTON_Y_MARGIN = 3; +const EXPORT_BUTTON_ICON_SPACING = 3; // ── Helpers ────────────────────────────────────────────────────────────────── @@ -71,11 +88,17 @@ function generateCSV(tracks: readonly Track[], unit: string): string { // Data rows for (const row of dataRows) { - const cells: string[] = [String(row.frame), row.time.toFixed(4)]; + const cells: string[] = [ + String(row.frame), + row.time.toFixed(CSV_DECIMAL_PLACES), + ]; for (const track of tracks) { const val = row.values.get(track.id); if (val) { - cells.push(val.x.toFixed(4), val.y.toFixed(4)); + cells.push( + val.x.toFixed(CSV_DECIMAL_PLACES), + val.y.toFixed(CSV_DECIMAL_PLACES), + ); } else { cells.push("", ""); } @@ -122,7 +145,7 @@ function buildHTMLTable( table.style.cssText = ` border-collapse: collapse; font-family: Arial, sans-serif; - font-size: 11px; + font-size: ${TABLE_FONT_SIZE}px; white-space: nowrap; `; @@ -200,8 +223,8 @@ function buildHTMLTable( if (dataRows.length === 0) { const tr = document.createElement("tr"); const td = document.createElement("td"); - // Always at least 4 columns: Frame, Time, x, y - td.colSpan = Math.max(4, 2 + tracks.length * 2); + // Always at least MIN_EMPTY_COL_COUNT columns: Frame, Time, x, y + td.colSpan = Math.max(MIN_EMPTY_COL_COUNT, 2 + tracks.length * 2); td.textContent = "No digitized points"; td.style.cssText = ` padding: 8px 16px; @@ -231,13 +254,13 @@ function buildHTMLTable( }; addCell(String(row.frame)); - addCell(row.time.toFixed(3)); + addCell(row.time.toFixed(CELL_DECIMAL_PLACES)); for (const track of tracks) { const val = row.values.get(track.id); if (val) { - addCell(val.x.toFixed(3)); - addCell(val.y.toFixed(3)); + addCell(val.x.toFixed(CELL_DECIMAL_PLACES)); + addCell(val.y.toFixed(CELL_DECIMAL_PLACES)); } else { addCell("—"); addCell("—"); @@ -299,16 +322,16 @@ export class DataTableNode extends Panel { children: [ makeDownloadIcon(), new Text("CSV", { - font: new PhetFont({ size: 9, weight: "bold" }), + font: new PhetFont({ size: EXPORT_BUTTON_FONT_SIZE, weight: "bold" }), fill: TrackLabColors.textOnDarkProperty, }), ], - spacing: 3, + spacing: EXPORT_BUTTON_ICON_SPACING, }), baseColor: TrackLabColors.exportButtonProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 5, - yMargin: 3, + xMargin: EXPORT_BUTTON_X_MARGIN, + yMargin: EXPORT_BUTTON_Y_MARGIN, listener: () => { const tracks = model.tracksProperty.value; const unit = unitProperty.value; @@ -337,23 +360,23 @@ export class DataTableNode extends Panel { const titleRow = new HBox({ children: [titleLabel, exportButton], - spacing: 8, + spacing: TITLE_ROW_SPACING, align: "center", }); // ── Main content ───────────────────────────────────────────────────────── const content = new VBox({ children: [titleRow, tableDOMNode], - spacing: 6, + spacing: CONTENT_SPACING, align: "left", }); super(content, { fill: TrackLabColors.panelFillProperty, stroke: TrackLabColors.panelStrokeProperty, - cornerRadius: 8, - xMargin: 10, - yMargin: 10, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, visible: false, }); diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index af78370..376cdf2 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -25,6 +25,8 @@ const MAG_CROSSHAIR_RADIUS = 8; const MAG_CROSSHAIR_GAP = 2; const MAG_CROSSHAIR_LINE_WIDTH = 1; +const MARK_DOT_RADIUS = 2; // radius of each digitized-point dot drawn on the video + /** * Manual digitizing overlay for placing track points on the video. * Renders a custom crosshair cursor and magnifier, and places dots on click. @@ -265,7 +267,11 @@ export class DigitizingOverlayNode extends Node { new Vector2(point.x, point.y), ); circles.push( - new Circle(2, { fill: track.color, x: localPt.x, y: localPt.y }), + new Circle(MARK_DOT_RADIUS, { + fill: track.color, + x: localPt.x, + y: localPt.y, + }), ); } } diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 04f7a7c..76fae88 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -13,6 +13,18 @@ import { FRAME_RATE_RANGE, type SimModel } from "../model/SimModel.js"; const LABEL_FONT = new PhetFont(14); const SMALL_FONT = new PhetFont(12); +const CONTROLS_SPACING = 16; // gap between info display, time control, and scrubber +const SPEED_FAST = 2.0; // playback rate multiplier for TimeSpeed.FAST +const SPEED_NORMAL = 1.0; // playback rate multiplier for TimeSpeed.NORMAL +const SPEED_SLOW = 0.5; // playback rate multiplier for TimeSpeed.SLOW +const SCRUBBER_TRACK_WIDTH = 480; +const SCRUBBER_TRACK_HEIGHT = 4; +const SCRUBBER_THUMB_WIDTH = 12; +const SCRUBBER_THUMB_HEIGHT = 24; +const FPS_CONTROL_SPACING = 4; // gap between "fps:" label and spinner +const INFO_DISPLAY_SPACING = 2; // gap between time label, frame counter, and fps control +const FPS_SPINNER_SCALE = 0.6; +const FPS_SPINNER_MIN_WIDTH = 35; /** * Playback controls including time control, scrubber, and time/frame display. @@ -26,17 +38,17 @@ export class PlaybackControlsNode extends HBox { onStepBackward: () => void, onStepForward: () => void, ) { - super({ spacing: 16, align: "center" }); + super({ spacing: CONTROLS_SPACING, align: "center" }); // ── Playback rate via TimeSpeed ──────────────────────────────────────── const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL); const speedMap = new Map([ - [TimeSpeed.FAST, 2.0], - [TimeSpeed.NORMAL, 1.0], - [TimeSpeed.SLOW, 0.5], + [TimeSpeed.FAST, SPEED_FAST], + [TimeSpeed.NORMAL, SPEED_NORMAL], + [TimeSpeed.SLOW, SPEED_SLOW], ]); timeSpeedProperty.link((speed) => { - videoElement.playbackRate = speedMap.get(speed) ?? 1.0; + videoElement.playbackRate = speedMap.get(speed) ?? SPEED_NORMAL; }); // ── TimeControlNode: play/pause + step back + step forward + speed ───── @@ -66,8 +78,8 @@ export class PlaybackControlsNode extends HBox { model.currentTimeProperty as unknown as Property, rangeProperty, { - trackSize: new Dimension2(480, 4), - thumbSize: new Dimension2(12, 24), + trackSize: new Dimension2(SCRUBBER_TRACK_WIDTH, SCRUBBER_TRACK_HEIGHT), + thumbSize: new Dimension2(SCRUBBER_THUMB_WIDTH, SCRUBBER_THUMB_HEIGHT), startDrag: () => { this.isScrubbing = true; }, @@ -128,11 +140,11 @@ export class PlaybackControlsNode extends HBox { numberDisplayOptions: { decimalPlaces: 0, textOptions: { font: SMALL_FONT }, - minBackgroundWidth: 35, + minBackgroundWidth: FPS_SPINNER_MIN_WIDTH, }, arrowsPosition: "leftRight", arrowButtonOptions: { - scale: 0.6, + scale: FPS_SPINNER_SCALE, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, }, tandem: Tandem.OPT_OUT, @@ -141,13 +153,13 @@ export class PlaybackControlsNode extends HBox { const fpsControl = new HBox({ children: [fpsLabel, fpsSpinner], - spacing: 4, + spacing: FPS_CONTROL_SPACING, align: "center", }); const infoDisplay = new VBox({ children: [totalTimeLabel, frameCountLabel, fpsControl], - spacing: 2, + spacing: INFO_DISPLAY_SPACING, align: "left", }); diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index e48ff69..2ac4fb6 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -1,6 +1,13 @@ import { DerivedProperty } from "scenerystack/axon"; import { ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; +import { + CONTROL_PANEL_LEFT_MARGIN, + DATA_TABLE_TOP_SPACING, + RESET_BUTTON_MARGIN, + TRACK_LIST_LEFT_SPACING, + VIDEO_PLAYER_Y_OFFSET, +} from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import { CalibrationToolNode } from "./CalibrationToolNode.js"; import { ControlPanel } from "./ControlPanel.js"; @@ -47,19 +54,22 @@ export class SimScreenView extends ScreenView { // Uses model.modelViewTransformProperty (a DerivedProperty computed inside // SimModel from the tool state properties above). this.videoPlayerNode = new VideoPlayerNode(model, this); - this.videoPlayerNode.center = this.layoutBounds.center.plusXY(0, -20); + this.videoPlayerNode.center = this.layoutBounds.center.plusXY( + 0, + VIDEO_PLAYER_Y_OFFSET, + ); this.addChild(this.videoPlayerNode); // ── Control panel (left side) ───────────────────────────────────────── const controlPanel = new ControlPanel(model); - controlPanel.left = this.layoutBounds.left + 10; + controlPanel.left = this.layoutBounds.left + CONTROL_PANEL_LEFT_MARGIN; controlPanel.centerY = this.layoutBounds.centerY; this.addChild(controlPanel); // ── Track list panel (right of the video) ──────────────────────────── const trackListPanel = new TrackListPanel(model, model.videoLoadedProperty); this.addChild(trackListPanel); - trackListPanel.left = this.videoPlayerNode.right + 12; + trackListPanel.left = this.videoPlayerNode.right + TRACK_LIST_LEFT_SPACING; trackListPanel.top = this.videoPlayerNode.top; // ── Data table (beneath the track list panel, same column) ─────────── @@ -71,7 +81,7 @@ export class SimScreenView extends ScreenView { this.addChild(dataTableNode); dataTableNode.left = trackListPanel.left; trackListPanel.boundsProperty.link(() => { - dataTableNode.top = trackListPanel.bottom + 8; + dataTableNode.top = trackListPanel.bottom + DATA_TABLE_TOP_SPACING; }); // ── Reset all ───────────────────────────────────────────────────────── @@ -79,8 +89,8 @@ export class SimScreenView extends ScreenView { listener: () => { model.reset(); // resets all model state including tool positions }, - right: this.layoutBounds.maxX - 10, - bottom: this.layoutBounds.maxY - 10, + right: this.layoutBounds.maxX - RESET_BUTTON_MARGIN, + bottom: this.layoutBounds.maxY - RESET_BUTTON_MARGIN, }); this.addChild(resetAllButton); } diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index f815e74..65a3479 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -36,6 +36,7 @@ import { } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; +import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; @@ -45,6 +46,20 @@ const ROW_HEIGHT = 40; // height of each track box const BADGE_R = 13; // radius of colour badge circle const BADGE_CX = 22; // x-centre of badge inside the row const CHECKBOX_X = BADGE_CX + BADGE_R + 10; // left edge of checkbox +const ROW_CORNER_RADIUS = 6; // corner radius of each track row background +const ROW_BG_ALPHA = 0.15; // track colour fill alpha for row background +const ROW_STROKE_ALPHA = 0.7; // track colour stroke alpha for row border +const ROW_STROKE_WIDTH = 1.5; +const CHECKBOX_BOX_WIDTH = 14; +const TRASH_BUTTON_X_MARGIN = 6; +const TRASH_BUTTON_Y_MARGIN = 6; +const TRASH_BUTTON_RIGHT_OFFSET = 3; // inset from PANEL_WIDTH right edge +const TRACK_LIST_SPACING = 6; // gap between track rows +const PANEL_CONTENT_SPACING = 8; // gap between header, add button, and track list +const PANEL_X_MARGIN = 10; +const PANEL_Y_MARGIN = 10; +const ADD_BUTTON_X_MARGIN = 10; +const ADD_BUTTON_Y_MARGIN = 6; const HEADER_FONT = new PhetFont({ size: 13, weight: "bold" }); const SYMBOL_FONT = new PhetFont({ size: 15, weight: "bold" }); @@ -98,12 +113,20 @@ class TrackRowNode extends Node { const ROW_CY = ROW_HEIGHT / 2; // ── Rounded background (purely visual, not pickable) ────────────────── - const bg = new Rectangle(0, 0, PANEL_WIDTH, ROW_HEIGHT, 6, 6, { - fill: trackColor.withAlpha(0.15), - stroke: trackColor.withAlpha(0.7), - lineWidth: 1.5, - pickable: false, - }); + const bg = new Rectangle( + 0, + 0, + PANEL_WIDTH, + ROW_HEIGHT, + ROW_CORNER_RADIUS, + ROW_CORNER_RADIUS, + { + fill: trackColor.withAlpha(ROW_BG_ALPHA), + stroke: trackColor.withAlpha(ROW_STROKE_ALPHA), + lineWidth: ROW_STROKE_WIDTH, + pickable: false, + }, + ); // ── Colour badge with symbol letter ─────────────────────────────────── const badge = new Circle(BADGE_R, { @@ -143,7 +166,7 @@ class TrackRowNode extends Node { isDigitizingProperty, new Rectangle(0, 0, 0, 0), { - boxWidth: 14, + boxWidth: CHECKBOX_BOX_WIDTH, tandem: Tandem.OPT_OUT, }, ); @@ -155,13 +178,13 @@ class TrackRowNode extends Node { content: makeTrashIcon(), baseColor: TrackLabColors.trashButtonBaseProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 6, - yMargin: 6, + xMargin: TRASH_BUTTON_X_MARGIN, + yMargin: TRASH_BUTTON_Y_MARGIN, listener: () => model.removeTrack(track.id), tandem: Tandem.OPT_OUT, }); trashButton.centerY = ROW_CY; - trashButton.right = PANEL_WIDTH - 3; + trashButton.right = PANEL_WIDTH - TRASH_BUTTON_RIGHT_OFFSET; this.addChild(bg); this.addChild(badge); @@ -199,8 +222,8 @@ export class TrackListPanel extends Panel { }), baseColor: TrackLabColors.buttonBaseDarkProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 10, - yMargin: 6, + xMargin: ADD_BUTTON_X_MARGIN, + yMargin: ADD_BUTTON_Y_MARGIN, enabledProperty: addButtonEnabledProperty, listener: () => model.addTrack(), tandem: Tandem.OPT_OUT, @@ -209,7 +232,7 @@ export class TrackListPanel extends Panel { // ── Track list (rebuilt whenever tracks change) ─────────────────────── const trackListVBox = new VBox({ children: [], - spacing: 6, + spacing: TRACK_LIST_SPACING, align: "left", }); @@ -221,16 +244,16 @@ export class TrackListPanel extends Panel { const content = new VBox({ children: [widthSpacer, headerLabel, addButton, trackListVBox], - spacing: 8, + spacing: PANEL_CONTENT_SPACING, align: "center", }); super(content, { fill: TrackLabColors.panelFillProperty, stroke: TrackLabColors.panelStrokeProperty, - cornerRadius: 8, - xMargin: 10, - yMargin: 10, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, visible: false, }); diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 48fe6c9..2ed94a6 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -2,6 +2,8 @@ import { DerivedProperty } from "scenerystack/axon"; import { DOM, Node, VBox } from "scenerystack/scenery"; import TrackLabColors from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH, type SimModel } from "../model/SimModel.js"; + +const MAIN_CONTENT_SPACING = 10; // VBox gap between source control, video layer, and playback import { AutoTrackerNode } from "./AutoTrackerNode.js"; import { DigitizingOverlayNode } from "./DigitizingOverlayNode.js"; import { PlaybackControlsNode } from "./PlaybackControlsNode.js"; @@ -117,7 +119,7 @@ export class VideoPlayerNode extends Node { // ── Layout ───────────────────────────────────────────────────────────── const mainContent = new VBox({ children: [videoSourceControlNode, videoLayer, playbackControlsNode], - spacing: 10, + spacing: MAIN_CONTENT_SPACING, align: "center", }); diff --git a/src/screen-name/view/VideoSourceControlNode.ts b/src/screen-name/view/VideoSourceControlNode.ts index cc7aacb..a18d001 100644 --- a/src/screen-name/view/VideoSourceControlNode.ts +++ b/src/screen-name/view/VideoSourceControlNode.ts @@ -4,22 +4,23 @@ import { CameraButton, PhetFont } from "scenerystack/scenery-phet"; import { ComboBox, type ComboBoxItem } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; -import type { SimModel } from "../model/SimModel.js"; +import { DEFAULT_FRAME_RATE, type SimModel } from "../model/SimModel.js"; import { WebcamPanel } from "./WebcamPanel.js"; const LABEL_FONT = new PhetFont(14); +const CONTROLS_SPACING = 12; // gap between video combo box and webcam button // Bundled video files with known frame rates const VIDEO_FILES = [ - { label: "Ball in Oil", filename: "ball_oil.mp4", fps: 30, tandemName: "ballOilItem" }, - { label: "Bouncing Cart", filename: "bouncing_cart.mp4", fps: 30, tandemName: "bouncingCartItem" }, - { label: "Cart Pendulum", filename: "cart_pendulum.mp4", fps: 30, tandemName: "cartPendulumItem" }, - { label: "Cups Clips", filename: "CupsClips.mp4", fps: 30, tandemName: "cupsClipsItem" }, - { label: "Parachute Monkey", filename: "parachute_monkey.mp4", fps: 30, tandemName: "parachuteMonkeyItem" }, - { label: "Pendulum", filename: "Pendulum.mp4", fps: 30, tandemName: "pendulumItem" }, - { label: "Pendulum Drag", filename: "pendulum_drag.mp4", fps: 30, tandemName: "pendulumDragItem" }, - { label: "Pucks Collide", filename: "PucksCollide.mp4", fps: 30, tandemName: "pucksCollideItem" }, - { label: "Spring Wars", filename: "spring_wars.mp4", fps: 30, tandemName: "springWarsItem" }, + { label: "Ball in Oil", filename: "ball_oil.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "ballOilItem" }, + { label: "Bouncing Cart", filename: "bouncing_cart.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "bouncingCartItem" }, + { label: "Cart Pendulum", filename: "cart_pendulum.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "cartPendulumItem" }, + { label: "Cups Clips", filename: "CupsClips.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "cupsClipsItem" }, + { label: "Parachute Monkey", filename: "parachute_monkey.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "parachuteMonkeyItem" }, + { label: "Pendulum", filename: "Pendulum.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "pendulumItem" }, + { label: "Pendulum Drag", filename: "pendulum_drag.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "pendulumDragItem" }, + { label: "Pucks Collide", filename: "PucksCollide.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "pucksCollideItem" }, + { label: "Spring Wars", filename: "spring_wars.mp4", fps: DEFAULT_FRAME_RATE, tandemName: "springWarsItem" }, ] as const; export type VideoSelectedCallback = (url: string, fps: number) => void; @@ -37,7 +38,7 @@ export class VideoSourceControlNode extends HBox { onVideoSelected: VideoSelectedCallback, onWebcamReady: WebcamReadyCallback, ) { - super({ spacing: 12 }); + super({ spacing: CONTROLS_SPACING }); const selectedVideoProperty = new Property(null); @@ -63,7 +64,7 @@ export class VideoSourceControlNode extends HBox { selectedVideoProperty.lazyLink((filename) => { if (filename) { const videoInfo = VIDEO_FILES.find((v) => v.filename === filename); - const fps = videoInfo?.fps ?? 30; + const fps = videoInfo?.fps ?? DEFAULT_FRAME_RATE; onVideoSelected(`./videos/${filename}`, fps); } }); diff --git a/src/screen-name/view/WebcamPanel.ts b/src/screen-name/view/WebcamPanel.ts index b985a33..00adc43 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -15,9 +15,26 @@ import { } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; +import { + WEBCAM_PREVIEW_HEIGHT, + WEBCAM_PREVIEW_WIDTH, +} from "../../TrackLabConstants.js"; import { fixWebmDuration, WebcamRecorder } from "../../webcam.js"; const FONT = new PhetFont(14); +const STOP_ICON_SIZE = 14; +const REFRESH_ICON_HEIGHT = 20; +const CHECK_ICON_SCALE = 0.35; +const CAMERA_ICON_SCALE = 0.4; // camera icon next to the select dropdown +const TITLE_CAMERA_ICON_SCALE = 0.6; // larger camera icon in the panel title +const RECORD_ICON_RADIUS = 8; // circle radius for the record button icon +const ACTION_BUTTON_X_MARGIN = 8; // xMargin for start/stop/use-video buttons +const ACTION_BUTTON_Y_MARGIN = 6; +const PANEL_CORNER_RADIUS = 10; +const PANEL_X_MARGIN = 20; +const PANEL_Y_MARGIN = 15; +const LAYER_SPACING = 10; // VBox spacing between elements within each layer +const CAMERA_ROW_SPACING = 8; // HBox spacing between camera icon and select type WebcamPanelOptions = { onVideoReady: (blob: Blob, duration: number) => void; @@ -57,8 +74,8 @@ export class WebcamPanel extends Node { // ── Preview video ───────────────────────────────────────────────────── this.previewElement = document.createElement("video"); - this.previewElement.width = 480; - this.previewElement.height = 270; + this.previewElement.width = WEBCAM_PREVIEW_WIDTH; + this.previewElement.height = WEBCAM_PREVIEW_HEIGHT; this.previewElement.muted = true; this.previewElement.playsInline = true; this.previewElement.style.display = "block"; @@ -69,8 +86,8 @@ export class WebcamPanel extends Node { // ── Review video ────────────────────────────────────────────────────── this.reviewElement = document.createElement("video"); - this.reviewElement.width = 480; - this.reviewElement.height = 270; + this.reviewElement.width = WEBCAM_PREVIEW_WIDTH; + this.reviewElement.height = WEBCAM_PREVIEW_HEIGHT; this.reviewElement.controls = true; this.reviewElement.playsInline = true; this.reviewElement.style.display = "block"; @@ -98,22 +115,21 @@ export class WebcamPanel extends Node { }, }); - const recordIcon = new Path(Shape.circle(0, 0, 8), { + const recordIcon = new Path(Shape.circle(0, 0, RECORD_ICON_RADIUS), { fill: TrackLabColors.textOnDarkProperty, }); const startButton = new RectangularPushButton({ content: recordIcon, baseColor: TrackLabColors.buttonRecordProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 8, - yMargin: 6, + xMargin: ACTION_BUTTON_X_MARGIN, + yMargin: ACTION_BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, accessibleName: "Start Recording", listener: () => this.startRecording(), }); - const stopIconSize = 14; - const stopIcon = new Path(new StopIconShape(stopIconSize), { + const stopIcon = new Path(new StopIconShape(STOP_ICON_SIZE), { fill: TrackLabColors.textOnDarkProperty, }); stopIcon.translation = stopIcon.bounds.center.negated(); @@ -121,8 +137,8 @@ export class WebcamPanel extends Node { content: stopIcon, baseColor: TrackLabColors.buttonStopProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 8, - yMargin: 6, + xMargin: ACTION_BUTTON_X_MARGIN, + yMargin: ACTION_BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, accessibleName: "Stop Recording", listener: () => this.stopRecording(), @@ -132,22 +148,22 @@ export class WebcamPanel extends Node { const rerecordButton = new RefreshButton({ baseColor: TrackLabColors.buttonBaseDarkerProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - iconHeight: 20, + iconHeight: REFRESH_ICON_HEIGHT, tandem: Tandem.OPT_OUT, accessibleName: "Re-record", listener: () => this.goToPreview(), }); const useVideoIcon = new Path(checkSolidShape, { - scale: 0.35, + scale: CHECK_ICON_SCALE, fill: TrackLabColors.textOnDarkProperty, }); const useVideoButton = new RectangularPushButton({ content: useVideoIcon, baseColor: TrackLabColors.buttonSuccessProperty, buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - xMargin: 8, - yMargin: 6, + xMargin: ACTION_BUTTON_X_MARGIN, + yMargin: ACTION_BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, accessibleName: "Use Video", listener: () => this.useVideo(options.onVideoReady), @@ -155,7 +171,7 @@ export class WebcamPanel extends Node { // ── Layer: preview ──────────────────────────────────────────────────── const cameraIcon = new Path(cameraSolidShape, { - scale: 0.4, + scale: CAMERA_ICON_SCALE, fill: TrackLabColors.textMutedProperty, accessibleName: "Camera", }); @@ -163,16 +179,16 @@ export class WebcamPanel extends Node { children: [ new HBox({ children: [cameraIcon, cameraSelectDOM], - spacing: 8, + spacing: CAMERA_ROW_SPACING, align: "center", }), previewDOM, new HBox({ children: [cancelButton, startButton, stopButton], - spacing: 10, + spacing: LAYER_SPACING, }), ], - spacing: 10, + spacing: LAYER_SPACING, align: "center", }); @@ -185,17 +201,17 @@ export class WebcamPanel extends Node { reviewDOM, new HBox({ children: [rerecordButton, useVideoButton], - spacing: 10, + spacing: LAYER_SPACING, }), ], - spacing: 10, + spacing: LAYER_SPACING, align: "center", }); this.reviewLayer.visible = false; // ── Full panel ──────────────────────────────────────────────────────── const titleIcon = new Path(cameraSolidShape, { - scale: 0.6, + scale: TITLE_CAMERA_ICON_SCALE, fill: TrackLabColors.textOnDarkProperty, accessibleName: "Record from Webcam", }); @@ -206,7 +222,7 @@ export class WebcamPanel extends Node { this.reviewLayer, this.statusText, ], - spacing: 10, + spacing: LAYER_SPACING, align: "center", }); @@ -214,9 +230,9 @@ export class WebcamPanel extends Node { new Panel(content, { fill: TrackLabColors.webcamPanelFillProperty, stroke: TrackLabColors.panelStrokeProperty, - cornerRadius: 10, - xMargin: 20, - yMargin: 15, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, }), );