diff --git a/src/TrackLabColors.ts b/src/TrackLabColors.ts index 1e7fe94..f950980 100644 --- a/src/TrackLabColors.ts +++ b/src/TrackLabColors.ts @@ -24,16 +24,35 @@ function profileColor( }); } -// ── Track colour palette (one CSS color per symbol A–H, repeats after 8) ──── +// ── Track colour palette (one CSS color per symbol A–Z) ───────────────────── +// 26 distinct, high-contrast colours so every possible track has a unique hue. export const TRACK_COLORS = [ - new Color(255, 140, 0), // A – orange - new Color(0, 188, 212), // B – cyan - new Color(233, 30, 140), // C – magenta - new Color(156, 39, 176), // D – purple - new Color(205, 220, 57), // E – lime-yellow - new Color(0, 229, 255), // F – light cyan - new Color(255, 87, 34), // G – deep orange - new Color(118, 255, 3), // H – light green + new Color(255, 140, 0), // A – orange + new Color(0, 188, 212), // B – cyan + new Color(233, 30, 140), // C – magenta + new Color(156, 39, 176), // D – purple + new Color(205, 220, 57), // E – lime-yellow + new Color(0, 229, 255), // F – light cyan + new Color(255, 87, 34), // G – deep orange + new Color(118, 255, 3), // H – light green + new Color(244, 67, 54), // I – red + new Color(63, 81, 181), // J – indigo + new Color(0, 150, 136), // K – teal + new Color(255, 235, 59), // L – yellow + new Color(121, 85, 72), // M – brown + new Color(96, 125, 139), // N – blue-grey + new Color(233, 30, 99), // O – pink + new Color(33, 150, 243), // P – blue + new Color(139, 195, 74), // Q – light green (darker) + new Color(255, 193, 7), // R – amber + new Color(0, 188, 84), // S – green + new Color(121, 134, 203), // T – periwinkle + new Color(255, 112, 67), // U – deep orange (lighter) + new Color(77, 208, 225), // V – light teal + new Color(174, 213, 129), // W – sage + new Color(240, 98, 146), // X – light pink + new Color(129, 212, 250), // Y – sky blue + new Color(178, 132, 190), // Z – lavender ]; /** diff --git a/src/screen-name/graph/ConfigurableGraph.ts b/src/screen-name/graph/ConfigurableGraph.ts index b092e40..74b3033 100644 --- a/src/screen-name/graph/ConfigurableGraph.ts +++ b/src/screen-name/graph/ConfigurableGraph.ts @@ -672,7 +672,7 @@ export default class ConfigurableGraph extends Node { const x = this.getValueForAxis(xProperty, point); const y = this.getValueForAxis(yProperty, point); - if (x !== null && y !== null) { + if (x !== null && y !== null && Number.isFinite(x) && Number.isFinite(y)) { mappedPoints.push({ x, y }); } } diff --git a/src/screen-name/graph/GraphDataManager.ts b/src/screen-name/graph/GraphDataManager.ts index 28be702..7a84da0 100644 --- a/src/screen-name/graph/GraphDataManager.ts +++ b/src/screen-name/graph/GraphDataManager.ts @@ -36,6 +36,10 @@ export default class GraphDataManager { private readonly trailLength: number = 5; private isManuallyZoomed: boolean = false; + // Pool of Circle nodes reused by updateTrail() to avoid allocating and + // discarding SceneryStack nodes on every update (pan, zoom, new data, etc.). + private readonly trailCirclePool: Circle[] = []; + // Grid and tick components private readonly verticalGridLineSet: GridLineSet; private readonly horizontalGridLineSet: GridLineSet; @@ -240,52 +244,44 @@ export default class GraphDataManager { } /** - * Update the trail visualization showing the most recent points + * Update the trail visualization showing the most recent points. + * Reuses a pool of Circle nodes (updating radius, opacity, position) instead + * of destroying and recreating nodes on every call. */ public updateTrail(): void { - // Clear existing trail circles - this.trailNode.removeAllChildren(); - - // Get the last N points (up to trailLength) const numTrailPoints = Math.min(this.trailLength, this.dataPoints.length); - if (numTrailPoints === 0) { - return; - } - - // Start from the most recent points const startIndex = this.dataPoints.length - numTrailPoints; - for (let i = 0; i < numTrailPoints; i++) { - const point = this.dataPoints[startIndex + i]; - if (!point) continue; - - // Calculate the age of this point (0 = oldest in trail, numTrailPoints-1 = newest) - const age = i; - const fraction = age / (numTrailPoints - 1 || 1); // 0 to 1, where 1 is newest + const minRadius = 3; + const maxRadius = 5; + const minOpacity = 0.2; + const maxOpacity = 0.8; - // Size and opacity increase with recency - // Oldest point: small and transparent - // Newest point: large and opaque - const minRadius = 3; - const maxRadius = 5; - const radius = minRadius + (maxRadius - minRadius) * fraction; - - const minOpacity = 0.2; - const maxOpacity = 0.8; - const opacity = minOpacity + (maxOpacity - minOpacity) * fraction; - - // Transform model coordinates to view coordinates - const viewPosition = this.chartTransform.modelToViewPosition(point); - - // Create circle for this trail point - const circle = new Circle(radius, { + // Grow the pool if needed. + while (this.trailCirclePool.length < numTrailPoints) { + const circle = new Circle(minRadius, { fill: TrackLabColors.plot1Property, - opacity: opacity, - center: viewPosition, + opacity: minOpacity, }); - + this.trailCirclePool.push(circle); this.trailNode.addChild(circle); } + + // Update each pool circle (visible ones first, then hide extras). + for (const [i, circle] of this.trailCirclePool.entries()) { + if (i < numTrailPoints) { + const point = this.dataPoints[startIndex + i]; + if (!point) { circle.visible = false; continue; } + + const fraction = i / (numTrailPoints - 1 || 1); // 0 = oldest, 1 = newest + circle.radius = minRadius + (maxRadius - minRadius) * fraction; + circle.opacity = minOpacity + (maxOpacity - minOpacity) * fraction; + circle.center = this.chartTransform.modelToViewPosition(point); + circle.visible = true; + } else { + circle.visible = false; + } + } } /** diff --git a/src/screen-name/graph/GraphInteractionHandler.ts b/src/screen-name/graph/GraphInteractionHandler.ts index 48f1fde..92635d7 100644 --- a/src/screen-name/graph/GraphInteractionHandler.ts +++ b/src/screen-name/graph/GraphInteractionHandler.ts @@ -412,9 +412,9 @@ export default class GraphInteractionHandler { // Single touch - vertical pan const deltaY = globalPoint.y - singleTouchStartY; - // Convert delta to model coordinates + // Convert delta to model coordinates (negate: dragging down pans values down) const modelDeltaY = - deltaY * (initialYRange.getLength() / this.graphHeight); + -deltaY * (initialYRange.getLength() / this.graphHeight); const newYRange = new Range( initialYRange.min + modelDeltaY, @@ -1077,19 +1077,18 @@ export default class GraphInteractionHandler { * Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data */ public zoomIn(): void { - // Zoom centered on the middle of the chart + // Zoom centered on the middle of the chart; set manual flag so auto-rescale won't override. const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2); - this.zoom(this.zoomFactor, centerPoint, false); + this.zoom(this.zoomFactor, centerPoint, true); } /** * Zoom out centered on the graph - * Note: Does not disable auto-rescaling, allowing the graph to continue adjusting to new data */ public zoomOut(): void { - // Zoom out centered on the middle of the chart + // Zoom out centered on the middle of the chart; set manual flag so auto-rescale won't override. const centerPoint = new Vector2(this.graphWidth / 2, this.graphHeight / 2); - this.zoom(1 / this.zoomFactor, centerPoint, false); + this.zoom(1 / this.zoomFactor, centerPoint, true); } /** diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 301f11b..15b7543 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -40,8 +40,8 @@ export const FRAME_RATE_RANGE = new Range(1, 120); // 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 +export const VIDEO_CENTER_X = LAYOUT_CENTER_X; // 512 +export const VIDEO_CENTER_Y = LAYOUT_CENTER_Y - 20; // 289 const CALIB_HALF_LEN = 100; // pixels from center to each calibration endpoint // Initial tool positions (view / pixel space) @@ -417,7 +417,7 @@ export class SimModel { }; const tracks = [...this.tracksProperty.value, track]; - tracks.sort((a, b) => a.symbol.localeCompare(b.symbol)); + tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0)); this.tracksProperty.value = tracks; } diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index b667051..44243a0 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -59,6 +59,12 @@ export class AutoTrackerNode extends Node { private selecting = false; private selStart = Vector2.ZERO; + // Monotonically increasing counter — each new initFromVideo call captures the + // current value and only applies results if the counter hasn't changed by the + // time the async initialisation completes, preventing stale results from a + // previous drag from overwriting a more recent one. + private initVersion = 0; + // Kept for removeEventListener / unlink in dispose() private readonly boundVideoElement: HTMLVideoElement; private readonly boundOnFrame: () => void; @@ -137,6 +143,8 @@ export class AutoTrackerNode extends Node { const dragListener = new DragListener({ start: (event) => { this.trail.length = 0; + // Bump version so any in-flight initFromVideo call is discarded when it resolves. + this.initVersion++; this.model.tracker.dispose(); this.setCrosshairVisible(false); this.trailPath.shape = null; @@ -191,14 +199,25 @@ export class AutoTrackerNode extends Node { // initFromVideo is async (loads WASM on first call); tracking begins // automatically once `ready` becomes true. + // Capture the current version so stale results from a previous drag + // (still awaiting WASM load) are discarded if a new drag has started. + const capturedVersion = this.initVersion; this.model.tracker .initFromVideo(videoElement, region) + .then(() => { + if (this.initVersion !== capturedVersion) { + // A newer drag has already started; discard this result. + this.model.tracker.dispose(); + } + }) .catch((err) => { - console.error( - "[AutoTracker] Tracking initialisation failed:", - err, - ); - this.hintText.visible = true; + if (this.initVersion === capturedVersion) { + console.error( + "[AutoTracker] Tracking initialisation failed:", + err, + ); + this.hintText.visible = true; + } }); } else { this.hintText.visible = true; @@ -222,8 +241,11 @@ export class AutoTrackerNode extends Node { const activeId = model.activeTrackIdProperty.value; if (activeId) { const time = videoElement.currentTime; - const frameDuration = model.frameDurationProperty.value; - const frame = Math.round(time / frameDuration); + // Multiply by frame rate directly rather than dividing by frameDuration + // (1/fps) to avoid cascading floating-point error at non-integer fps values + // like 29.97, which could cause two adjacent timestamps to map to the same + // frame or skip a frame entirely. + const frame = Math.round(time * model.frameRateProperty.value); // O(1) duplicate-frame check via Set (vs O(n) linear scan). if (!this.recordedFrames.has(frame)) { diff --git a/src/screen-name/view/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index bc04158..01d855c 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -1,5 +1,6 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; import { DerivedProperty } from "scenerystack/axon"; +import { Color } from "scenerystack"; import { Shape } from "scenerystack/kite"; import { Circle, @@ -24,6 +25,7 @@ import type { SimModel } from "../model/SimModel.js"; import { CALIBRATION_UNITS } from "../model/SimModel.js"; const FONT = new PhetFont(14); +const WARNING_FONT = new PhetFont({ size: 11, weight: "bold" }); const ENDPOINT_RADIUS = 4; const ENDPOINT_TOUCH_DILATION = 12; // extra pixels for easier pickup (mouseArea/touchArea) const LINE_WIDTH = 2; @@ -38,6 +40,9 @@ 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 +// Pixel distance below which endpoints are considered overlapping and a warning is shown. +const OVERLAP_WARNING_DISTANCE = 10; +const ENDPOINT_WARNING_COLOR = new Color(255, 60, 60); export class CalibrationToolNode extends Node { public constructor( @@ -161,6 +166,15 @@ export class CalibrationToolNode extends Node { midpointPanel.setScaleMagnitude(MIDPOINT_PANEL_SCALE); this.addChild(midpointPanel); + // ── Overlap warning text ────────────────────────────────────────────── + // Shown when endpoints are too close together to produce a valid calibration. + const overlapWarning = new Text("Points too close — move apart to calibrate", { + font: WARNING_FONT, + fill: ENDPOINT_WARNING_COLOR, + visible: false, + }); + this.addChild(overlapWarning); + // ── Update geometry when endpoints move ─────────────────────────────── const updateGeometry = () => { const p1 = model.calibPoint1Property.value; @@ -171,6 +185,17 @@ export class CalibrationToolNode extends Node { const mid = p1.blend(p2, 0.5); midpointPanel.centerX = mid.x; midpointPanel.bottom = mid.y - MIDPOINT_Y_OFFSET; + + // Show warning and highlight endpoints when too close to be useful. + const tooClose = p1.distance(p2) < OVERLAP_WARNING_DISTANCE; + const endpointFill = tooClose ? ENDPOINT_WARNING_COLOR : TrackLabColors.calibrationFillProperty.value; + endpoint1.fill = endpointFill; + endpoint2.fill = endpointFill; + overlapWarning.visible = tooClose; + if (tooClose) { + overlapWarning.centerX = mid.x; + overlapWarning.top = mid.y + MIDPOINT_Y_OFFSET; + } }; model.calibPoint1Property.link(updateGeometry); model.calibPoint2Property.link(updateGeometry); diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index ec2a3b0..121c025 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -1,4 +1,5 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Bounds2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { Circle, Node, RichDragListener, Text } from "scenerystack/scenery"; import { ArrowNode, PhetFont } from "scenerystack/scenery-phet"; @@ -6,8 +7,17 @@ import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import type { SimModel } from "../model/SimModel.js"; +import { VIDEO_CENTER_X, VIDEO_CENTER_Y, VIDEO_HEIGHT, VIDEO_WIDTH } from "../model/SimModel.js"; const ARROW_LENGTH = 120; + +// Bounds of the video area in view (pixel) coordinates — used to clamp the coord origin drag. +const VIDEO_BOUNDS = new Bounds2( + VIDEO_CENTER_X - VIDEO_WIDTH / 2, + VIDEO_CENTER_Y - VIDEO_HEIGHT / 2, + VIDEO_CENTER_X + VIDEO_WIDTH / 2, + VIDEO_CENTER_Y + VIDEO_HEIGHT / 2, +); const HANDLE_FRACTION = 1 / 3; const FONT = new PhetFont({ size: 14, weight: "bold" }); @@ -135,6 +145,20 @@ export class CoordinateSystemNode extends Node { rotatingNode.rotation = angle; }); + // ── Clamp coord origin to video bounds on every change ──────────────── + // Prevents the user from dragging the coordinate system completely off-screen. + let isClamping = false; + model.coordOriginProperty.lazyLink((pos) => { + if (isClamping) return; + const clampedX = Math.max(VIDEO_BOUNDS.minX, Math.min(VIDEO_BOUNDS.maxX, pos.x)); + const clampedY = Math.max(VIDEO_BOUNDS.minY, Math.min(VIDEO_BOUNDS.maxY, pos.y)); + if (clampedX !== pos.x || clampedY !== pos.y) { + isClamping = true; + model.coordOriginProperty.value = model.coordOriginProperty.value.copy().setXY(clampedX, clampedY); + isClamping = false; + } + }); + // ── Drag: translate the entire coordinate system ────────────────────── positionNode.addInputListener( new RichDragListener({ diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index d88a381..72d8c3b 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -31,8 +31,9 @@ const TABLE_FONT_SIZE = 11; // HTML table font size in px const EXPORT_BUTTON_FONT_SIZE = 9; // ── Precision ───────────────────────────────────────────────────────────────── +// Both values are kept equal so exported CSV data matches what users see on screen. 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 CELL_DECIMAL_PLACES = 4; // 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 ────────────────────────────────────────────────────────────── @@ -407,15 +408,13 @@ export class DataTableNode extends Panel { const unit = unitProperty.value; const csv = generateCSV(tracks, unit, getLabels()); - // Create download + // Create download — no DOM insertion needed in modern browsers. const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); const url = URL.createObjectURL(blob); const link = document.createElement("a"); link.href = url; link.download = `export${this.exportCounter}.csv`; - document.body.appendChild(link); link.click(); - document.body.removeChild(link); URL.revokeObjectURL(url); this.exportCounter++; @@ -533,9 +532,9 @@ export class DataTableNode extends Panel { } for (const row of dataRows) { - if (this.frameRowMap.has(row.frame)) { + const tr = this.frameRowMap.get(row.frame); + if (tr !== undefined) { // Update cells in an existing row (a second track filled in this frame). - const tr = this.frameRowMap.get(row.frame)!; const cells = tr.querySelectorAll("td"); let cellIdx = 2; // skip Frame and Time columns for (const track of tracks) { diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index 353e468..1f8053e 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -122,8 +122,16 @@ export class DigitizingOverlayNode extends Node { /** * Computes the rendered video bounds within the display element, * accounting for letterboxing/pillarboxing when aspect ratios differ. + * Cached and only recomputed when the video's intrinsic dimensions change. */ - const getRenderedVideoBounds = () => { + type VideoBoundsCache = { + renderedW: number; renderedH: number; + offsetX: number; offsetY: number; + videoW: number; videoH: number; + }; + let cachedVideoBounds: VideoBoundsCache | null = null; + + const computeRenderedVideoBounds = (): VideoBoundsCache => { const displayW = videoElement.width; const displayH = videoElement.height; const videoW = videoElement.videoWidth || displayW; @@ -152,6 +160,17 @@ export class DigitizingOverlayNode extends Node { return { renderedW, renderedH, offsetX, offsetY, videoW, videoH }; }; + // Invalidate the cache whenever the video's intrinsic dimensions become available. + const onMetadata = () => { cachedVideoBounds = null; }; + videoElement.addEventListener("loadedmetadata", onMetadata); + + const getRenderedVideoBounds = (): VideoBoundsCache => { + if (!cachedVideoBounds) { + cachedVideoBounds = computeRenderedVideoBounds(); + } + return cachedVideoBounds; + }; + const updateMagnifier = ( localX: number, localY: number, @@ -344,8 +363,7 @@ export class DigitizingOverlayNode extends Node { ); const time = model.currentTimeProperty.value; - const frameDuration = model.frameDurationProperty.value; - const frame = Math.round(time / frameDuration); + const frame = Math.round(time * model.frameRateProperty.value); const mvt = model.modelViewTransformProperty.value; const modelPt = mvt.inversePosition2(localPt); @@ -362,6 +380,7 @@ export class DigitizingOverlayNode extends Node { // Store cleanup function this.disposeDigitizingOverlay = () => { + videoElement.removeEventListener("loadedmetadata", onMetadata); TrackLabColors.digitizingMagnifierBorderProperty.unlink(magBorderListener); TrackLabColors.digitizingMagnifierCrosshairProperty.unlink(magCrosshairListener); TrackLabColors.digitizingMagnifierShadowProperty.unlink(magShadowListener); diff --git a/src/screen-name/view/KeyboardShorcutsNode.ts b/src/screen-name/view/KeyboardShorcutsNode.ts deleted file mode 100644 index 5c0ddec..0000000 --- a/src/screen-name/view/KeyboardShorcutsNode.ts +++ /dev/null @@ -1,72 +0,0 @@ -/** - * Keyboard shortcuts help content for TrackLab simulations. - * Displays available keyboard shortcuts in a two-column layout. - */ - -import { - KeyboardHelpSection, - KeyboardHelpSectionRow, - TextKeyNode, - TwoColumnKeyboardHelpContent, -} from "scenerystack/scenery-phet"; -import { StringManager } from "../../i18n/StringManager.js"; -import trackLab from "../../TrackLabNamespace.js"; - -export class KeyboardShortcutsNode extends TwoColumnKeyboardHelpContent { - public constructor() { - const stringManager = StringManager.getInstance(); - const keyboardShortcutsStrings = - stringManager.getKeyboardShortcutsStrings(); - - // Create sections for simulation controls - const simulationControlsSection = new KeyboardHelpSection( - keyboardShortcutsStrings.simulationControlsStringProperty, - [ - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.playPauseSimulationStringProperty, - TextKeyNode.space(), - ), - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.resetSimulationStringProperty, - new TextKeyNode("R"), - ), - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.stepBackwardStringProperty, - new TextKeyNode("\u2190"), // Left arrow - ), - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.stepForwardStringProperty, - new TextKeyNode("\u2192"), // Right arrow - ), - ], - ); - - // Create sections for graph interactions - const graphInteractionsSection = new KeyboardHelpSection( - keyboardShortcutsStrings.graphInteractionsStringProperty, - [ - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.resetZoomStringProperty, - new TextKeyNode("Double-click"), - ), - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.zoomInOutStringProperty, - new TextKeyNode("Mouse wheel"), - ), - KeyboardHelpSectionRow.labelWithIcon( - keyboardShortcutsStrings.panViewStringProperty, - new TextKeyNode("Drag"), - ), - ], - ); - - // Left column has simulation controls, right column has graph interactions - super([simulationControlsSection], [graphInteractionsSection], { - columnSpacing: 20, - sectionSpacing: 15, - }); - } -} - -// Register with namespace for debugging accessibility -trackLab.register("KeyboardShortcutsNode", KeyboardShortcutsNode); diff --git a/src/screen-name/view/KeyboardShortcutsNode.ts b/src/screen-name/view/KeyboardShortcutsNode.ts index d1705ec..5c0ddec 100644 --- a/src/screen-name/view/KeyboardShortcutsNode.ts +++ b/src/screen-name/view/KeyboardShortcutsNode.ts @@ -1,22 +1,72 @@ -import { Node, VBox } from "scenerystack/scenery"; +/** + * Keyboard shortcuts help content for TrackLab simulations. + * Displays available keyboard shortcuts in a two-column layout. + */ + import { - BasicActionsKeyboardHelpSection, - MoveDraggableItemsKeyboardHelpSection, + KeyboardHelpSection, + KeyboardHelpSectionRow, + TextKeyNode, + TwoColumnKeyboardHelpContent, } from "scenerystack/scenery-phet"; +import { StringManager } from "../../i18n/StringManager.js"; +import trackLab from "../../TrackLabNamespace.js"; -export class KeyboardShortcutsNode extends Node { +export class KeyboardShortcutsNode extends TwoColumnKeyboardHelpContent { public constructor() { - super(); + const stringManager = StringManager.getInstance(); + const keyboardShortcutsStrings = + stringManager.getKeyboardShortcutsStrings(); + + // Create sections for simulation controls + const simulationControlsSection = new KeyboardHelpSection( + keyboardShortcutsStrings.simulationControlsStringProperty, + [ + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.playPauseSimulationStringProperty, + TextKeyNode.space(), + ), + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.resetSimulationStringProperty, + new TextKeyNode("R"), + ), + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.stepBackwardStringProperty, + new TextKeyNode("\u2190"), // Left arrow + ), + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.stepForwardStringProperty, + new TextKeyNode("\u2192"), // Right arrow + ), + ], + ); - this.addChild( - new VBox({ - children: [ - new BasicActionsKeyboardHelpSection(), - new MoveDraggableItemsKeyboardHelpSection(), - ], - spacing: 16, - align: "left", - }), + // Create sections for graph interactions + const graphInteractionsSection = new KeyboardHelpSection( + keyboardShortcutsStrings.graphInteractionsStringProperty, + [ + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.resetZoomStringProperty, + new TextKeyNode("Double-click"), + ), + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.zoomInOutStringProperty, + new TextKeyNode("Mouse wheel"), + ), + KeyboardHelpSectionRow.labelWithIcon( + keyboardShortcutsStrings.panViewStringProperty, + new TextKeyNode("Drag"), + ), + ], ); + + // Left column has simulation controls, right column has graph interactions + super([simulationControlsSection], [graphInteractionsSection], { + columnSpacing: 20, + sectionSpacing: 15, + }); } } + +// Register with namespace for debugging accessibility +trackLab.register("KeyboardShortcutsNode", KeyboardShortcutsNode); diff --git a/src/tracking/OpenCVTracker.ts b/src/tracking/OpenCVTracker.ts index cc2f6a9..3ca19b4 100644 --- a/src/tracking/OpenCVTracker.ts +++ b/src/tracking/OpenCVTracker.ts @@ -72,6 +72,24 @@ export class OpenCVTracker { return this.cv !== null && this.templateMat !== null; } + /** + * Draw a video frame onto the offscreen canvas and read back the pixels. + * Throws a descriptive Error (wrapping the original SecurityError) if the + * video is cross-origin and has no CORS headers, instead of letting the + * SecurityError propagate uncaught. + */ + private captureFrame(video: HTMLVideoElement): ImageData { + this.ctx.drawImage(video, 0, 0); + try { + return this.ctx.getImageData(0, 0, this.offscreen.width, this.offscreen.height); + } catch (e) { + const err = new Error( + "Cannot read video pixels: the video source may be cross-origin without CORS headers.", + ); + throw Object.assign(err, { cause: e }); + } + } + /** * Capture the template from the currently visible video frame inside `region`, * loading OpenCV (WASM) on first call. @@ -82,13 +100,7 @@ export class OpenCVTracker { ): Promise { this.cv = await loadCV(); - this.ctx.drawImage(video, 0, 0); - const imageData = this.ctx.getImageData( - 0, - 0, - this.offscreen.width, - this.offscreen.height, - ); + const imageData = this.captureFrame(video); const frame = this.cv.matFromImageData(imageData); const gray = new this.cv.Mat(); try { @@ -122,13 +134,13 @@ export class OpenCVTracker { public track(video: HTMLVideoElement): { x: number; y: number } | null { if (!this.ready) return null; - this.ctx.drawImage(video, 0, 0); - const imageData = this.ctx.getImageData( - 0, - 0, - this.offscreen.width, - this.offscreen.height, - ); + let imageData: ImageData; + try { + imageData = this.captureFrame(video); + } catch { + // Cross-origin video — silently skip this frame rather than crashing. + return null; + } const frame = this.cv.matFromImageData(imageData); const gray = new this.cv.Mat(); const result = new this.cv.Mat();