Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
66 changes: 66 additions & 0 deletions src/screen-name/model/SimModel.ts
Original file line number Diff line number Diff line change
@@ -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 );
Expand All @@ -21,6 +34,57 @@ export class SimModel {
new Transform3( Matrix3.IDENTITY )
);

// ── Manual particle tracks ────────────────────────────────────────────
public readonly tracksProperty = new Property<readonly Track[]>( [] );
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();
Expand All @@ -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
Expand Down
19 changes: 19 additions & 0 deletions src/screen-name/model/Track.ts
Original file line number Diff line number Diff line change
@@ -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[];
};
12 changes: 12 additions & 0 deletions src/screen-name/view/SimScreenView.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";

/**
Expand Down Expand Up @@ -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();
Expand Down
195 changes: 195 additions & 0 deletions src/screen-name/view/TrackListPanel.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>,
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();
} );
}
}
11 changes: 10 additions & 1 deletion src/screen-name/view/VideoPlayerNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' },
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -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();
Expand Down