From 7719e6be39c40104c67fb0972c3d63415540e701 Mon Sep 17 00:00:00 2001 From: Claude Date: Wed, 18 Feb 2026 04:49:24 +0000 Subject: [PATCH] Add manual particle-tracking panel MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Users can now create named tracks (A, B, C …) from a right-side panel that appears once a video is loaded. Each track is displayed as a colour-coded box with its letter symbol and a trash-can delete button. Clicking a track box records a data point (frame number + timestamp) for that track and automatically advances the video by one frame, enabling frame-by-frame manual digitising. All recorded points are logged to the browser console. Changes: - src/screen-name/model/Track.ts (new) – TrackPoint / Track types - src/screen-name/model/SimModel.ts – addTrack / removeTrack / addPointToTrack / canAddTrack methods + tracksProperty - src/screen-name/view/TrackListPanel.ts (new) – TrackListPanel + TrackRowNode UI components - src/screen-name/view/VideoPlayerNode.ts – public stepForward() method - src/screen-name/view/SimScreenView.ts – wire TrackListPanel into layout https://claude.ai/code/session_01CP5L71zPTyP7PVNarwE1sz --- src/screen-name/model/SimModel.ts | 66 ++++++++ src/screen-name/model/Track.ts | 19 +++ src/screen-name/view/SimScreenView.ts | 12 ++ src/screen-name/view/TrackListPanel.ts | 195 ++++++++++++++++++++++++ src/screen-name/view/VideoPlayerNode.ts | 11 +- 5 files changed, 302 insertions(+), 1 deletion(-) create mode 100644 src/screen-name/model/Track.ts create mode 100644 src/screen-name/view/TrackListPanel.ts diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 2e06fae..aa1c8df 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -1,5 +1,18 @@ import { BooleanProperty, Property } from "scenerystack/axon"; import { Matrix3, Transform3 } from "scenerystack/dot"; +import type { Track, TrackPoint } from "./Track.js"; + +// ── Track colour palette (one per letter, repeats after 8) ───────────────── +const TRACK_COLORS = [ + '#FF8C00', // A – orange + '#00BCD4', // B – cyan + '#E91E8C', // C – magenta + '#9C27B0', // D – purple + '#CDDC39', // E – lime-yellow + '#00E5FF', // F – light cyan + '#FF5722', // G – deep orange + '#76FF03', // H – light green +]; export class SimModel { public readonly isPlayingProperty = new BooleanProperty( false ); @@ -21,6 +34,57 @@ export class SimModel { new Transform3( Matrix3.IDENTITY ) ); + // ── Manual particle tracks ──────────────────────────────────────────── + public readonly tracksProperty = new Property( [] ); + private nextSymbolCode = 65; // ASCII code for 'A' + + public addTrack(): void { + if ( this.nextSymbolCode > 90 ) return; // 'Z' is the last allowed symbol + const symbol = String.fromCharCode( this.nextSymbolCode ); + const color = TRACK_COLORS[ ( this.nextSymbolCode - 65 ) % TRACK_COLORS.length ]; + this.nextSymbolCode++; + + const track: Track = { + id: `track-${ symbol }`, + symbol, + color, + points: [], + }; + + const tracks = [ ...this.tracksProperty.value, track ]; + tracks.sort( ( a, b ) => a.symbol.localeCompare( b.symbol ) ); + this.tracksProperty.value = tracks; + + console.log( `[TrackLab] Added track ${ symbol }. Active tracks: ${ tracks.map( t => t.symbol ).join( ', ' ) }` ); + } + + public removeTrack( id: string ): void { + const removed = this.tracksProperty.value.find( t => t.id === id ); + this.tracksProperty.value = this.tracksProperty.value.filter( t => t.id !== id ); + const remaining = this.tracksProperty.value.map( t => t.symbol ).join( ', ' ) || 'none'; + console.log( `[TrackLab] Removed track ${ removed?.symbol ?? id }. Remaining: ${ remaining }` ); + } + + public addPointToTrack( id: string, frame: number, time: number ): void { + const tracks = this.tracksProperty.value.map( track => { + if ( track.id !== id ) return track; + + const point: TrackPoint = { frame, time }; + const updated: Track = { ...track, points: [ ...track.points, point ] }; + console.log( + `[TrackLab] Track ${ track.symbol } → frame ${ frame } (t=${ time.toFixed( 3 ) }s) | ${ updated.points.length } point(s) recorded`, + updated.points + ); + return updated; + } ); + this.tracksProperty.value = tracks; + } + + /** Returns true if another track can still be added (A–Z not yet exhausted). */ + public canAddTrack(): boolean { + return this.nextSymbolCode <= 90; + } + public reset(): void { this.isPlayingProperty.reset(); this.currentTimeProperty.reset(); @@ -30,6 +94,8 @@ export class SimModel { this.calibrationVisibleProperty.reset(); this.magnifyVideoProperty.reset(); this.autoTrackingProperty.reset(); + this.tracksProperty.value = []; + this.nextSymbolCode = 65; } // eslint-disable-next-line @typescript-eslint/no-unused-vars diff --git a/src/screen-name/model/Track.ts b/src/screen-name/model/Track.ts new file mode 100644 index 0000000..4bcf46f --- /dev/null +++ b/src/screen-name/model/Track.ts @@ -0,0 +1,19 @@ +/** + * Track.ts + * + * Data types for manual particle-tracking tracks. + * Each Track belongs to one particle; each TrackPoint records + * the frame number and timestamp at which the user tagged it. + */ + +export type TrackPoint = { + frame: number; + time: number; // seconds +}; + +export type Track = { + id: string; + symbol: string; // single uppercase letter: 'A', 'B', 'C', ... + color: string; // CSS hex color + points: readonly TrackPoint[]; +}; diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 828315e..2eaa321 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -6,6 +6,7 @@ import type { SimModel } from "../model/SimModel.js"; import { CalibrationToolNode } from "./CalibrationToolNode.js"; import { ControlPanel } from "./ControlPanel.js"; import { CoordinateSystemNode } from "./CoordinateSystemNode.js"; +import { TrackListPanel } from "./TrackListPanel.js"; import { VideoPlayerNode } from "./VideoPlayerNode.js"; /** @@ -107,6 +108,17 @@ export class SimScreenView extends ScreenView { controlPanel.centerY = this.layoutBounds.centerY; this.addChild( controlPanel ); + // ── Track list panel (right of the video) ──────────────────────────── + const trackListPanel = new TrackListPanel( + model, + videoLoadedProperty, + () => this.videoPlayerNode.stepForward() + ); + 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; + const resetAllButton = new ResetAllButton( { listener: () => { model.reset(); diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts new file mode 100644 index 0000000..bfc34a3 --- /dev/null +++ b/src/screen-name/view/TrackListPanel.ts @@ -0,0 +1,195 @@ +/** + * TrackListPanel.ts + * + * Right-side panel for manual particle tracking. + * + * Workflow: + * • User clicks "+ Add Track" → a new labelled track box appears (A, B, C …). + * • Clicking the track box records a data point at the current frame and + * advances the video by one frame (manual digitising). + * • The trash icon removes the track. + * • The panel is hidden until a video is loaded. + */ + +import { BooleanProperty } from "scenerystack/axon"; +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Circle, FireListener, Line, 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 { Color } from "scenerystack"; +import type { SimModel } from "../model/SimModel.js"; +import type { Track } from "../model/Track.js"; +import TrackLabColors from "../../TrackLabColors.js"; + +// ── Layout constants ──────────────────────────────────────────────────────── +const PANEL_WIDTH = 165; // inner content width +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 TRASH_W = 34; // width of the trash button column + +const HEADER_FONT = new PhetFont( { size: 13, weight: 'bold' } ); +const SYMBOL_FONT = new PhetFont( { size: 15, weight: 'bold' } ); +const LABEL_FONT = new PhetFont( 12 ); + +const FRAME_DURATION = 1 / 30; // seconds per frame (30 fps) + +// ── Trash-can icon ────────────────────────────────────────────────────────── + +function makeTrashIcon(): Node { + const s = '#ff6666'; + const lw = 1.5; + const bw = 10; + const bh = 11; + + const body = new Rectangle( 0, 0, bw, bh, 1, 1, { stroke: s, lineWidth: lw, fill: null } ); + const lid = new Rectangle( -1.5, -3.5, bw + 3, 3, 0, 0, { stroke: s, lineWidth: lw, fill: null } ); + const handle = new Rectangle( 2.5, -7, 5, 3.5, 1, 1, { stroke: s, lineWidth: lw, fill: null } ); + const l1 = new Line( bw / 4, 2, bw / 4, bh - 2, { stroke: s, lineWidth: 1 } ); + const l2 = new Line( bw / 2, 2, bw / 2, bh - 2, { stroke: s, lineWidth: 1 } ); + const l3 = new Line( bw * 3 / 4, 2, bw * 3 / 4, bh - 2, { stroke: s, lineWidth: 1 } ); + + return new Node( { children: [ handle, lid, body, l1, l2, l3 ] } ); +} + +// ── Individual track row ──────────────────────────────────────────────────── + +class TrackRowNode extends Node { + public constructor( track: Track, model: SimModel, onTag: () => void ) { + super(); + + const trackColor = new Color( track.color ); + 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, + } ); + + // ── Clickable zone (left of the trash button) ───────────────────────── + // Separate transparent rectangle so the trash button events don't bubble + // up through the same handler. + const clickZone = new Rectangle( 0, 0, PANEL_WIDTH - TRASH_W, ROW_HEIGHT, 0, 0, { + fill: 'transparent', + cursor: 'pointer', + } ); + clickZone.addInputListener( new FireListener( { + fire: onTag, + tandem: Tandem.OPT_OUT, + } ) ); + + // ── Colour badge with symbol letter ─────────────────────────────────── + const badge = new Circle( BADGE_R, { + fill: track.color, + x: BADGE_CX, + y: ROW_CY, + } ); + + const symbolLabel = new Text( track.symbol, { + font: SYMBOL_FONT, + fill: 'white', + } ); + symbolLabel.centerX = BADGE_CX; + symbolLabel.centerY = ROW_CY; + + // ── Trash button (right side) ───────────────────────────────────────── + const trashButton = new RectangularPushButton( { + content: makeTrashIcon(), + baseColor: new Color( 60, 20, 20, 0.5 ), + xMargin: 6, + yMargin: 6, + listener: () => model.removeTrack( track.id ), + tandem: Tandem.OPT_OUT, + } ); + trashButton.centerY = ROW_CY; + trashButton.right = PANEL_WIDTH - 3; + + this.addChild( bg ); + this.addChild( clickZone ); + this.addChild( badge ); + this.addChild( symbolLabel ); + this.addChild( trashButton ); + } +} + +// ── TrackListPanel ────────────────────────────────────────────────────────── + +export class TrackListPanel extends Panel { + public constructor( + model: SimModel, + videoLoadedProperty: TReadOnlyProperty, + onStepForward: () => void + ) { + // Width enforcer: invisible rectangle keeps the panel wide even when the + // track list is empty. + const widthSpacer = new Rectangle( 0, 0, PANEL_WIDTH, 1, { fill: null, stroke: null, pickable: false } ); + + // ── "Add Track" button ──────────────────────────────────────────────── + const addButtonEnabledProperty = new BooleanProperty( false ); + + const addButton = new RectangularPushButton( { + content: new Text( '+ Add Track', { + font: LABEL_FONT, + fill: TrackLabColors.textOnDarkProperty, + } ), + baseColor: TrackLabColors.buttonBaseDarkProperty, + xMargin: 10, + yMargin: 6, + enabledProperty: addButtonEnabledProperty, + listener: () => model.addTrack(), + tandem: Tandem.OPT_OUT, + } ); + + // ── Track list (rebuilt whenever tracks change) ─────────────────────── + const trackListVBox = new VBox( { + children: [], + spacing: 6, + align: 'left', + } ); + + // ── Panel content ───────────────────────────────────────────────────── + const headerLabel = new Text( 'Tracks', { + font: HEADER_FONT, + fill: TrackLabColors.textOnDarkProperty, + } ); + + const content = new VBox( { + children: [ widthSpacer, headerLabel, addButton, trackListVBox ], + spacing: 8, + align: 'center', + } ); + + super( content, { + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, + cornerRadius: 8, + xMargin: 10, + yMargin: 10, + visible: false, + } ); + + // ── Show only when video is loaded ──────────────────────────────────── + videoLoadedProperty.link( loaded => { + this.visible = loaded; + addButtonEnabledProperty.value = loaded && model.canAddTrack(); + } ); + + // ── Rebuild track rows on every track change ────────────────────────── + model.tracksProperty.link( tracks => { + trackListVBox.children = tracks.map( track => { + const onTag = () => { + const frame = Math.round( model.currentTimeProperty.value / FRAME_DURATION ); + model.addPointToTrack( track.id, frame, model.currentTimeProperty.value ); + onStepForward(); + }; + return new TrackRowNode( track, model, onTag ); + } ); + + addButtonEnabledProperty.value = model.canAddTrack(); + } ); + } +} diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 781c762..b1a5e4b 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -11,6 +11,7 @@ import { AutoTrackerNode } from "./AutoTrackerNode.js"; import TrackLabColors from "../../TrackLabColors.js"; const LABEL_FONT = new PhetFont( 14 ); +const FRAME_DURATION = 1 / 30; // assumes 30 fps const VIDEO_FILES = [ { label: 'Ball in Oil', filename: 'ball_oil.mp4', tandemName: 'ballOilItem' }, @@ -104,7 +105,6 @@ export class VideoPlayerNode extends Node { } ); // ── Step one frame (assumed 30 fps) ──────────────────────────────────── - const FRAME_DURATION = 1 / 30; const seekByFrames = ( direction: number ) => { model.isPlayingProperty.value = false; const raw = this.videoElement.currentTime + direction * FRAME_DURATION; @@ -281,6 +281,15 @@ 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 clamped = Math.max( 0, Math.min( raw, this.videoElement.duration ) ); + this.videoElement.currentTime = clamped; + this.model.currentTimeProperty.value = clamped; + } + private loadUrl( url: string ): void { this.videoElement.src = url; this.videoElement.load();