diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 3dd6c2e..27e9027 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -1,5 +1,5 @@ -import { BooleanProperty, Property } from "scenerystack/axon"; -import { Matrix3, Transform3 } from "scenerystack/dot"; +import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { Matrix3, Range, Transform3, Vector2 } from "scenerystack/dot"; import type { Track, TrackPoint } from "./Track.js"; // ── Track colour palette (one per letter, repeats after 8) ───────────────── @@ -14,30 +14,99 @@ const TRACK_COLORS = [ '#76FF03', // H – light green ]; +// ── Calibration unit type ────────────────────────────────────────────────── +export const CALIBRATION_UNITS = [ 'mm', 'cm', 'm', 'km', 'in', 'ft' ] as const; +export type CalibrationUnit = typeof CALIBRATION_UNITS[ number ]; +export const CALIBRATION_DISTANCE_RANGE = new Range( 0.001, 100000 ); + +// ── Layout constants ─────────────────────────────────────────────────────── +// SceneryStack's ScreenView.DEFAULT_LAYOUT_BOUNDS = Bounds2(0, 0, 1024, 618). +// The VideoPlayerNode is centered at layoutBounds.center + (0, -20). +const LAYOUT_CENTER_X = 512; // 1024 / 2 +const LAYOUT_CENTER_Y = 309; // 618 / 2 +const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512 +const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289 +const VIDEO_WIDTH = 640; +const VIDEO_HEIGHT = 360; +const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint + +// Initial tool positions (view / pixel space) +const COORD_ORIGIN_INITIAL = new Vector2( VIDEO_CENTER_X - VIDEO_WIDTH / 4, VIDEO_CENTER_Y ); +const CALIB_CENTER_INITIAL = new Vector2( VIDEO_CENTER_X, VIDEO_CENTER_Y + VIDEO_HEIGHT / 4 ); +const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY( -CALIB_HALF_LEN, 0 ); +const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY( CALIB_HALF_LEN, 0 ); + +// ── Model-view transform builder ─────────────────────────────────────────── +/** + * Builds a Transform3 from the coordinate-system tool and calibration tool. + * + * Composed as: T(origin) · R(θ) · S(s, −s) + * + * S(s, −s) — scale model units to pixels, flip Y (model +y points up) + * R(θ) — rotate by the coord-system angle (clockwise on screen) + * T(origin) — translate so model origin lands on the coord-system view position + * + * where s = |p2 − p1| / calibrationDistance (pixels per model unit). + * Returns the identity transform when the calibration segment has zero length. + */ +function buildModelViewTransform( + origin: Vector2, + angle: number, + p1: Vector2, + p2: Vector2, + dist: number +): Transform3 { + const pixelDist = p1.distance( p2 ); + if ( pixelDist < 1e-6 || dist < 1e-9 ) { + return new Transform3( Matrix3.IDENTITY ); + } + const s = pixelDist / dist; // pixels per model unit + + const matrix = Matrix3.translationFromVector( origin ) + .timesMatrix( Matrix3.rotation2( angle ) ) + .timesMatrix( Matrix3.scaling( s, -s ) ); + + return new Transform3( matrix ); +} + export class SimModel { - public readonly isPlayingProperty = new BooleanProperty( false ); - public readonly currentTimeProperty = new Property( 0 ); - public readonly durationProperty = new Property( 0 ); - public readonly videoUrlProperty = new Property( null ); + public readonly isPlayingProperty = new BooleanProperty( false ); + public readonly currentTimeProperty = new Property( 0 ); + public readonly durationProperty = new Property( 0 ); + public readonly videoUrlProperty = new Property( null ); // ── Overlay visibility ──────────────────────────────────────────────── - public readonly axesVisibleProperty = new BooleanProperty( true ); + public readonly axesVisibleProperty = new BooleanProperty( true ); public readonly calibrationVisibleProperty = new BooleanProperty( true ); // ── Future features (not yet implemented) ──────────────────────────── public readonly magnifyVideoProperty = new BooleanProperty( false ); public readonly autoTrackingProperty = new BooleanProperty( false ); - // Maps between real-world model coordinates and view (pixel) coordinates. - // Updated by SimScreenView whenever the coordinate system or calibration tool changes. - public readonly modelViewTransformProperty = new Property( - new Transform3( Matrix3.IDENTITY ) + // ── Coordinate system tool state (view / pixel space) ───────────────── + public readonly coordOriginProperty = new Property( COORD_ORIGIN_INITIAL.copy() ); + public readonly coordAngleProperty = new NumberProperty( 0 ); + + // ── Calibration tool state ──────────────────────────────────────────── + public readonly calibPoint1Property = new Property( CALIB_P1_INITIAL.copy() ); + public readonly calibPoint2Property = new Property( CALIB_P2_INITIAL.copy() ); + public readonly calibDistanceProperty = new NumberProperty( 1, { range: CALIBRATION_DISTANCE_RANGE } ); + public readonly calibUnitProperty = new Property( 'm' ); + + // ── Model-view transform (derived; the view never writes to this) ───── + public readonly modelViewTransformProperty: TReadOnlyProperty = new DerivedProperty( + [ + this.coordOriginProperty, + this.coordAngleProperty, + this.calibPoint1Property, + this.calibPoint2Property, + this.calibDistanceProperty, + ], + ( origin, angle, p1, p2, dist ) => buildModelViewTransform( origin, angle, p1, p2, dist ) ); // ── Manual particle tracks ──────────────────────────────────────────── - public readonly tracksProperty = new Property( [] ); - - // The track currently selected for manual digitizing (null = none). + public readonly tracksProperty = new Property( [] ); public readonly activeTrackIdProperty = new Property( null ); private nextSymbolCode = 65; // ASCII code for 'A' @@ -91,6 +160,12 @@ export class SimModel { this.calibrationVisibleProperty.reset(); this.magnifyVideoProperty.reset(); this.autoTrackingProperty.reset(); + this.coordOriginProperty.reset(); + this.coordAngleProperty.reset(); + this.calibPoint1Property.reset(); + this.calibPoint2Property.reset(); + this.calibDistanceProperty.reset(); + this.calibUnitProperty.reset(); this.tracksProperty.value = []; this.activeTrackIdProperty.value = null; this.nextSymbolCode = 65; diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index c1c93b5..e2d2fac 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -6,11 +6,13 @@ import { Vector2 } from "scenerystack/dot"; import type { TReadOnlyProperty } from "scenerystack/axon"; import { OpenCVTracker } from "../../tracking/OpenCVTracker.js"; import TrackLabColors from "../../TrackLabColors.js"; +import type { SimModel } from "../model/SimModel.js"; const VIDEO_W = 640; const VIDEO_H = 360; const MAX_TRAIL = 150; const CROSSHAIR_SIZE = 16; +const FRAME_DURATION = 1 / 30; // assumes 30 fps /** * Transparent SceneryStack overlay that sits directly on top of the video element. @@ -22,6 +24,8 @@ const CROSSHAIR_SIZE = 16; * 4. On each video frame (timeupdate / seeked), the best-match position is * computed via OpenCV template matching and shown as a red crosshair. * 5. Past positions are shown as a green trail of dots. + * 6. If a track is active (model.activeTrackIdProperty), each new unique frame + * position is recorded to the model via addPointToTrack. * * Local coordinates of this node correspond directly to video-pixel coordinates * (0,0 = top-left of video) because it is added to the same layer as the video @@ -43,7 +47,8 @@ export class AutoTrackerNode extends Node { public constructor( videoElement: HTMLVideoElement, - autoTrackingShownProperty: TReadOnlyProperty + autoTrackingShownProperty: TReadOnlyProperty, + model: SimModel ) { super( { visible: false } ); @@ -170,7 +175,25 @@ export class AutoTrackerNode extends Node { this.trail.push( pt ); if ( this.trail.length > MAX_TRAIL ) this.trail.shift(); -this.updateTrackerVisuals( pt ); + this.updateTrackerVisuals( pt ); + + // ── Record position to model if a track is active ───────────────── + const activeId = model.activeTrackIdProperty.value; + if ( activeId ) { + const time = videoElement.currentTime; + const frame = Math.round( time / FRAME_DURATION ); + + // Avoid duplicate points for the same frame. + const activeTrack = model.tracksProperty.value.find( t => t.id === activeId ); + const alreadyRecorded = activeTrack ? activeTrack.points.some( p => p.frame === frame ) : false; + + if ( !alreadyRecorded ) { + // Convert video-pixel coords to global coords, then to model coords. + const globalPt = this.localToGlobalPoint( new Vector2( pt.x, pt.y ) ); + const modelPt = model.modelViewTransformProperty.value.inversePosition2( globalPt ); + model.addPointToTrack( activeId, frame, time, modelPt.x, modelPt.y ); + } + } }; videoElement.addEventListener( 'timeupdate', onFrame ); videoElement.addEventListener( 'seeked', onFrame ); diff --git a/src/screen-name/view/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index 73dd5e0..f931230 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -1,39 +1,25 @@ import { Circle, HBox, Line, Node, RichDragListener, Text } from "scenerystack/scenery"; import { Keypad, PhetFont } from "scenerystack/scenery-phet"; import { KeypadDialog } from "scenerystack/sim"; -import { Range, type Vector2 } from "scenerystack/dot"; -import { DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { DerivedProperty } from "scenerystack/axon"; +import type { TReadOnlyProperty } from "scenerystack/axon"; import { ComboBox, type ComboBoxItem, Panel, TextPushButton } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; +import type { SimModel } from "../model/SimModel.js"; +import { CALIBRATION_UNITS } from "../model/SimModel.js"; import TrackLabColors from "../../TrackLabColors.js"; const FONT = new PhetFont( 14 ); const ENDPOINT_RADIUS = 8; -const INITIAL_HALF_LENGTH = 100; // pixels from center to each endpoint - -const UNITS = [ 'mm', 'cm', 'm', 'km', 'in', 'ft' ] as const; -type Unit = typeof UNITS[ number ]; - -const DISTANCE_RANGE = new Range( 0.001, 100000 ); export class CalibrationToolNode extends Node { - public readonly distanceProperty: NumberProperty; - public readonly unitProperty: Property; - public readonly point1Property: Property; - public readonly point2Property: Property; - public constructor( videoLoadedProperty: TReadOnlyProperty, listParent: Node, - initialCenter: Vector2 + model: SimModel ) { super(); - this.point1Property = new Property( initialCenter.plusXY( -INITIAL_HALF_LENGTH, 0 ) ); - this.point2Property = new Property( initialCenter.plusXY( INITIAL_HALF_LENGTH, 0 ) ); - this.distanceProperty = new NumberProperty( 1, { range: DISTANCE_RANGE } ); - this.unitProperty = new Property( 'm' ); - // ── Connecting line ──────────────────────────────────────────────────── const calibrationLine = new Line( 0, 0, 0, 0, { stroke: TrackLabColors.calibrationStrokeProperty, @@ -69,15 +55,15 @@ export class CalibrationToolNode extends Node { } ); // Pattern shown inside the dialog as "Range: {{min}} – {{max}} " const rangePatternProperty = new DerivedProperty( - [ this.unitProperty ], - ( unit: Unit ) => `{{min}} – {{max}} ${unit}` + [ model.calibUnitProperty ], + unit => `{{min}} – {{max}} ${ unit }` ); // ── Midpoint panel ──────────────────────────────────────────────────── // Button showing current value + unit; clicking it opens the keypad. const buttonLabelProperty = new DerivedProperty( - [ this.distanceProperty, this.unitProperty ], - ( dist: number, unit: Unit ) => `${dist.toFixed( 2 )} ${unit}` + [ model.calibDistanceProperty, model.calibUnitProperty ], + ( dist, unit ) => `${ dist.toFixed( 2 ) } ${ unit }` ); const distanceButton = new TextPushButton( buttonLabelProperty, { @@ -86,8 +72,8 @@ export class CalibrationToolNode extends Node { textFill: TrackLabColors.textOnDarkProperty, listener: () => { keypadDialog.beginEdit( - ( value: number ) => { this.distanceProperty.value = value; }, - DISTANCE_RANGE, + ( value: number ) => { model.calibDistanceProperty.value = value; }, + model.calibDistanceProperty.range, rangePatternProperty, () => {} ); @@ -96,12 +82,12 @@ export class CalibrationToolNode extends Node { } ); // Unit selector - const unitItems: ComboBoxItem[] = UNITS.map( unit => ( { + const unitItems: ComboBoxItem[] = CALIBRATION_UNITS.map( unit => ( { value: unit, createNode: () => new Text( unit, { font: FONT } ), tandemName: `${ unit }Item`, } ) ); - const unitComboBox = new ComboBox( this.unitProperty, unitItems, listParent, { + const unitComboBox = new ComboBox( model.calibUnitProperty, unitItems, listParent, { tandem: Tandem.OPT_OUT, } ); @@ -124,8 +110,8 @@ export class CalibrationToolNode extends Node { // ── Update geometry when endpoints move ─────────────────────────────── const updateGeometry = () => { - const p1 = this.point1Property.value; - const p2 = this.point2Property.value; + const p1 = model.calibPoint1Property.value; + const p2 = model.calibPoint2Property.value; calibrationLine.setLine( p1.x, p1.y, p2.x, p2.y ); endpoint1.translation = p1; endpoint2.translation = p2; @@ -133,31 +119,22 @@ export class CalibrationToolNode extends Node { midpointPanel.centerX = mid.x; midpointPanel.bottom = mid.y - 12; }; - this.point1Property.link( updateGeometry ); - this.point2Property.link( updateGeometry ); + model.calibPoint1Property.link( updateGeometry ); + model.calibPoint2Property.link( updateGeometry ); // ── Drag listeners for endpoints ────────────────────────────────────── - const makeDragListener = ( pointProperty: Property ) => { - return new RichDragListener( { - positionProperty: pointProperty, - keyboardDragListenerOptions: { - dragSpeed: 200, - shiftDragSpeed: 40, - }, - tandem: Tandem.OPT_OUT, - } ); - }; - endpoint1.addInputListener( makeDragListener( this.point1Property ) ); - endpoint2.addInputListener( makeDragListener( this.point2Property ) ); + endpoint1.addInputListener( new RichDragListener( { + positionProperty: model.calibPoint1Property, + keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 }, + tandem: Tandem.OPT_OUT, + } ) ); + endpoint2.addInputListener( new RichDragListener( { + positionProperty: model.calibPoint2Property, + keyboardDragListenerOptions: { dragSpeed: 200, shiftDragSpeed: 40 }, + tandem: Tandem.OPT_OUT, + } ) ); // ── Visibility ───────────────────────────────────────────────────────── videoLoadedProperty.link( loaded => { this.visible = loaded; } ); } - - public reset(): void { - this.point1Property.reset(); - this.point2Property.reset(); - this.distanceProperty.reset(); - this.unitProperty.reset(); - } } diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index 09a8363..828565c 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -1,8 +1,8 @@ import { Circle, Node, RichDragListener, Text } from "scenerystack/scenery"; import { ArrowNode, PhetFont } from "scenerystack/scenery-phet"; -import type { Vector2 } from "scenerystack/dot"; -import { NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; import { Tandem } from "scenerystack/tandem"; +import type { TReadOnlyProperty } from "scenerystack/axon"; +import type { SimModel } from "../model/SimModel.js"; import TrackLabColors from "../../TrackLabColors.js"; const ARROW_LENGTH = 120; @@ -10,15 +10,9 @@ const HANDLE_FRACTION = 1 / 3; const FONT = new PhetFont( { size: 14, weight: 'bold' } ); export class CoordinateSystemNode extends Node { - public readonly viewPositionProperty: Property; - public readonly rotationAngleProperty: NumberProperty; - - public constructor( videoLoadedProperty: TReadOnlyProperty, initialPosition: Vector2 ) { + public constructor( videoLoadedProperty: TReadOnlyProperty, model: SimModel ) { super(); - this.viewPositionProperty = new Property( initialPosition.copy() ); - this.rotationAngleProperty = new NumberProperty( 0 ); - // ── Rotating node: axes + rotation handle ───────────────────────────── const rotatingNode = new Node(); @@ -75,7 +69,7 @@ export class CoordinateSystemNode extends Node { lineWidth: 1, } ); - // ── Position wrapper: translates with viewPositionProperty ─────────── + // ── Position wrapper: translates with model.coordOriginProperty ─────── const positionNode = new Node( { children: [ rotatingNode, originMarker ], cursor: 'move', @@ -86,12 +80,12 @@ export class CoordinateSystemNode extends Node { this.addChild( positionNode ); // ── Property → scene-graph linkage ──────────────────────────────────── - this.viewPositionProperty.link( pos => { positionNode.translation = pos; } ); - this.rotationAngleProperty.link( angle => { rotatingNode.rotation = angle; } ); + model.coordOriginProperty.link( pos => { positionNode.translation = pos; } ); + model.coordAngleProperty.link( angle => { rotatingNode.rotation = angle; } ); // ── Drag: translate the entire coordinate system ────────────────────── positionNode.addInputListener( new RichDragListener( { - positionProperty: this.viewPositionProperty, + positionProperty: model.coordOriginProperty, keyboardDragListenerOptions: { dragSpeed: 300, shiftDragSpeed: 50, @@ -106,7 +100,7 @@ export class CoordinateSystemNode extends Node { dragListenerOptions: { drag: ( event ) => { const p = positionNode.globalToLocalPoint( event.pointer.point ); - this.rotationAngleProperty.value = Math.atan2( p.y, p.x ); + model.coordAngleProperty.value = Math.atan2( p.y, p.x ); }, }, keyboardDragListenerOptions: { @@ -114,7 +108,7 @@ export class CoordinateSystemNode extends Node { dragSpeed: 100, shiftDragSpeed: 20, drag: ( _event, listener ) => { - this.rotationAngleProperty.value += listener.modelDelta.x * ( Math.PI / 180 ); + model.coordAngleProperty.value += listener.modelDelta.x * ( Math.PI / 180 ); }, }, tandem: Tandem.OPT_OUT, @@ -123,9 +117,4 @@ export class CoordinateSystemNode extends Node { // ── Visibility: only shown once a video with a finite duration is loaded videoLoadedProperty.link( loaded => { this.visible = loaded; } ); } - - public reset(): void { - this.viewPositionProperty.reset(); - this.rotationAngleProperty.reset(); - } } diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index 01a712b..6a856d0 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -1,83 +1,97 @@ /** * DataTableNode.ts * - * Scrollable (vertical + horizontal) data table rendered as an HTML - * inside a fixed-size
, wrapped in a Scenery DOM node. + * Panel displayed below the TrackListPanel showing the digitized position + * of each track at (or just before) the current video frame. * - * Row 0 — header: Frame | Time (s) | X_A (cm) | Y_A (cm) | X_B (cm) | … - * Row 1+ — one row per unique frame number, sorted ascending. - * - * A download button in the title row exports the current data as a CSV file - * named export1.csv, export2.csv, … on successive clicks. + * Columns: colour badge with symbol | x position | y position + * Values are shown in the unit selected in the CalibrationToolNode. */ import type { TReadOnlyProperty } from "scenerystack/axon"; -import { DOM, HBox, Line, Node, Path, Text, VBox } from "scenerystack/scenery"; -import { Shape } from "scenerystack/kite"; +import { Circle, HBox, Node, Rectangle, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; -import { Panel, RectangularPushButton } from "scenerystack/sun"; -import { Tandem } from "scenerystack/tandem"; +import { Panel } from "scenerystack/sun"; import { Color } from "scenerystack"; import type { SimModel } from "../model/SimModel.js"; +import type { Track, TrackPoint } from "../model/Track.js"; import TrackLabColors from "../../TrackLabColors.js"; -const TITLE_FONT = new PhetFont( { size: 13, weight: 'bold' } ); - -// ── Palette ─────────────────────────────────────────────────────────────────── -const C_HEADER_BG = '#1c4587'; -const C_ROW_ODD = '#1a2840'; -const C_ROW_EVEN = '#243550'; -const C_GRID = 'rgba(100,130,180,0.45)'; -const C_TEXT = '#ffffff'; -const C_TEXT_DIM = 'rgba(255,255,255,0.38)'; -const C_SCROLL_BG = '#0e1b2b'; -const C_SCROLL_THUMB = '#445a78'; - -// ── Column widths (px) ──────────────────────────────────────────────────────── -const W_FRAME = 50; -const W_TIME = 68; -const W_XY = 76; - -// ── Shared cell styles ──────────────────────────────────────────────────────── -const BASE_CELL = `padding:3px 6px;border:0.5px solid ${C_GRID};text-align:center;white-space:nowrap;`; -const HDR_CELL = `${BASE_CELL}background:${C_HEADER_BG};font-weight:bold;`; - -// ── Inject WebKit scrollbar CSS once ───────────────────────────────────────── -if ( !document.getElementById( 'tracklab-scroll-styles' ) ) { - const s = document.createElement( 'style' ); - s.id = 'tracklab-scroll-styles'; - s.textContent = ` - .tl-scroll::-webkit-scrollbar { width:6px; height:6px } - .tl-scroll::-webkit-scrollbar-track { background:${C_SCROLL_BG} } - .tl-scroll::-webkit-scrollbar-thumb { background:${C_SCROLL_THUMB}; border-radius:3px } - .tl-scroll::-webkit-scrollbar-corner { background:${C_SCROLL_BG} } - `; - document.head.appendChild( s ); -} - -// ── Download icon ───────────────────────────────────────────────────────────── -function makeDownloadIcon(): Node { - const stroke = 'white'; - const lw = 1.5; - const r = 7; // half-height of icon - - // Stem: vertical line from top down to just above arrowhead tip - const stem = new Line( 0, -r, 0, r * 0.1, { stroke, lineWidth: lw } ); - - // Arrowhead: chevron pointing down - const arrowShape = new Shape() - .moveTo( -r * 0.55, -r * 0.3 ) - .lineTo( 0, r * 0.5 ) - .lineTo( r * 0.55, -r * 0.3 ); - const arrow = new Path( arrowShape, { stroke, lineWidth: lw, fill: null } ); +const FRAME_DURATION = 1 / 30; // assumes 30 fps +const PANEL_WIDTH = 165; +const BADGE_R = 8; - // Tray: horizontal line at bottom - const tray = new Line( -r * 0.7, r, r * 0.7, r, { stroke, lineWidth: lw } ); +const HEADER_FONT = new PhetFont( { size: 13, weight: "bold" } ); +const LABEL_FONT = new PhetFont( 11 ); +const VALUE_FONT = new PhetFont( { size: 11, weight: "bold" } ); - return new Node( { children: [ stem, arrow, tray ] } ); +/** + * Returns the last TrackPoint at or before `frame`, or null if none exists. + */ +function getPointAtFrame( track: Track, frame: number ): TrackPoint | null { + const candidates = track.points.filter( p => p.frame <= frame ); + if ( candidates.length === 0 ) return null; + return candidates.reduce( ( best, p ) => p.frame > best.frame ? p : best ); } -// ── DataTableNode ───────────────────────────────────────────────────────────── +/** Builds one compact row for a track. */ +function buildTrackRow( track: Track, frame: number, unit: string ): Node { + const trackColor = new Color( track.color ); + + const badge = new Circle( BADGE_R, { + fill: track.color, + } ); + const symbolLabel = new Text( track.symbol, { + font: new PhetFont( { size: 10, weight: "bold" } ), + fill: "white", + } ); + symbolLabel.center = badge.center; + + const badgeNode = new Node( { children: [ badge, symbolLabel ] } ); + + const point = getPointAtFrame( track, frame ); + const xStr = point ? point.x.toFixed( 3 ) : "—"; + const yStr = point ? point.y.toFixed( 3 ) : "—"; + + const xLabel = new Text( `x: ${ xStr } ${ unit }`, { + font: LABEL_FONT, + fill: TrackLabColors.textOnDarkProperty, + maxWidth: PANEL_WIDTH - BADGE_R * 2 - 20, + } ); + const yLabel = new Text( `y: ${ yStr } ${ unit }`, { + font: LABEL_FONT, + fill: TrackLabColors.textOnDarkProperty, + maxWidth: PANEL_WIDTH - BADGE_R * 2 - 20, + } ); + + const valuesBox = new VBox( { + children: [ xLabel, yLabel ], + spacing: 2, + align: "left", + } ); + + const bg = new Rectangle( 0, 0, PANEL_WIDTH, 0, 4, 4, { + fill: trackColor.withAlpha( 0.12 ), + stroke: trackColor.withAlpha( 0.5 ), + lineWidth: 1, + pickable: false, + } ); + + const row = new HBox( { + children: [ badgeNode, valuesBox ], + spacing: 8, + align: "center", + } ); + + // Wrap in a node to add the background + const container = new Node( { children: [ bg, row ] } ); + row.left = 6; + row.centerY = 0; + bg.rectHeight = row.height + 8; + bg.top = row.top - 4; + + return container; +} export class DataTableNode extends Panel { public constructor( @@ -85,165 +99,65 @@ export class DataTableNode extends Panel { videoLoadedProperty: TReadOnlyProperty, unitProperty: TReadOnlyProperty ) { - - // ── Scrollable DOM container ────────────────────────────────────────── - const container = document.createElement( 'div' ); - container.className = 'tl-scroll'; - Object.assign( container.style, { - width: '285px', - height: '280px', - overflow: 'auto', - background: C_SCROLL_BG, - borderRadius: '2px', - scrollbarWidth: 'thin', - scrollbarColor: `${C_SCROLL_THUMB} ${C_SCROLL_BG}`, + const widthSpacer = new Rectangle( 0, 0, PANEL_WIDTH, 1, { + fill: null, + stroke: null, + pickable: false, } ); - const table = document.createElement( 'table' ); - Object.assign( table.style, { - borderCollapse: 'collapse', - fontFamily: '"Courier New", Courier, monospace', - fontSize: '11px', - color: C_TEXT, - width: 'max-content', - } ); - container.appendChild( table ); - - // ── CSV generator (shared by rebuild & download) ────────────────────── - const buildCSV = (): string => { - const tracks = [ ...model.tracksProperty.value ]; - - const unit = unitProperty.value; - const headers = [ 'Frame', 'Time (s)', - ...tracks.flatMap( t => [ `X_${ t.symbol } (${ unit })`, `Y_${ t.symbol } (${ unit })` ] ), - ]; - - const allFrames = new Set(); - for ( const track of tracks ) { - for ( const pt of track.points ) allFrames.add( pt.frame ); - } - const sortedFrames = [ ...allFrames ].sort( ( a, b ) => a - b ); - - const lines: string[] = [ headers.join( ',' ) ]; - sortedFrames.forEach( frame => { - let time = 0; - for ( const track of tracks ) { - const pt = track.points.find( p => p.frame === frame ); - if ( pt ) { time = pt.time; break; } - } - const row = [ String( frame ), time.toFixed( 3 ), - ...tracks.flatMap( track => { - const pt = track.points.find( p => p.frame === frame ); - return pt ? [ pt.x.toFixed( 3 ), pt.y.toFixed( 3 ) ] : [ '', '' ]; - } ), - ]; - lines.push( row.join( ',' ) ); - } ); - return lines.join( '\r\n' ); - }; - - // ── Rebuild HTML table ──────────────────────────────────────────────── - const rebuild = () => { - const tracks = [ ...model.tracksProperty.value ]; - - let html = '
'; - const unit = unitProperty.value; - html += ``; - html += ``; - tracks.forEach( track => { - html += ``; - html += ``; - } ); - html += ''; - - const allFrames = new Set(); - for ( const track of tracks ) { - for ( const pt of track.points ) allFrames.add( pt.frame ); - } - const sortedFrames = [ ...allFrames ].sort( ( a, b ) => a - b ); - - html += ''; - sortedFrames.forEach( ( frame, idx ) => { - const rowBg = idx % 2 === 0 ? C_ROW_ODD : C_ROW_EVEN; - - let time = 0; - for ( const track of tracks ) { - const pt = track.points.find( p => p.frame === frame ); - if ( pt ) { time = pt.time; break; } - } - - html += ``; - html += ``; - html += ``; - tracks.forEach( track => { - const pt = track.points.find( p => p.frame === frame ); - const xTxt = pt ? pt.x.toFixed( 3 ) : '—'; - const yTxt = pt ? pt.y.toFixed( 3 ) : '—'; - const dim = pt ? '' : `color:${C_TEXT_DIM};`; - html += ``; - html += ``; - } ); - html += ''; - } ); - html += ''; - - table.innerHTML = html; - }; - - model.tracksProperty.link( () => rebuild() ); - unitProperty.link( () => rebuild() ); - - // ── Download button ─────────────────────────────────────────────────── - let exportCounter = 1; - - const downloadButton = new RectangularPushButton( { - content: makeDownloadIcon(), - baseColor: new Color( 30, 50, 80, 0.8 ), - xMargin: 5, - yMargin: 5, - tandem: Tandem.OPT_OUT, - listener: () => { - const csv = buildCSV(); - const blob = new Blob( [ csv ], { type: 'text/csv;charset=utf-8;' } ); - const url = URL.createObjectURL( blob ); - const a = document.createElement( 'a' ); - a.href = url; - a.download = `export${ exportCounter }.csv`; - a.click(); - URL.revokeObjectURL( url ); - exportCounter++; - }, + const headerLabel = new Text( "Position Data", { + font: HEADER_FONT, + fill: TrackLabColors.textOnDarkProperty, } ); - // ── Scenery wrapper ─────────────────────────────────────────────────── - const domNode = new DOM( container, { allowInput: true } ); - - const titleLabel = new Text( 'Data', { - font: TITLE_FONT, + const noDataLabel = new Text( "No digitized points", { + font: LABEL_FONT, fill: TrackLabColors.textOnDarkProperty, } ); - const titleRow = new HBox( { - children: [ titleLabel, downloadButton ], - spacing: 8, - align: 'center', + const rowsVBox = new VBox( { + children: [ noDataLabel ], + spacing: 6, + align: "left", } ); const content = new VBox( { - children: [ titleRow, domNode ], - spacing: 6, - align: 'left', + children: [ widthSpacer, headerLabel, rowsVBox ], + spacing: 8, + align: "center", } ); super( content, { - fill: TrackLabColors.panelFillProperty, - stroke: TrackLabColors.panelStrokeProperty, + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, cornerRadius: 8, - xMargin: 10, - yMargin: 10, - visible: false, + xMargin: 10, + yMargin: 10, + visible: false, } ); - videoLoadedProperty.link( loaded => { this.visible = loaded; } ); + const rebuildRows = () => { + const tracks = model.tracksProperty.value; + const currentFrame = Math.round( model.currentTimeProperty.value / FRAME_DURATION ); + const unit = unitProperty.value; + + // Find tracks that have at least one point at or before the current frame. + const tracksWithData = tracks.filter( t => t.points.some( p => p.frame <= currentFrame ) ); + + if ( tracksWithData.length === 0 ) { + rowsVBox.children = [ noDataLabel ]; + } + else { + rowsVBox.children = tracksWithData.map( t => buildTrackRow( t, currentFrame, unit ) ); + } + }; + + model.tracksProperty.link( rebuildRows ); + model.currentTimeProperty.link( rebuildRows ); + unitProperty.link( rebuildRows ); + + videoLoadedProperty.link( loaded => { + this.visible = loaded; + } ); } } diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 584d4d9..a0d8cfb 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -1,5 +1,4 @@ -import { DerivedProperty, Multilink } from "scenerystack/axon"; -import { Matrix3, Transform3, Vector2 } from "scenerystack/dot"; +import { DerivedProperty } from "scenerystack/axon"; import { ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; import type { SimModel } from "../model/SimModel.js"; @@ -10,58 +9,15 @@ import { DataTableNode } from "./DataTableNode.js"; import { TrackListPanel } from "./TrackListPanel.js"; import { VideoPlayerNode } from "./VideoPlayerNode.js"; -/** - * Builds a Transform3 from the coordinate-system tool and calibration tool. - * - * Composed as: T(origin) · R(θ) · S(s, −s) - * - * S(s, −s) — scale model units to pixels, flip Y (model +y points up) - * R(θ) — rotate by the coord-system angle (clockwise on screen) - * T(origin) — translate so model origin lands on the coord-system view position - * - * where s = |p2 − p1| / calibrationDistance (pixels per model unit). - * Returns the identity transform when the calibration segment has zero length. - */ -function buildModelViewTransform( - origin: Vector2, - angle: number, - p1: Vector2, - p2: Vector2, - dist: number -): Transform3 { - const pixelDist = p1.distance( p2 ); - if ( pixelDist < 1e-6 || dist < 1e-9 ) { - return new Transform3( Matrix3.IDENTITY ); - } - const s = pixelDist / dist; // pixels per model unit - - const matrix = Matrix3.translationFromVector( origin ) - .timesMatrix( Matrix3.rotation2( angle ) ) - .timesMatrix( Matrix3.scaling( s, -s ) ); - - return new Transform3( matrix ); -} - export class SimScreenView extends ScreenView { private readonly videoPlayerNode: VideoPlayerNode; - private readonly coordinateSystemNode: CoordinateSystemNode; - private readonly calibrationToolNode: CalibrationToolNode; public constructor( model: SimModel, options?: ScreenViewOptions ) { super( options ); - this.videoPlayerNode = new VideoPlayerNode( model, this ); - this.videoPlayerNode.center = this.layoutBounds.center.plusXY( 0, -20 ); - this.addChild( this.videoPlayerNode ); - // True once a video with a finite duration has been loaded. const videoLoadedProperty = new DerivedProperty( [ model.durationProperty ], d => d > 0 ); - // The video element is 640×360. videoCenter approximates the video element center. - const VIDEO_WIDTH = 640; - const VIDEO_HEIGHT = 360; - const videoCenter = this.layoutBounds.center.plusXY( 0, -20 ); - // Combined visibility: video loaded AND user-toggled model flag. const axesShownProperty = new DerivedProperty( [ videoLoadedProperty, model.axesVisibleProperty ], @@ -72,74 +28,56 @@ export class SimScreenView extends ScreenView { ( loaded, visible ) => loaded && visible ); - // Coord system origin: center of the left half of the video (¼ from left edge, mid-height). - this.coordinateSystemNode = new CoordinateSystemNode( - axesShownProperty, - videoCenter.plusXY( -VIDEO_WIDTH / 4, 0 ) - ); - this.addChild( this.coordinateSystemNode ); + // ── Coordinate system overlay ───────────────────────────────────────── + // Reads/writes model.coordOriginProperty and model.coordAngleProperty. + const coordinateSystemNode = new CoordinateSystemNode( axesShownProperty, model ); + this.addChild( coordinateSystemNode ); - // Calibration line: horizontally centered, ¼ above the video bottom (¾ from top). - this.calibrationToolNode = new CalibrationToolNode( - calibrationShownProperty, - this, - videoCenter.plusXY( 0, VIDEO_HEIGHT / 4 ) - ); - this.addChild( this.calibrationToolNode ); + // ── Calibration tool overlay ────────────────────────────────────────── + // Reads/writes model.calibPoint1/2Property, model.calibDistanceProperty, + // and model.calibUnitProperty. + const calibrationToolNode = new CalibrationToolNode( calibrationShownProperty, this, model ); + this.addChild( calibrationToolNode ); - // ── ModelViewTransform: recomputed whenever either tool changes ──────── - Multilink.multilink( - [ - this.coordinateSystemNode.viewPositionProperty, - this.coordinateSystemNode.rotationAngleProperty, - this.calibrationToolNode.point1Property, - this.calibrationToolNode.point2Property, - this.calibrationToolNode.distanceProperty, - ], - ( origin, angle, p1, p2, dist ) => { - model.modelViewTransformProperty.value = buildModelViewTransform( - origin, angle, p1, p2, dist - ); - } - ); + // ── Video player ────────────────────────────────────────────────────── + // 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.addChild( this.videoPlayerNode ); // ── Control panel (left side) ───────────────────────────────────────── const controlPanel = new ControlPanel( model ); - controlPanel.left = this.layoutBounds.left + 10; + controlPanel.left = this.layoutBounds.left + 10; controlPanel.centerY = this.layoutBounds.centerY; this.addChild( controlPanel ); // ── Track list panel (right of the video) ──────────────────────────── const trackListPanel = new TrackListPanel( model, videoLoadedProperty ); this.addChild( trackListPanel ); - // Position: just right of the video player, top-aligned with it. trackListPanel.left = this.videoPlayerNode.right + 12; trackListPanel.top = this.videoPlayerNode.top; // ── Data table (beneath the track list panel, same column) ─────────── - const dataTableNode = new DataTableNode( model, videoLoadedProperty, this.calibrationToolNode.unitProperty ); + const dataTableNode = new DataTableNode( model, videoLoadedProperty, model.calibUnitProperty ); this.addChild( dataTableNode ); dataTableNode.left = trackListPanel.left; trackListPanel.boundsProperty.link( () => { dataTableNode.top = trackListPanel.bottom + 8; } ); + // ── Reset all ───────────────────────────────────────────────────────── const resetAllButton = new ResetAllButton( { listener: () => { - model.reset(); + model.reset(); // resets all model state including tool positions this.reset(); }, - right: this.layoutBounds.maxX - 10, + right: this.layoutBounds.maxX - 10, bottom: this.layoutBounds.maxY - 10, } ); this.addChild( resetAllButton ); } - public reset(): void { - this.coordinateSystemNode.reset(); - this.calibrationToolNode.reset(); - } - public override step( dt: number ): void { super.step( dt ); this.videoPlayerNode.step(); diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 346d5a7..5651dc5 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -76,7 +76,7 @@ export class VideoPlayerNode extends Node { [ videoLoadedProperty, model.autoTrackingProperty ], ( loaded, tracking ) => loaded && tracking ); - const autoTrackerNode = new AutoTrackerNode( this.videoElement, autoTrackingShownProperty ); + const autoTrackerNode = new AutoTrackerNode( this.videoElement, autoTrackingShownProperty, model ); // ── Manual digitizing overlay ───────────────────────────────────────── // Sits on top of the video; active when a track checkbox is checked. @@ -198,12 +198,20 @@ export class VideoPlayerNode extends Node { }, } ); - // Coloured dots at click positions, filtered by current frame - type MarkData = { frame: number; localX: number; localY: number; color: string }; + // ── Mark data: pixel-space dots for digitized positions ─────────────── + // trackId is included so that marks can be filtered when a track is deleted. + type MarkData = { trackId: string; frame: number; localX: number; localY: number; color: string }; const markData: MarkData[] = []; const marksLayer = new Node( { pickable: false } ); const rebuildMarks = () => { + const activeTrackIds = new Set( model.tracksProperty.value.map( t => t.id ) ); + // Remove any marks whose track has been deleted or all tracks on reset. + for ( let i = markData.length - 1; i >= 0; i-- ) { + if ( !activeTrackIds.has( markData[ i ].trackId ) ) { + markData.splice( i, 1 ); + } + } const currentFrame = Math.round( model.currentTimeProperty.value / FRAME_DURATION ); marksLayer.children = markData .filter( m => m.frame <= currentFrame ) @@ -211,6 +219,8 @@ export class VideoPlayerNode extends Node { }; model.currentTimeProperty.link( () => rebuildMarks() ); + // Rebuild (and prune) whenever tracks are added, removed, or cleared. + model.tracksProperty.link( () => rebuildMarks() ); model.activeTrackIdProperty.link( activeId => { digitizingOverlay.visible = activeId !== null; @@ -235,7 +245,7 @@ export class VideoPlayerNode extends Node { const mvt = model.modelViewTransformProperty.value; const modelPt = mvt.inversePosition2( globalPt ); - markData.push( { frame, localX: localPt.x, localY: localPt.y, color: track.color } ); + markData.push( { trackId: activeId, frame, localX: localPt.x, localY: localPt.y, color: track.color } ); rebuildMarks(); model.addPointToTrack( activeId, frame, time, modelPt.x, modelPt.y ); @@ -451,7 +461,7 @@ export class VideoPlayerNode extends Node { /** Pause playback and advance by exactly one frame (1/30 s). */ public stepForward(): void { this.model.isPlayingProperty.value = false; - const raw = this.videoElement.currentTime + FRAME_DURATION; + const raw = this.videoElement.currentTime + ( 1 / 30 ); const clamped = Math.max( 0, Math.min( raw, this.videoElement.duration ) ); this.videoElement.currentTime = clamped; this.model.currentTimeProperty.value = clamped;
FrameTime (s)X${ track.symbol } (${ unit })Y${ track.symbol } (${ unit })
${ frame }${ time.toFixed( 3 ) }${ xTxt }${ yTxt }