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
7 changes: 7 additions & 0 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -61,14 +61,21 @@ Fleet-standard Vitest layout:
| `tests/setup.ts` | Canvas / AudioContext mocks + `init({ name: "…" })` before SceneryStack imports |
| `tests/**/*.test.ts` | Model/physics unit tests — mirror `src/` under `tests/` |
| `tests/memory-leak.test.ts` | WeakRef + `forceGC` dispose regression (fleet pattern) |
| `tsconfig.tests.json` | Typecheck config for `tests/` (adds `node` types), run by `npm run check` |

Actual specs:

- `tests/track-lab/model/KinematicsComputer.test.ts`
- `tests/track-lab/model/TrackingModel.test.ts`
- `tests/memory-leak.test.ts`

Run `npm test`. CI runs the suite when a `test` script is present.

`npm run check` typechecks three programs — `src` (root `tsconfig.json`), `scripts`
(`tsconfig.scripts.json`), and `tests` (`tsconfig.tests.json`). Vitest transpiles without
typechecking, so a passing `npm test` does not imply the specs typecheck; `npm run check` is what
catches that.

## Commands

```bash
Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@
"lint": "biome check .",
"format": "biome format --write .",
"fix": "biome check --write .",
"check": "tsc --noEmit && tsc -p tsconfig.scripts.json --noEmit",
"check": "tsc --noEmit && tsc -p tsconfig.scripts.json --noEmit && tsc -p tsconfig.tests.json --noEmit",
"test": "vitest run",
"test:watch": "vitest",
"release": "npm run check && npm run lint && npm run build && npm version patch && git push && git push --tags",
Expand Down
2 changes: 2 additions & 0 deletions src/TrackLabColors.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,8 @@ const TrackLabColors = {
tableEmptyTextProperty: profileColor("tableEmptyText", new Color(136, 136, 136), new Color(120, 120, 120)),
tableSymbolShadowProperty: profileColor("tableSymbolShadow", new Color(0, 0, 0, 0.5), new Color(0, 0, 0, 0.3)),
tableBackgroundProperty: profileColor("tableBackground", WHITE, new Color(250, 250, 250)),
// Outline marking the row for the frame the video is parked on.
tableCurrentRowProperty: profileColor("tableCurrentRow", new Color(232, 119, 34), new Color(214, 100, 20)),
exportButtonProperty: profileColor("exportButton", new Color(76, 175, 80), new Color(60, 150, 65)),

// Graph (ConfigurableGraph, GraphDataManager, GraphControlsPanel)
Expand Down
70 changes: 70 additions & 0 deletions src/TrackLabIcons.ts
Original file line number Diff line number Diff line change
Expand Up @@ -143,6 +143,76 @@ export function makeTrashIcon(): Node {
return new Node({ children: [handle, lid, body, l1, l2, l3] });
}

/**
* Eraser icon: a tilted eraser block with a divider between the rubber and the
* sleeve. Deliberately unlike makeTrashIcon() — the erase-point and
* remove-track actions must never be mistaken for one another.
*/
export function makeEraserIcon(): Node {
const w = 12; // eraser block length
const h = 6.5; // eraser block height
const lw = 1.2;
const sleeveX = 4.5; // divider between rubber tip and sleeve
const tilt = -Math.PI / 6; // block leans like a held eraser

const body = new Rectangle(0, 0, w, h, 1, 1, {
stroke: TrackLabColors.playbackButtonIconColorProperty,
lineWidth: lw,
fill: null,
});
const divider = new Line(sleeveX, 0, sleeveX, h, {
stroke: TrackLabColors.playbackButtonIconColorProperty,
lineWidth: lw,
});

const block = new Node({ children: [body, divider] });
block.rotation = tilt;
// Re-wrap so the caller receives an unrotated node whose origin is its centre.
return new Node({ children: [block] });
}

/**
* Restore icon: a counter-clockwise arrow looping back on itself ("undo the
* last delete").
*/
export function makeRestoreIcon(): Node {
const r = 5; // arc radius
const lw = 1.6;
const headLength = 5; // tip-to-base distance of the arrowhead
const headHalfWidth = 3; // half the arrowhead's base
// The glyph is only ~12px on screen, so the head has to be large relative to
// the arc — a subtle one just reads as a letter "C".
const headAngle = Math.PI * 0.4;
const tailAngle = Math.PI * 1.75;

const arc = new Path(new Shape().arc(0, 0, r, headAngle, tailAngle, false), {
stroke: TrackLabColors.playbackButtonIconColorProperty,
lineWidth: lw,
fill: null,
});

// Arrowhead at the arc's start, pointing back against the sweep so the loop
// reads as "go backwards". Tangent at headAngle is (-sin, cos); negate it.
const hx = r * Math.cos(headAngle);
const hy = r * Math.sin(headAngle);
const tipX = Math.sin(headAngle);
const tipY = -Math.cos(headAngle);
// Perpendicular to the tip direction, used to lay out the base corners.
const perpX = -tipY;
const perpY = tipX;

const head = new Path(
new Shape()
.moveTo(hx + tipX * headLength, hy + tipY * headLength)
.lineTo(hx + perpX * headHalfWidth, hy + perpY * headHalfWidth)
.lineTo(hx - perpX * headHalfWidth, hy - perpY * headHalfWidth)
.close(),
{ fill: TrackLabColors.playbackButtonIconColorProperty },
);

return new Node({ children: [arc, head] });
}

/**
* Two small XY arrows for the coordinate-system control / info-dialog row.
*/
Expand Down
8 changes: 8 additions & 0 deletions src/i18n/StringManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -88,11 +88,13 @@ export class StringManager {
titleStringProperty: ReadOnlyProperty<string>;
simulationControlsStringProperty: ReadOnlyProperty<string>;
graphInteractionsStringProperty: ReadOnlyProperty<string>;
digitizingStringProperty: ReadOnlyProperty<string>;
playPauseSimulationStringProperty: ReadOnlyProperty<string>;
resetSimulationStringProperty: ReadOnlyProperty<string>;
stepBackwardStringProperty: ReadOnlyProperty<string>;
stepForwardStringProperty: ReadOnlyProperty<string>;
rewindToStartStringProperty: ReadOnlyProperty<string>;
erasePointStringProperty: ReadOnlyProperty<string>;
resetZoomStringProperty: ReadOnlyProperty<string>;
zoomInOutStringProperty: ReadOnlyProperty<string>;
panViewStringProperty: ReadOnlyProperty<string>;
Expand All @@ -101,11 +103,13 @@ export class StringManager {
titleStringProperty: stringProperties.keyboardShortcuts.titleStringProperty,
simulationControlsStringProperty: stringProperties.keyboardShortcuts.simulationControlsStringProperty,
graphInteractionsStringProperty: stringProperties.keyboardShortcuts.graphInteractionsStringProperty,
digitizingStringProperty: stringProperties.keyboardShortcuts.digitizingStringProperty,
playPauseSimulationStringProperty: stringProperties.keyboardShortcuts.playPauseSimulationStringProperty,
resetSimulationStringProperty: stringProperties.keyboardShortcuts.resetSimulationStringProperty,
stepBackwardStringProperty: stringProperties.keyboardShortcuts.stepBackwardStringProperty,
stepForwardStringProperty: stringProperties.keyboardShortcuts.stepForwardStringProperty,
rewindToStartStringProperty: stringProperties.keyboardShortcuts.rewindToStartStringProperty,
erasePointStringProperty: stringProperties.keyboardShortcuts.erasePointStringProperty,
resetZoomStringProperty: stringProperties.keyboardShortcuts.resetZoomStringProperty,
zoomInOutStringProperty: stringProperties.keyboardShortcuts.zoomInOutStringProperty,
panViewStringProperty: stringProperties.keyboardShortcuts.panViewStringProperty,
Expand Down Expand Up @@ -441,6 +445,8 @@ export class StringManager {
digitizingAreaStringProperty: ReadOnlyProperty<string>;
digitizeTrackStringProperty: ReadOnlyProperty<string>;
removeTrackStringProperty: ReadOnlyProperty<string>;
erasePointStringProperty: ReadOnlyProperty<string>;
restorePointStringProperty: ReadOnlyProperty<string>;
dataTableStringProperty: ReadOnlyProperty<string>;
exportCSVStringProperty: ReadOnlyProperty<string>;
measuringTapeBaseStringProperty: ReadOnlyProperty<string>;
Expand Down Expand Up @@ -485,6 +491,8 @@ export class StringManager {
digitizingAreaStringProperty: stringProperties.a11y.digitizingAreaStringProperty,
digitizeTrackStringProperty: stringProperties.a11y.digitizeTrackStringProperty,
removeTrackStringProperty: stringProperties.a11y.removeTrackStringProperty,
erasePointStringProperty: stringProperties.a11y.erasePointStringProperty,
restorePointStringProperty: stringProperties.a11y.restorePointStringProperty,
dataTableStringProperty: stringProperties.a11y.dataTableStringProperty,
exportCSVStringProperty: stringProperties.a11y.exportCSVStringProperty,
measuringTapeBaseStringProperty: stringProperties.a11y.measuringTapeBaseStringProperty,
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
"title": "Keyboard Shortcuts",
"simulationControls": "Simulation Controls",
"graphInteractions": "Graph Interactions",
"digitizing": "Digitizing",
"playPauseSimulation": "Play/Pause Simulation",
"resetSimulation": "Reset Simulation",
"stepBackward": "Step Backward",
"stepForward": "Step Forward",
"rewindToStart": "Rewind to Start",
"erasePoint": "Erase Point at Current Frame",
"resetZoom": "Reset Zoom",
"zoomInOut": "Zoom In/Out",
"panView": "Pan View"
Expand Down Expand Up @@ -158,7 +160,7 @@
"addTrackTitle": "4. Add a Track",
"addTrackBody": "In the Tracks panel, click “+ Add Track” to create a new track (A, B, C…). Up to 4 tracks can be active at a time.",
"digitizeTitle": "5. Digitize Frame by Frame",
"digitizeBody": "Select a track and click the object’s position in the video. Enable the Magnifier for precision. The video advances one frame after each click.",
"digitizeBody": "Select a track and click the object’s position in the video. Enable the Magnifier for precision. The video advances one frame after each click. Click the same frame again to move a point, or press Delete to erase it.",
"autoTrackTitle": "Auto-Tracking",
"autoTrackBody": "Enable Auto-Tracking in the control panel, then drag a selection box around the object. TrackLab will track it automatically across the remaining frames.",
"measuringTapeTitle": "Measuring Tape",
Expand All @@ -176,9 +178,11 @@
"videoPlayer": "Video player",
"videoScrubber": "Video timeline — drag to seek",
"rewindToStart": "Rewind to start",
"digitizingArea": "Video digitizing area — click to record the position of the active track",
"digitizingArea": "Video digitizing area — click to record the position of the active track, or press Delete to erase the point at this frame",
"digitizeTrack": "Digitize track {{symbol}}",
"removeTrack": "Remove track {{symbol}}",
"erasePoint": "Erase the digitized point at the current frame",
"restorePoint": "Restore the last erased point",
"dataTable": "Track data",
"exportCSV": "Export track data as CSV file",
"measuringTapeBase": "Measuring tape base endpoint",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/strings_es.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
"title": "Atajos de teclado",
"simulationControls": "Controles de simulación",
"graphInteractions": "Interacciones con la gráfica",
"digitizing": "Digitalización",
"playPauseSimulation": "Reproducir/Pausar la simulación",
"resetSimulation": "Reiniciar la simulación",
"stepBackward": "Retroceder un paso",
"stepForward": "Avanzar un paso",
"rewindToStart": "Rebobinar al inicio",
"erasePoint": "Borrar punto en el fotograma actual",
"resetZoom": "Restablecer el zoom",
"zoomInOut": "Acercar/Alejar",
"panView": "Desplazar la vista"
Expand Down Expand Up @@ -158,7 +160,7 @@
"addTrackTitle": "4. Añadir una pista",
"addTrackBody": "En el panel de pistas, haz clic en «+ Añadir pista» para crear una nueva pista (A, B, C…). Pueden estar activas hasta 4 pistas a la vez.",
"digitizeTitle": "5. Digitalizar fotograma a fotograma",
"digitizeBody": "Selecciona una pista y haz clic en la posición del objeto en el video. Activa la lupa para mayor precisión. El video avanza un fotograma después de cada clic.",
"digitizeBody": "Selecciona una pista y haz clic en la posición del objeto en el video. Activa la lupa para mayor precisión. El video avanza un fotograma después de cada clic. Vuelve a hacer clic en el mismo fotograma para mover un punto, o pulsa Supr para borrarlo.",
"autoTrackTitle": "Seguimiento automático",
"autoTrackBody": "Activa el seguimiento automático en el panel de control, luego arrastra un cuadro de selección alrededor del objeto. TrackLab lo seguirá automáticamente a lo largo de los fotogramas restantes.",
"measuringTapeTitle": "Cinta métrica",
Expand All @@ -176,9 +178,11 @@
"videoPlayer": "Reproductor de video",
"videoScrubber": "Línea de tiempo del video — arrastra para buscar",
"rewindToStart": "Rebobinar al inicio",
"digitizingArea": "Área de digitalización del video — haz clic para registrar la posición de la pista activa",
"digitizingArea": "Área de digitalización del video — haz clic para registrar la posición de la pista activa, o pulsa Supr para borrar el punto de este fotograma",
"digitizeTrack": "Digitalizar la pista {{symbol}}",
"removeTrack": "Eliminar la pista {{symbol}}",
"erasePoint": "Borrar el punto digitalizado en el fotograma actual",
"restorePoint": "Restaurar el último punto borrado",
"dataTable": "Datos de la pista",
"exportCSV": "Exportar los datos de la pista como archivo CSV",
"measuringTapeBase": "Extremo base de la cinta métrica",
Expand Down
8 changes: 6 additions & 2 deletions src/i18n/strings_fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -42,11 +42,13 @@
"title": "Raccourcis clavier",
"simulationControls": "Contrôles de simulation",
"graphInteractions": "Interactions avec le graphique",
"digitizing": "Numérisation",
"playPauseSimulation": "Lecture/Pause de la simulation",
"resetSimulation": "Réinitialiser la simulation",
"stepBackward": "Reculer d'un pas",
"stepForward": "Avancer d'un pas",
"rewindToStart": "Revenir au début",
"erasePoint": "Effacer le point à l'image actuelle",
"resetZoom": "Réinitialiser le zoom",
"zoomInOut": "Zoom avant/arrière",
"panView": "Déplacer la vue"
Expand Down Expand Up @@ -158,7 +160,7 @@
"addTrackTitle": "4. Ajouter une piste",
"addTrackBody": "Dans le panneau Pistes, cliquez sur « + Ajouter une piste » pour créer une nouvelle piste étiquetée A, B, C, …",
"digitizeTitle": "5. Numériser image par image",
"digitizeBody": "Sélectionnez une piste et cliquez sur la position de l’objet dans la vidéo. Activez la Loupe pour plus de précision. La vidéo avance automatiquement d’une image après chaque clic.",
"digitizeBody": "Sélectionnez une piste et cliquez sur la position de l’objet dans la vidéo. Activez la Loupe pour plus de précision. La vidéo avance automatiquement d’une image après chaque clic. Cliquez à nouveau sur la même image pour déplacer un point, ou appuyez sur Suppr pour l’effacer.",
"autoTrackTitle": "Suivi automatique",
"autoTrackBody": "Activez le Suivi automatique dans le panneau de contrôle, puis faites glisser un cadre de sélection autour de l’objet. TrackLab le suivra automatiquement sur les images restantes.",
"measuringTapeTitle": "Ruban de mesure",
Expand All @@ -176,9 +178,11 @@
"videoPlayer": "Lecteur vidéo",
"videoScrubber": "Ligne de temps vidéo — glisser pour naviguer",
"rewindToStart": "Revenir au début",
"digitizingArea": "Zone de numérisation vidéo — cliquer pour enregistrer la position de la piste active",
"digitizingArea": "Zone de numérisation vidéo — cliquer pour enregistrer la position de la piste active, ou appuyer sur Suppr pour effacer le point de cette image",
"digitizeTrack": "Numériser la piste {{symbol}}",
"removeTrack": "Supprimer la piste {{symbol}}",
"erasePoint": "Effacer le point numérisé à l'image actuelle",
"restorePoint": "Restaurer le dernier point effacé",
"dataTable": "Données de piste",
"exportCSV": "Exporter les données de piste en fichier CSV",
"measuringTapeBase": "Extrémité de base du ruban de mesure",
Expand Down
28 changes: 26 additions & 2 deletions src/track-lab/model/TrackLabModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -61,12 +61,36 @@ export class TrackLabModel {
* The view passes raw inputs (track id + pixel position); this method
* coordinates all sub-model interactions so the view does not need to reach
* into playback or overlayTools directly.
*
* Clicking a frame that already holds a point *replaces* it: re-clicking is
* how a user corrects a misclick, and silently ignoring the second click
* reads as the tool being broken. Auto-tracking keeps the first-wins policy
* so it never clobbers a hand-placed point.
*/
public recordTrackPoint(trackId: string, pixelPoint: Vector2): void {
const time = this.playback.currentTimeProperty.value;
const frame = Math.round(time * this.playback.frameRateProperty.value);
// Same derivation as deleteTrackPointAtCurrentFrame(), so the frame a click
// writes is exactly the frame the erase button targets.
const frame = this.playback.currentFrameProperty.value;
const modelPt = this.overlayTools.modelViewTransformProperty.value.inversePosition2(pixelPoint);
this.tracking.addPointToTrack(trackId, frame, time, modelPt.x, modelPt.y);
this.tracking.addOrReplacePointOnTrack(trackId, frame, time, modelPt.x, modelPt.y);
}

/**
* Delete the active track's point at the current playback frame, if any.
* Returns true when a point was actually removed.
*/
public deleteTrackPointAtCurrentFrame(): boolean {
const trackId = this.tracking.activeTrackIdProperty.value;
if (trackId === null) {
return false;
}
const frame = this.playback.currentFrameProperty.value;
if (!this.tracking.hasPointAtFrame(trackId, frame)) {
return false;
}
this.tracking.removePointFromTrack(trackId, frame);
return true;
}

// ── Video source activation ─────────────────────────────────────────────
Expand Down
Loading
Loading