diff --git a/biome.json b/biome.json index b6c4919..4c8efc5 100644 --- a/biome.json +++ b/biome.json @@ -1,5 +1,5 @@ { - "$schema": "https://biomejs.dev/schemas/2.4.2/schema.json", + "$schema": "https://biomejs.dev/schemas/2.4.4/schema.json", "assist": { "actions": { "source": { @@ -10,13 +10,125 @@ "linter": { "enabled": true, "rules": { - "recommended": true + "recommended": true, + "nursery": { + "recommended": true, + "noShadow": "error", + "useExplicitType": "warn", + "noEqualsToNull": "error", + "noReturnAssign": "error", + "useArraySortCompare": "error", + "useFind": "warn", + "useSpread": "warn" + }, + "correctness": { + "recommended": true, + "noUnusedImports": "error", + "noUnusedVariables": "error", + "noUnusedPrivateClassMembers": "error", + "noUndeclaredVariables": "error", + "noUnreachable": "error", + "noInvalidUseBeforeDeclaration": "error" + }, + "style": { + "recommended": true, + "noDefaultExport": "off", + "noParameterAssign": "error", + "useBlockStatements": "error", + "useCollapsedElseIf": "warn", + "useConsistentBuiltinInstantiation": "error", + "useDefaultParameterLast": "error", + "useExplicitLengthCheck": "warn", + "useForOf": "warn", + "useFragmentSyntax": "warn", + "useImportType": "error", + "useShorthandAssign": "warn", + "useShorthandFunctionType": "warn", + "useThrowNewError": "error", + "useThrowOnlyError": "error", + "noCommonJs": "error", + "noExportedImports": "off", + "useNamingConvention": { + "level": "warn", + "options": { + "conventions": [ + { + "selector": { "kind": "variable" }, + "formats": ["camelCase", "CONSTANT_CASE", "PascalCase"] + }, + { + "selector": { "kind": "function" }, + "formats": ["camelCase", "PascalCase"] + }, + { + "selector": { "kind": "typeLike" }, + "formats": ["PascalCase"] + }, + { + "selector": { "kind": "enumMember" }, + "formats": ["CONSTANT_CASE", "PascalCase"] + } + ] + } + }, + "useFilenamingConvention": { + "level": "warn", + "options": { + "requireAscii": true, + "filenameCases": ["PascalCase", "camelCase"] + } + } + }, + "suspicious": { + "recommended": true, + "noConsole": "warn", + "noEmptyBlockStatements": "error", + "noExplicitAny": "warn", + "useAwait": "error", + "useErrorMessage": "error", + "useGuardForIn": "error", + "useIsArray": "error", + "noIrregularWhitespace": "off" + }, + "complexity": { + "recommended": true, + "noExcessiveCognitiveComplexity": { + "level": "warn", + "options": { + "maxAllowedComplexity": 25 + } + }, + "noForEach": "warn", + "noStaticOnlyClass": "error", + "noUselessStringConcat": "error", + "noUselessUndefinedInitialization": "error", + "noVoid": "error", + "useArrowFunction": "warn", + "useDateNow": "error", + "useFlatMap": "warn", + "useSimplifiedLogicExpression": "warn" + }, + "performance": { + "recommended": true, + "noAccumulatingSpread": "warn", + "noBarrelFile": "warn", + "noDelete": "warn", + "noReExportAll": "warn" + }, + "a11y": { + "recommended": true + }, + "security": { + "recommended": true, + "noGlobalEval": "error" + } } }, "formatter": { "enabled": true, "indentStyle": "space", - "indentWidth": 2 + "indentWidth": 2, + "lineWidth": 120 }, "files": { "includes": ["**/*.ts", "**/*.js", "**/*.json", "**/*.html", "!dist"] @@ -24,7 +136,10 @@ "javascript": { "formatter": { "quoteStyle": "double", - "semicolons": "always" + "semicolons": "always", + "trailingCommas": "all", + "arrowParentheses": "always", + "bracketSameLine": false } } } diff --git a/scripts/bouncingBallToSVG.ts b/scripts/bouncingBallToSVG.ts index e5b0932..e21a2c7 100644 --- a/scripts/bouncingBallToSVG.ts +++ b/scripts/bouncingBallToSVG.ts @@ -29,10 +29,7 @@ const FLOOR_WIDTH = 0.8; // stroke-width * T_n ∝ sqrt(h_n). We normalise so the total "time" = 1. */ function buildBounces(): Array<{ tStart: number; tEnd: number; h: number }> { - const heights = Array.from( - { length: NUM_BOUNCES }, - (_, n) => INITIAL_HEIGHT * RESTITUTION ** n, - ); + const heights = Array.from({ length: NUM_BOUNCES }, (_, n) => INITIAL_HEIGHT * RESTITUTION ** n); // flight time ∝ sqrt(h) const durations = heights.map((h) => Math.sqrt(h)); @@ -59,9 +56,7 @@ function computeSnapshots(): Point[] { // x positions of bounce endpoints (floor contacts) spaced proportionally // to flight time (= proportional to normalised duration) - const xContacts: number[] = bounces.map( - (b) => X_START + b.tStart * (X_END - X_START), - ); + const xContacts: number[] = bounces.map((b) => X_START + b.tStart * (X_END - X_START)); xContacts.push(X_END); // final landing for (let i = 0; i < TOTAL_SNAPSHOTS; i++) { @@ -70,7 +65,9 @@ function computeSnapshots(): Point[] { // find which bounce this snapshot falls in const bounce = bounces.find((b) => tGlobal >= b.tStart && tGlobal < b.tEnd); - if (!bounce) continue; // shouldn't happen + if (!bounce) { + continue; // shouldn't happen + } const bounceIdx = bounces.indexOf(bounce); // local time within this bounce, in [0, 1] @@ -93,13 +90,9 @@ function computeSnapshots(): Point[] { return points; } -function buildSVG(points: Point[]): string { - const circles = points - .map( - (p) => - ` `, - ) +function buildSvg(snapshots: Point[]): string { + const circles = snapshots + .map((p) => ` `) .join("\n"); return `; } { return { - titleStringProperty: - this.stringProperties.keyboardShortcuts.titleStringProperty, - simulationControlsStringProperty: - this.stringProperties.keyboardShortcuts - .simulationControlsStringProperty, - graphInteractionsStringProperty: - this.stringProperties.keyboardShortcuts.graphInteractionsStringProperty, - playPauseSimulationStringProperty: - this.stringProperties.keyboardShortcuts - .playPauseSimulationStringProperty, - resetSimulationStringProperty: - this.stringProperties.keyboardShortcuts.resetSimulationStringProperty, - stepBackwardStringProperty: - this.stringProperties.keyboardShortcuts.stepBackwardStringProperty, - stepForwardStringProperty: - this.stringProperties.keyboardShortcuts.stepForwardStringProperty, - resetZoomStringProperty: - this.stringProperties.keyboardShortcuts.resetZoomStringProperty, - zoomInOutStringProperty: - this.stringProperties.keyboardShortcuts.zoomInOutStringProperty, - panViewStringProperty: - this.stringProperties.keyboardShortcuts.panViewStringProperty, + titleStringProperty: this.stringProperties.keyboardShortcuts.titleStringProperty, + simulationControlsStringProperty: this.stringProperties.keyboardShortcuts.simulationControlsStringProperty, + graphInteractionsStringProperty: this.stringProperties.keyboardShortcuts.graphInteractionsStringProperty, + playPauseSimulationStringProperty: this.stringProperties.keyboardShortcuts.playPauseSimulationStringProperty, + resetSimulationStringProperty: this.stringProperties.keyboardShortcuts.resetSimulationStringProperty, + stepBackwardStringProperty: this.stringProperties.keyboardShortcuts.stepBackwardStringProperty, + stepForwardStringProperty: this.stringProperties.keyboardShortcuts.stepForwardStringProperty, + resetZoomStringProperty: this.stringProperties.keyboardShortcuts.resetZoomStringProperty, + zoomInOutStringProperty: this.stringProperties.keyboardShortcuts.zoomInOutStringProperty, + panViewStringProperty: this.stringProperties.keyboardShortcuts.panViewStringProperty, }; } @@ -127,8 +116,7 @@ export class StringManager { speedStringProperty: this.stringProperties.controls.speedStringProperty, recordStringProperty: this.stringProperties.controls.recordStringProperty, stopStringProperty: this.stringProperties.controls.stopStringProperty, - graphVsStringProperty: - this.stringProperties.controls.graphVsStringProperty, + graphVsStringProperty: this.stringProperties.controls.graphVsStringProperty, }; } @@ -149,15 +137,11 @@ export class StringManager { trackStringProperty: this.stringProperties.tracking.trackStringProperty, targetStringProperty: this.stringProperties.tracking.targetStringProperty, originStringProperty: this.stringProperties.tracking.originStringProperty, - calibrateStringProperty: - this.stringProperties.tracking.calibrateStringProperty, + calibrateStringProperty: this.stringProperties.tracking.calibrateStringProperty, scaleStringProperty: this.stringProperties.tracking.scaleStringProperty, - positionStringProperty: - this.stringProperties.tracking.positionStringProperty, - velocityStringProperty: - this.stringProperties.tracking.velocityStringProperty, - accelerationStringProperty: - this.stringProperties.tracking.accelerationStringProperty, + positionStringProperty: this.stringProperties.tracking.positionStringProperty, + velocityStringProperty: this.stringProperties.tracking.velocityStringProperty, + accelerationStringProperty: this.stringProperties.tracking.accelerationStringProperty, }; } @@ -172,14 +156,10 @@ export class StringManager { exportStringProperty: ReadOnlyProperty; } { return { - openFileStringProperty: - this.stringProperties.video.openFileStringProperty, - frameRateStringProperty: - this.stringProperties.video.frameRateStringProperty, - currentFrameStringProperty: - this.stringProperties.video.currentFrameStringProperty, - durationStringProperty: - this.stringProperties.video.durationStringProperty, + openFileStringProperty: this.stringProperties.video.openFileStringProperty, + frameRateStringProperty: this.stringProperties.video.frameRateStringProperty, + currentFrameStringProperty: this.stringProperties.video.currentFrameStringProperty, + durationStringProperty: this.stringProperties.video.durationStringProperty, exportStringProperty: this.stringProperties.video.exportStringProperty, }; } @@ -196,13 +176,10 @@ export class StringManager { } { return { timeStringProperty: this.stringProperties.measurement.timeStringProperty, - distanceStringProperty: - this.stringProperties.measurement.distanceStringProperty, - angleStringProperty: - this.stringProperties.measurement.angleStringProperty, + distanceStringProperty: this.stringProperties.measurement.distanceStringProperty, + angleStringProperty: this.stringProperties.measurement.angleStringProperty, massStringProperty: this.stringProperties.measurement.massStringProperty, - gravityStringProperty: - this.stringProperties.measurement.gravityStringProperty, + gravityStringProperty: this.stringProperties.measurement.gravityStringProperty, }; } @@ -215,13 +192,10 @@ export class StringManager { enableAutoTrackingDescriptionStringProperty: ReadOnlyProperty; } { return { - simulationStringProperty: - this.stringProperties.preferences.simulationStringProperty, - enableAutoTrackingStringProperty: - this.stringProperties.preferences.enableAutoTrackingStringProperty, + simulationStringProperty: this.stringProperties.preferences.simulationStringProperty, + enableAutoTrackingStringProperty: this.stringProperties.preferences.enableAutoTrackingStringProperty, enableAutoTrackingDescriptionStringProperty: - this.stringProperties.preferences - .enableAutoTrackingDescriptionStringProperty, + this.stringProperties.preferences.enableAutoTrackingDescriptionStringProperty, }; } @@ -232,8 +206,7 @@ export class StringManager { dragToSelectStringProperty: ReadOnlyProperty; } { return { - dragToSelectStringProperty: - this.stringProperties.autoTracker.dragToSelectStringProperty, + dragToSelectStringProperty: this.stringProperties.autoTracker.dragToSelectStringProperty, }; } @@ -250,11 +223,9 @@ export class StringManager { return { titleStringProperty: this.stringProperties.dataTable.titleStringProperty, csvStringProperty: this.stringProperties.dataTable.csvStringProperty, - noDataStringProperty: - this.stringProperties.dataTable.noDataStringProperty, + noDataStringProperty: this.stringProperties.dataTable.noDataStringProperty, frameStringProperty: this.stringProperties.dataTable.frameStringProperty, - timeSecondsStringProperty: - this.stringProperties.dataTable.timeSecondsStringProperty, + timeSecondsStringProperty: this.stringProperties.dataTable.timeSecondsStringProperty, }; } @@ -266,10 +237,8 @@ export class StringManager { tracksStringProperty: ReadOnlyProperty; } { return { - addTrackStringProperty: - this.stringProperties.trackList.addTrackStringProperty, - tracksStringProperty: - this.stringProperties.trackList.tracksStringProperty, + addTrackStringProperty: this.stringProperties.trackList.addTrackStringProperty, + tracksStringProperty: this.stringProperties.trackList.tracksStringProperty, }; } @@ -282,8 +251,7 @@ export class StringManager { } { return { fpsStringProperty: this.stringProperties.ui.fpsStringProperty, - selectVideoStringProperty: - this.stringProperties.ui.selectVideoStringProperty, + selectVideoStringProperty: this.stringProperties.ui.selectVideoStringProperty, }; } @@ -295,10 +263,8 @@ export class StringManager { yAxisLabelStringProperty: ReadOnlyProperty; } { return { - xAxisLabelStringProperty: - this.stringProperties.coordSystem.xAxisLabelStringProperty, - yAxisLabelStringProperty: - this.stringProperties.coordSystem.yAxisLabelStringProperty, + xAxisLabelStringProperty: this.stringProperties.coordSystem.xAxisLabelStringProperty, + yAxisLabelStringProperty: this.stringProperties.coordSystem.yAxisLabelStringProperty, }; } @@ -313,16 +279,11 @@ export class StringManager { recordingStringProperty: ReadOnlyProperty; } { return { - requestingAccessStringProperty: - this.stringProperties.webcam.requestingAccessStringProperty, - accessDeniedStringProperty: - this.stringProperties.webcam.accessDeniedStringProperty, - processingStringProperty: - this.stringProperties.webcam.processingStringProperty, - fixingMetadataStringProperty: - this.stringProperties.webcam.fixingMetadataStringProperty, - recordingStringProperty: - this.stringProperties.webcam.recordingStringProperty, + requestingAccessStringProperty: this.stringProperties.webcam.requestingAccessStringProperty, + accessDeniedStringProperty: this.stringProperties.webcam.accessDeniedStringProperty, + processingStringProperty: this.stringProperties.webcam.processingStringProperty, + fixingMetadataStringProperty: this.stringProperties.webcam.fixingMetadataStringProperty, + recordingStringProperty: this.stringProperties.webcam.recordingStringProperty, }; } @@ -341,24 +302,15 @@ export class StringManager { springWarsStringProperty: ReadOnlyProperty; } { return { - ballOilStringProperty: - this.stringProperties.videoFiles.ballOilStringProperty, - bouncingCartStringProperty: - this.stringProperties.videoFiles.bouncingCartStringProperty, - cartPendulumStringProperty: - this.stringProperties.videoFiles.cartPendulumStringProperty, - cupsClipsStringProperty: - this.stringProperties.videoFiles.cupsClipsStringProperty, - parachuteMonkeyStringProperty: - this.stringProperties.videoFiles.parachuteMonkeyStringProperty, - pendulumStringProperty: - this.stringProperties.videoFiles.pendulumStringProperty, - pendulumDragStringProperty: - this.stringProperties.videoFiles.pendulumDragStringProperty, - pucksCollideStringProperty: - this.stringProperties.videoFiles.pucksCollideStringProperty, - springWarsStringProperty: - this.stringProperties.videoFiles.springWarsStringProperty, + ballOilStringProperty: this.stringProperties.videoFiles.ballOilStringProperty, + bouncingCartStringProperty: this.stringProperties.videoFiles.bouncingCartStringProperty, + cartPendulumStringProperty: this.stringProperties.videoFiles.cartPendulumStringProperty, + cupsClipsStringProperty: this.stringProperties.videoFiles.cupsClipsStringProperty, + parachuteMonkeyStringProperty: this.stringProperties.videoFiles.parachuteMonkeyStringProperty, + pendulumStringProperty: this.stringProperties.videoFiles.pendulumStringProperty, + pendulumDragStringProperty: this.stringProperties.videoFiles.pendulumDragStringProperty, + pucksCollideStringProperty: this.stringProperties.videoFiles.pucksCollideStringProperty, + springWarsStringProperty: this.stringProperties.videoFiles.springWarsStringProperty, }; } } diff --git a/src/main.ts b/src/main.ts index 1d9c1b1..73d3a5a 100644 --- a/src/main.ts +++ b/src/main.ts @@ -35,18 +35,13 @@ onReadyToLaunch(() => { simulationOptions: { customPreferences: [ { - createContent: (_tandem: Tandem) => - new TrackLabPreferencesNode(trackLabPreferences), + createContent: (_tandem: Tandem) => new TrackLabPreferencesNode(trackLabPreferences), }, ], }, }), }; - const sim = new Sim( - stringManager.getTitleStringProperty(), - screens, - simOptions, - ); + const sim = new Sim(stringManager.getTitleStringProperty(), screens, simOptions); sim.start(); }); diff --git a/src/screen-name/graph/AxisGestureHandler.ts b/src/screen-name/graph/AxisGestureHandler.ts index ef0af54..50541ef 100644 --- a/src/screen-name/graph/AxisGestureHandler.ts +++ b/src/screen-name/graph/AxisGestureHandler.ts @@ -41,11 +41,7 @@ export default class AxisGestureHandler { private readonly zoomFactor: number = 1.1; - public constructor( - chartConfig: ChartConfig, - regions: AxisInteractionRegions, - dimensions: GraphDimensions, - ) { + public constructor(chartConfig: ChartConfig, regions: AxisInteractionRegions, dimensions: GraphDimensions) { this.chartTransform = chartConfig.chartTransform; this.chartRectangle = chartConfig.chartRectangle; this.dataManager = chartConfig.dataManager; @@ -76,30 +72,19 @@ export default class AxisGestureHandler { private setupAxisControls(axis: "x" | "y"): void { const isX = axis === "x"; - const region = isX - ? this.xAxisInteractionRegion - : this.yAxisInteractionRegion; + const region = isX ? this.xAxisInteractionRegion : this.yAxisInteractionRegion; // Read the current model range for this axis. - const getRange = (): Range => - isX - ? this.chartTransform.modelXRange - : this.chartTransform.modelYRange; + const getRange = (): Range => (isX ? this.chartTransform.modelXRange : this.chartTransform.modelYRange); // Apply a new range for this axis and update tick spacing. const setRange = (range: Range): void => { if (isX) { this.chartTransform.setModelXRange(range); - this.dataManager.updateTickSpacing( - range, - this.chartTransform.modelYRange, - ); + this.dataManager.updateTickSpacing(range, this.chartTransform.modelYRange); } else { this.chartTransform.setModelYRange(range); - this.dataManager.updateTickSpacing( - this.chartTransform.modelXRange, - range, - ); + this.dataManager.updateTickSpacing(this.chartTransform.modelXRange, range); } }; @@ -131,7 +116,9 @@ export default class AxisGestureHandler { region.addInputListener({ down: (event) => { - if (event.pointer.type !== "touch") return; + if (event.pointer.type !== "touch") { + return; + } const pt = event.pointer.point; activePointers.set(event.pointer, pt); @@ -154,54 +141,35 @@ export default class AxisGestureHandler { }, move: (event) => { - if ( - event.pointer.type !== "touch" || - !activePointers.has(event.pointer) - ) + if (event.pointer.type !== "touch" || !activePointers.has(event.pointer)) { return; + } const pt = event.pointer.point; activePointers.set(event.pointer, pt); - if ( - activePointers.size === 1 && - singleTouchStart !== null && - initialRange - ) { + if (activePointers.size === 1 && singleTouchStart !== null && initialRange) { // Single touch: pan. Negate so content follows the finger on both axes. const axisSize = isX ? this.graphWidth : this.graphHeight; - const modelDelta = - -(coord(pt) - singleTouchStart) * - (initialRange.getLength() / axisSize); - setRange( - new Range( - initialRange.min + modelDelta, - initialRange.max + modelDelta, - ), - ); - } else if ( - activePointers.size === 2 && - initialPinchDistance && - initialPinchMidpoint !== null && - initialRange - ) { + const modelDelta = -(coord(pt) - singleTouchStart) * (initialRange.getLength() / axisSize); + setRange(new Range(initialRange.min + modelDelta, initialRange.max + modelDelta)); + } else if (activePointers.size === 2 && initialPinchDistance && initialPinchMidpoint !== null && initialRange) { // Two-finger pinch: zoom this axis only, centered on the pinch midpoint. const points = Array.from(activePointers.values()); const p0 = points[0]; const p1 = points[1]; - if (!p0 || !p1) return; + if (!(p0 && p1)) { + return; + } - const zoomFactor = - initialPinchDistance / Math.abs(coord(p0) - coord(p1)); + const zoomFactor = initialPinchDistance / Math.abs(coord(p0) - coord(p1)); // Build a view midpoint at the pinch centre; use the graph centre on // the perpendicular axis so the localToModel conversion is correct. const viewMidpoint = isX ? new Vector2(initialPinchMidpoint, this.graphHeight / 2) : new Vector2(this.graphWidth / 2, initialPinchMidpoint); - const localMidpoint = - this.chartRectangle.globalToLocalPoint(viewMidpoint); - const modelPos = - this.chartTransform.viewToModelPosition(localMidpoint); + const localMidpoint = this.chartRectangle.globalToLocalPoint(viewMidpoint); + const modelPos = this.chartTransform.viewToModelPosition(localMidpoint); const modelCenter = isX ? modelPos.x : modelPos.y; setRange( @@ -214,7 +182,9 @@ export default class AxisGestureHandler { }, up: (event) => { - if (event.pointer.type !== "touch") return; + if (event.pointer.type !== "touch") { + return; + } activePointers.delete(event.pointer); if (activePointers.size < 2) { initialPinchDistance = null; @@ -227,7 +197,9 @@ export default class AxisGestureHandler { }, cancel: (event) => { - if (event.pointer.type !== "touch") return; + if (event.pointer.type !== "touch") { + return; + } activePointers.delete(event.pointer); if (activePointers.size < 2) { initialPinchDistance = null; @@ -261,19 +233,15 @@ export default class AxisGestureHandler { }, drag: (event) => { - if (mouseDragStart === null || !mouseDragInitialRange) return; + if (mouseDragStart === null || !mouseDragInitialRange) { + return; + } const axisSize = isX ? this.graphWidth : this.graphHeight; const delta = coord(event.pointer.point) - mouseDragStart; // X: negate (screen X and model X share direction; negation makes content follow drag). // Y: keep positive (screen Y is inverted from model Y; signs cancel, content follows drag). - const modelDelta = - (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize); - setRange( - new Range( - mouseDragInitialRange.min + modelDelta, - mouseDragInitialRange.max + modelDelta, - ), - ); + const modelDelta = (isX ? -1 : 1) * delta * (mouseDragInitialRange.getLength() / axisSize); + setRange(new Range(mouseDragInitialRange.min + modelDelta, mouseDragInitialRange.max + modelDelta)); }, end: () => { @@ -305,15 +273,12 @@ export default class AxisGestureHandler { const viewMidpoint = isX ? new Vector2(mouseCoord, this.graphHeight / 2) : new Vector2(this.graphWidth / 2, mouseCoord); - const localMidpoint = - this.chartRectangle.globalToLocalPoint(viewMidpoint); - const modelPos = - this.chartTransform.viewToModelPosition(localMidpoint); + const localMidpoint = this.chartRectangle.globalToLocalPoint(viewMidpoint); + const modelPos = this.chartTransform.viewToModelPosition(localMidpoint); const modelCenter = isX ? modelPos.x : modelPos.y; const currentRange = getRange(); - const zoomFactor = - delta < 0 ? this.zoomFactor : 1 / this.zoomFactor; + const zoomFactor = delta < 0 ? this.zoomFactor : 1 / this.zoomFactor; setRange( new Range( diff --git a/src/screen-name/graph/ConfigurableGraph.ts b/src/screen-name/graph/ConfigurableGraph.ts index 0e74b2f..f7b6a17 100644 --- a/src/screen-name/graph/ConfigurableGraph.ts +++ b/src/screen-name/graph/ConfigurableGraph.ts @@ -3,29 +3,12 @@ * This provides a flexible way to explore relationships between any two quantities. */ -import { - BooleanProperty, - Property, - type TReadOnlyProperty, -} from "scenerystack/axon"; -import { - ChartRectangle, - ChartTransform, - GridLineSet, - LinePlot, - TickLabelSet, - TickMarkSet, -} from "scenerystack/bamboo"; +import { BooleanProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { ChartRectangle, ChartTransform, GridLineSet, LinePlot, TickLabelSet, TickMarkSet } from "scenerystack/bamboo"; import { Range } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; import { Orientation } from "scenerystack/phet-core"; -import { - FireListener, - HBox, - Node, - Rectangle, - Text, -} from "scenerystack/scenery"; +import { FireListener, HBox, Node, Rectangle, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import TrackLabColors from "../../TrackLabColors.js"; import trackLab from "../../TrackLabNamespace.js"; @@ -58,11 +41,6 @@ const BUTTON_FONT = new PhetFont({ size: 14, weight: "bold" }); const BUTTON_HOVER_OPACITY = 0.8; const TITLE_BOTTOM_OFFSET = -5; -// Resize handle (reserved for future use) -const _RESIZE_HANDLE_SIZE = 16; -const _RESIZE_DOT_RADIUS = 2; -const _RESIZE_DOT_SPACING = 5; - export default class ConfigurableGraph extends Node { private readonly xPropertyProperty: Property; private readonly yPropertyProperty: Property; @@ -120,7 +98,7 @@ export default class ConfigurableGraph extends Node { initialYProperty: PlottableProperty, width: number, height: number, - maxDataPoints: number = 2000, + maxDataPoints: number, listParent: Node, dragTargetNode?: Node, ) { @@ -164,84 +142,52 @@ export default class ConfigurableGraph extends Node { this.graphContentNode.addChild(chartRectangle); // Create grid lines, tick marks, and tick labels - const initialSpacing = GraphDataManager.calculateTickSpacing( - initialRange.getLength(), - ); + const initialSpacing = GraphDataManager.calculateTickSpacing(initialRange.getLength()); - const verticalGridLineSet = new GridLineSet( - this.chartTransform, - Orientation.VERTICAL, - initialSpacing, - { - stroke: TrackLabColors.gridLinesProperty, - lineWidth: GRID_LINE_WIDTH, - }, - ); + const verticalGridLineSet = new GridLineSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { + stroke: TrackLabColors.gridLinesProperty, + lineWidth: GRID_LINE_WIDTH, + }); this.graphContentNode.addChild(verticalGridLineSet); - const horizontalGridLineSet = new GridLineSet( - this.chartTransform, - Orientation.HORIZONTAL, - initialSpacing, - { - stroke: TrackLabColors.gridLinesProperty, - lineWidth: GRID_LINE_WIDTH, - }, - ); + const horizontalGridLineSet = new GridLineSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { + stroke: TrackLabColors.gridLinesProperty, + lineWidth: GRID_LINE_WIDTH, + }); this.graphContentNode.addChild(horizontalGridLineSet); - const xTickMarkSet = new TickMarkSet( - this.chartTransform, - Orientation.HORIZONTAL, - initialSpacing, - { - edge: "min", - extent: TICK_EXTENT, - stroke: TrackLabColors.controlPanelStrokeProperty, - }, - ); + const xTickMarkSet = new TickMarkSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { + edge: "min", + extent: TICK_EXTENT, + stroke: TrackLabColors.controlPanelStrokeProperty, + }); this.graphContentNode.addChild(xTickMarkSet); - const yTickMarkSet = new TickMarkSet( - this.chartTransform, - Orientation.VERTICAL, - initialSpacing, - { - edge: "min", - extent: TICK_EXTENT, - stroke: TrackLabColors.controlPanelStrokeProperty, - }, - ); + const yTickMarkSet = new TickMarkSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { + edge: "min", + extent: TICK_EXTENT, + stroke: TrackLabColors.controlPanelStrokeProperty, + }); this.graphContentNode.addChild(yTickMarkSet); - const xTickLabelSet = new TickLabelSet( - this.chartTransform, - Orientation.HORIZONTAL, - initialSpacing, - { - edge: "min", - createLabel: (value: number) => - new Text(value.toFixed(TICK_LABEL_DECIMALS), { - font: TICK_LABEL_FONT, - fill: TrackLabColors.textProperty, - }), - }, - ); + const xTickLabelSet = new TickLabelSet(this.chartTransform, Orientation.HORIZONTAL, initialSpacing, { + edge: "min", + createLabel: (value: number) => + new Text(value.toFixed(TICK_LABEL_DECIMALS), { + font: TICK_LABEL_FONT, + fill: TrackLabColors.textProperty, + }), + }); this.graphContentNode.addChild(xTickLabelSet); - const yTickLabelSet = new TickLabelSet( - this.chartTransform, - Orientation.VERTICAL, - initialSpacing, - { - edge: "min", - createLabel: (value: number) => - new Text(value.toFixed(TICK_LABEL_DECIMALS), { - font: TICK_LABEL_FONT, - fill: TrackLabColors.textProperty, - }), - }, - ); + const yTickLabelSet = new TickLabelSet(this.chartTransform, Orientation.VERTICAL, initialSpacing, { + edge: "min", + createLabel: (value: number) => + new Text(value.toFixed(TICK_LABEL_DECIMALS), { + font: TICK_LABEL_FONT, + fill: TrackLabColors.textProperty, + }), + }); this.graphContentNode.addChild(yTickLabelSet); // Create invisible interaction regions for axis controls @@ -251,29 +197,17 @@ export default class ConfigurableGraph extends Node { const axisInteractionHeight = X_AXIS_INTERACTION_HEIGHT; // Y-axis interaction region (left side of graph, covering full height) - this.yAxisInteractionRegion = new Rectangle( - -axisInteractionWidth, - 0, - axisInteractionWidth, - height, - { - fill: "transparent", - pickable: true, - }, - ); + this.yAxisInteractionRegion = new Rectangle(-axisInteractionWidth, 0, axisInteractionWidth, height, { + fill: "transparent", + pickable: true, + }); this.graphContentNode.addChild(this.yAxisInteractionRegion); // X-axis interaction region (bottom of graph, covering full width) - this.xAxisInteractionRegion = new Rectangle( - 0, - height, - width, - axisInteractionHeight, - { - fill: "transparent", - pickable: true, - }, - ); + this.xAxisInteractionRegion = new Rectangle(0, height, width, axisInteractionHeight, { + fill: "transparent", + pickable: true, + }); this.graphContentNode.addChild(this.xAxisInteractionRegion); // Create line plot @@ -308,19 +242,14 @@ export default class ConfigurableGraph extends Node { this.graphContentNode.addChild(this.yAxisLabelNode); // Initialize data manager - this.dataManager = new GraphDataManager( - this.chartTransform, - linePlot, - maxDataPoints, - { - verticalGridLineSet, - horizontalGridLineSet, - xTickMarkSet, - yTickMarkSet, - xTickLabelSet, - yTickLabelSet, - }, - ); + this.dataManager = new GraphDataManager(this.chartTransform, linePlot, maxDataPoints, { + verticalGridLineSet, + horizontalGridLineSet, + xTickMarkSet, + yTickMarkSet, + xTickLabelSet, + yTickLabelSet, + }); // Create controls panel helper const controlsPanel = new GraphControlsPanel( @@ -349,19 +278,11 @@ export default class ConfigurableGraph extends Node { fill: TrackLabColors.controlPanelStrokeProperty, }); - const buttonBackground = new Rectangle( - 0, - 0, - buttonSize, - buttonSize, - BUTTON_CORNER_RADIUS, - BUTTON_CORNER_RADIUS, - { - fill: TrackLabColors.controlPanelFillProperty, - stroke: TrackLabColors.controlPanelStrokeProperty, - cursor: "pointer", - }, - ); + const buttonBackground = new Rectangle(0, 0, buttonSize, buttonSize, BUTTON_CORNER_RADIUS, BUTTON_CORNER_RADIUS, { + fill: TrackLabColors.controlPanelFillProperty, + stroke: TrackLabColors.controlPanelStrokeProperty, + cursor: "pointer", + }); const button = new Node({ children: [buttonBackground, buttonText], @@ -425,15 +346,7 @@ export default class ConfigurableGraph extends Node { // Create HBox to hold all buttons const controlButtonsPanel = new HBox({ - children: [ - rescaleButton, - zoomInButton, - zoomOutButton, - panLeftButton, - panRightButton, - panUpButton, - panDownButton, - ], + children: [rescaleButton, zoomInButton, zoomOutButton, panLeftButton, panRightButton, panUpButton, panDownButton], spacing: buttonSpacing, left: buttonPadding, top: buttonPadding, @@ -551,10 +464,10 @@ export default class ConfigurableGraph extends Node { /** * Get the string value from a unit (which can be string or TReadOnlyProperty) */ - private getUnitValue( - unit: string | TReadOnlyProperty | undefined, - ): string | undefined { - if (unit === undefined) return undefined; + private getUnitValue(unit: string | TReadOnlyProperty | undefined): string | undefined { + if (unit === undefined) { + return undefined; + } return typeof unit === "string" ? unit : unit.value; } @@ -590,18 +503,8 @@ export default class ConfigurableGraph extends Node { // Update invisible interaction regions const axisInteractionWidth = 60; const axisInteractionHeight = 30; - this.yAxisInteractionRegion.setRect( - -axisInteractionWidth, - 0, - axisInteractionWidth, - newHeight, - ); - this.xAxisInteractionRegion.setRect( - 0, - newHeight, - newWidth, - axisInteractionHeight, - ); + this.yAxisInteractionRegion.setRect(-axisInteractionWidth, 0, axisInteractionWidth, newHeight); + this.xAxisInteractionRegion.setRect(0, newHeight, newWidth, axisInteractionHeight); // Update axis labels positions this.xAxisLabelNode.centerX = newWidth / 2; @@ -627,7 +530,9 @@ export default class ConfigurableGraph extends Node { * Add data points from a record array, mapping each record to the selected axes. */ public addDataPoints(dataPoints: Array>): void { - if (dataPoints.length === 0) return; + if (dataPoints.length === 0) { + return; + } const xProperty = this.xPropertyProperty.value; const yProperty = this.yPropertyProperty.value; @@ -651,10 +556,7 @@ export default class ConfigurableGraph extends Node { * Dispatches on the PlottableProperty variant: RecordPlottable uses an * accessor function; LivePlottable reads from a reactive property. */ - private getValueForAxis( - axisProperty: PlottableProperty, - point: Record, - ): number | null { + private getValueForAxis(axisProperty: PlottableProperty, point: Record): number | null { if ("accessor" in axisProperty) { return axisProperty.accessor(point); } @@ -665,12 +567,8 @@ export default class ConfigurableGraph extends Node { * Update the axis labels (call when units change) */ public updateAxisLabels(): void { - this.xAxisLabelNode.string = this.formatAxisLabel( - this.xPropertyProperty.value, - ); - this.yAxisLabelNode.string = this.formatAxisLabel( - this.yPropertyProperty.value, - ); + this.xAxisLabelNode.string = this.formatAxisLabel(this.xPropertyProperty.value); + this.yAxisLabelNode.string = this.formatAxisLabel(this.yPropertyProperty.value); } /** @@ -721,10 +619,7 @@ export default class ConfigurableGraph extends Node { this.graphVisibleProperty.reset(); // Reset graph size to initial dimensions if it has been resized - if ( - this.graphWidth !== this.initialWidth || - this.graphHeight !== this.initialHeight - ) { + if (this.graphWidth !== this.initialWidth || this.graphHeight !== this.initialHeight) { this.resizeGraph(this.initialWidth, this.initialHeight); } diff --git a/src/screen-name/graph/GraphControlsPanel.ts b/src/screen-name/graph/GraphControlsPanel.ts index ba99f67..17b7ef5 100644 --- a/src/screen-name/graph/GraphControlsPanel.ts +++ b/src/screen-name/graph/GraphControlsPanel.ts @@ -4,11 +4,7 @@ * - Header bar */ -import { - DerivedProperty, - type Property, - type TReadOnlyProperty, -} from "scenerystack/axon"; +import { DerivedProperty, type Property, type TReadOnlyProperty } from "scenerystack/axon"; import { HBox, type Node, Rectangle, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { ComboBox } from "scenerystack/sun"; @@ -118,10 +114,7 @@ export default class GraphControlsPanel { }); const vsText = new Text( - new DerivedProperty( - [StringManager.getInstance().getControls().graphVsStringProperty], - (vs: string) => ` ${vs} `, - ), + new DerivedProperty([StringManager.getInstance().getControls().graphVsStringProperty], (vs: string) => ` ${vs} `), { font: TITLE_FONT, fill: TrackLabColors.textProperty, @@ -146,10 +139,8 @@ export default class GraphControlsPanel { */ public createHeaderBar(): Rectangle { // Create header bar with dynamic fill that darkens the control panel background - const headerFillProperty = new DerivedProperty( - [TrackLabColors.controlPanelFillProperty], - (backgroundColor) => - backgroundColor.colorUtilsDarker(HEADER_DARKEN_FACTOR), + const headerFillProperty = new DerivedProperty([TrackLabColors.controlPanelFillProperty], (backgroundColor) => + backgroundColor.colorUtilsDarker(HEADER_DARKEN_FACTOR), ); const headerBar = new Rectangle( 0, @@ -173,10 +164,7 @@ export default class GraphControlsPanel { /** * Update header bar width when graph is resized */ - public static updateHeaderBarWidth( - headerBar: Rectangle, - newWidth: number, - ): void { + public static updateHeaderBarWidth(headerBar: Rectangle, newWidth: number): void { headerBar.setRect(0, -HEADER_HEIGHT, newWidth, HEADER_HEIGHT); } } diff --git a/src/screen-name/graph/GraphDataManager.ts b/src/screen-name/graph/GraphDataManager.ts index adc9fa9..0443afb 100644 --- a/src/screen-name/graph/GraphDataManager.ts +++ b/src/screen-name/graph/GraphDataManager.ts @@ -3,13 +3,7 @@ * Handles auto-scaling and tick spacing calculations. */ -import type { - ChartTransform, - GridLineSet, - LinePlot, - TickLabelSet, - TickMarkSet, -} from "scenerystack/bamboo"; +import type { ChartTransform, GridLineSet, LinePlot, TickLabelSet, TickMarkSet } from "scenerystack/bamboo"; import { Range, Vector2 } from "scenerystack/dot"; import trackLab from "../../TrackLabNamespace.js"; @@ -62,7 +56,7 @@ export default class GraphDataManager { */ public addDataPoint(xValue: number, yValue: number): void { // Skip invalid values - if (!Number.isFinite(xValue) || !Number.isFinite(yValue)) { + if (!(Number.isFinite(xValue) && Number.isFinite(yValue))) { return; } @@ -89,7 +83,9 @@ export default class GraphDataManager { * @param points - Array of x/y value pairs */ public addDataPoints(points: Array<{ x: number; y: number }>): void { - if (points.length === 0) return; + if (points.length === 0) { + return; + } // Add all valid points for (const { x, y } of points) { diff --git a/src/screen-name/graph/GraphInteractionHandler.ts b/src/screen-name/graph/GraphInteractionHandler.ts index 41f0a22..43a9d32 100644 --- a/src/screen-name/graph/GraphInteractionHandler.ts +++ b/src/screen-name/graph/GraphInteractionHandler.ts @@ -11,18 +11,11 @@ */ import type { BooleanProperty } from "scenerystack/axon"; -import type { - ChartRectangle, - ChartTransform, - TickLabelSet, -} from "scenerystack/bamboo"; -import { - type Node, - Rectangle, -} from "scenerystack/scenery"; +import type { ChartRectangle, ChartTransform, TickLabelSet } from "scenerystack/bamboo"; +import type { Node, Rectangle } from "scenerystack/scenery"; import trackLab from "../../TrackLabNamespace.js"; -import type GraphDataManager from "./GraphDataManager.js"; import AxisGestureHandler from "./AxisGestureHandler.js"; +import type GraphDataManager from "./GraphDataManager.js"; import HeaderDragHandler from "./HeaderDragHandler.js"; import PanGestureHandler from "./PanGestureHandler.js"; import ResizeGestureHandler from "./ResizeGestureHandler.js"; diff --git a/src/screen-name/graph/HeaderDragHandler.ts b/src/screen-name/graph/HeaderDragHandler.ts index afddbe5..9780868 100644 --- a/src/screen-name/graph/HeaderDragHandler.ts +++ b/src/screen-name/graph/HeaderDragHandler.ts @@ -14,7 +14,7 @@ export interface HeaderDragElements { headerBar: Rectangle; graphNode: Node; /** If provided, this node is moved instead of graphNode. */ - dragTargetNode?: Node; + dragTargetNode?: Node | undefined; } export default class HeaderDragHandler { @@ -22,10 +22,7 @@ export default class HeaderDragHandler { private readonly dragTargetNode: Node; private readonly isDraggingProperty: BooleanProperty; - public constructor( - elements: HeaderDragElements, - isDraggingProperty: BooleanProperty, - ) { + public constructor(elements: HeaderDragElements, isDraggingProperty: BooleanProperty) { this.headerBar = elements.headerBar; this.dragTargetNode = elements.dragTargetNode ?? elements.graphNode; this.isDraggingProperty = isDraggingProperty; @@ -47,16 +44,15 @@ export default class HeaderDragHandler { const dragListener = new DragListener({ start: (event) => { - dragStartPosition = new Vector2( - this.dragTargetNode.x, - this.dragTargetNode.y, - ); + dragStartPosition = new Vector2(this.dragTargetNode.x, this.dragTargetNode.y); dragStartPointerPoint = event.pointer.point.copy(); this.isDraggingProperty.value = true; }, drag: (event) => { - if (!dragStartPosition || !dragStartPointerPoint) return; + if (!(dragStartPosition && dragStartPointerPoint)) { + return; + } const delta = event.pointer.point.minus(dragStartPointerPoint); this.dragTargetNode.x = dragStartPosition.x + delta.x; this.dragTargetNode.y = dragStartPosition.y + delta.y; diff --git a/src/screen-name/graph/PanGestureHandler.ts b/src/screen-name/graph/PanGestureHandler.ts index e0fdb83..8c98993 100644 --- a/src/screen-name/graph/PanGestureHandler.ts +++ b/src/screen-name/graph/PanGestureHandler.ts @@ -5,7 +5,7 @@ */ import type { ChartRectangle, ChartTransform } from "scenerystack/bamboo"; -import { Range, Vector2 } from "scenerystack/dot"; +import { Range, type Vector2 } from "scenerystack/dot"; import { DragListener } from "scenerystack/scenery"; import trackLab from "../../TrackLabNamespace.js"; import type GraphDataManager from "./GraphDataManager.js"; @@ -48,28 +48,16 @@ export default class PanGestureHandler { switch (direction) { case "left": - newXRange = new Range( - currentXRange.min - xDelta, - currentXRange.max - xDelta, - ); + newXRange = new Range(currentXRange.min - xDelta, currentXRange.max - xDelta); break; case "right": - newXRange = new Range( - currentXRange.min + xDelta, - currentXRange.max + xDelta, - ); + newXRange = new Range(currentXRange.min + xDelta, currentXRange.max + xDelta); break; case "up": - newYRange = new Range( - currentYRange.min + yDelta, - currentYRange.max + yDelta, - ); + newYRange = new Range(currentYRange.min + yDelta, currentYRange.max + yDelta); break; case "down": - newYRange = new Range( - currentYRange.min - yDelta, - currentYRange.max - yDelta, - ); + newYRange = new Range(currentYRange.min - yDelta, currentYRange.max - yDelta); break; } @@ -87,11 +75,8 @@ export default class PanGestureHandler { const dragListener = new DragListener({ start: (event) => { - const viewPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - dragStartModelPoint = - this.chartTransform.viewToModelPosition(viewPoint); + const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point); + dragStartModelPoint = this.chartTransform.viewToModelPosition(viewPoint); dragStartXRange = this.chartTransform.modelXRange.copy(); dragStartYRange = this.chartTransform.modelYRange.copy(); @@ -100,26 +85,18 @@ export default class PanGestureHandler { }, drag: (event) => { - if (!dragStartModelPoint || !dragStartXRange || !dragStartYRange) + if (!(dragStartModelPoint && dragStartXRange && dragStartYRange)) { return; + } - const viewPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); - const currentModelPoint = - this.chartTransform.viewToModelPosition(viewPoint); + const viewPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point); + const currentModelPoint = this.chartTransform.viewToModelPosition(viewPoint); const deltaX = dragStartModelPoint.x - currentModelPoint.x; const deltaY = dragStartModelPoint.y - currentModelPoint.y; - const newXRange = new Range( - dragStartXRange.min + deltaX, - dragStartXRange.max + deltaX, - ); - const newYRange = new Range( - dragStartYRange.min + deltaY, - dragStartYRange.max + deltaY, - ); + const newXRange = new Range(dragStartXRange.min + deltaX, dragStartXRange.max + deltaX); + const newYRange = new Range(dragStartYRange.min + deltaY, dragStartYRange.max + deltaY); this.chartTransform.setModelXRange(newXRange); this.chartTransform.setModelYRange(newYRange); diff --git a/src/screen-name/graph/ResizeGestureHandler.ts b/src/screen-name/graph/ResizeGestureHandler.ts index 08e4487..eb42211 100644 --- a/src/screen-name/graph/ResizeGestureHandler.ts +++ b/src/screen-name/graph/ResizeGestureHandler.ts @@ -9,7 +9,7 @@ */ import type { BooleanProperty } from "scenerystack/axon"; -import { Vector2 } from "scenerystack/dot"; +import type { Vector2 } from "scenerystack/dot"; import { DragListener, type Node, Rectangle } from "scenerystack/scenery"; import TrackLabColors from "../../TrackLabColors.js"; import trackLab from "../../TrackLabNamespace.js"; @@ -66,20 +66,12 @@ export default class ResizeGestureHandler { ]; corners.forEach((corner, index) => { - const handle = new Rectangle( - corner.x + HANDLE_OFFSET, - corner.y + HANDLE_OFFSET, - HANDLE_SIZE, - HANDLE_SIZE, - 2, - 2, - { - fill: TrackLabColors.controlPanelFillProperty, - stroke: TrackLabColors.controlPanelStrokeProperty, - lineWidth: 2, - cursor: corner.cursor, - }, - ); + const handle = new Rectangle(corner.x + HANDLE_OFFSET, corner.y + HANDLE_OFFSET, HANDLE_SIZE, HANDLE_SIZE, 2, 2, { + fill: TrackLabColors.controlPanelFillProperty, + stroke: TrackLabColors.controlPanelStrokeProperty, + lineWidth: 2, + cursor: corner.cursor, + }); this.handles.push(handle); this.attachDragListener(handle, index); @@ -103,12 +95,7 @@ export default class ResizeGestureHandler { this.handles.forEach((handle, index) => { const corner = corners[index]; if (corner) { - handle.setRect( - corner.x + HANDLE_OFFSET, - corner.y + HANDLE_OFFSET, - HANDLE_SIZE, - HANDLE_SIZE, - ); + handle.setRect(corner.x + HANDLE_OFFSET, corner.y + HANDLE_OFFSET, HANDLE_SIZE, HANDLE_SIZE); } }); } @@ -137,7 +124,9 @@ export default class ResizeGestureHandler { }, drag: (event) => { - if (!dragStartGraphBounds || !dragStartPointerPoint) return; + if (!(dragStartGraphBounds && dragStartPointerPoint)) { + return; + } const delta = event.pointer.point.minus(dragStartPointerPoint); let newWidth = dragStartGraphBounds.width; @@ -148,35 +137,23 @@ export default class ResizeGestureHandler { switch (cornerIndex) { case 0: // Top-left newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width - delta.x); - newHeight = Math.max( - MIN_HEIGHT, - dragStartGraphBounds.height - delta.y, - ); + newHeight = Math.max(MIN_HEIGHT, dragStartGraphBounds.height - delta.y); deltaX = dragStartGraphBounds.width - newWidth; deltaY = dragStartGraphBounds.height - newHeight; break; case 1: // Top-right newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width + delta.x); - newHeight = Math.max( - MIN_HEIGHT, - dragStartGraphBounds.height - delta.y, - ); + newHeight = Math.max(MIN_HEIGHT, dragStartGraphBounds.height - delta.y); deltaY = dragStartGraphBounds.height - newHeight; break; case 2: // Bottom-left newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width - delta.x); - newHeight = Math.max( - MIN_HEIGHT, - dragStartGraphBounds.height + delta.y, - ); + newHeight = Math.max(MIN_HEIGHT, dragStartGraphBounds.height + delta.y); deltaX = dragStartGraphBounds.width - newWidth; break; case 3: // Bottom-right newWidth = Math.max(MIN_WIDTH, dragStartGraphBounds.width + delta.x); - newHeight = Math.max( - MIN_HEIGHT, - dragStartGraphBounds.height + delta.y, - ); + newHeight = Math.max(MIN_HEIGHT, dragStartGraphBounds.height + delta.y); break; } diff --git a/src/screen-name/graph/ZoomGestureHandler.ts b/src/screen-name/graph/ZoomGestureHandler.ts index 64d64e7..20c41ca 100644 --- a/src/screen-name/graph/ZoomGestureHandler.ts +++ b/src/screen-name/graph/ZoomGestureHandler.ts @@ -76,9 +76,7 @@ export default class ZoomGestureHandler { wheel: (event) => { event.handle(); const delta = event.domEvent?.deltaY ?? 0; - const pointerPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); + const pointerPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point); if (delta < 0) { this.zoom(this.zoomFactor, pointerPoint); @@ -109,10 +107,10 @@ export default class ZoomGestureHandler { this.chartRectangle.addInputListener({ down: (event) => { - if (event.pointer.type !== "touch") return; - const localPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); + if (event.pointer.type !== "touch") { + return; + } + const localPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point); activePointers.set(event.pointer, localPoint); if (activePointers.size === 2) { @@ -130,58 +128,40 @@ export default class ZoomGestureHandler { }, move: (event) => { - if ( - event.pointer.type !== "touch" || - !activePointers.has(event.pointer) - ) + if (event.pointer.type !== "touch" || !activePointers.has(event.pointer)) { return; + } - const localPoint = this.chartRectangle.globalToLocalPoint( - event.pointer.point, - ); + const localPoint = this.chartRectangle.globalToLocalPoint(event.pointer.point); activePointers.set(event.pointer, localPoint); - if ( - activePointers.size === 2 && - initialDistance && - initialMidpoint && - initialXRange && - initialYRange - ) { + if (activePointers.size === 2 && initialDistance && initialMidpoint && initialXRange && initialYRange) { const points = Array.from(activePointers.values()); const point0 = points[0]; const point1 = points[1]; - if (!point0 || !point1) return; + if (!(point0 && point1)) { + return; + } const currentDistance = point0.distance(point1); const zoomFactor = initialDistance / currentDistance; - const initialModelCenter = - this.chartTransform.viewToModelPosition(initialMidpoint); - - const xMin = - initialModelCenter.x - - (initialModelCenter.x - initialXRange.min) * zoomFactor; - const xMax = - initialModelCenter.x + - (initialXRange.max - initialModelCenter.x) * zoomFactor; - const yMin = - initialModelCenter.y - - (initialModelCenter.y - initialYRange.min) * zoomFactor; - const yMax = - initialModelCenter.y + - (initialYRange.max - initialModelCenter.y) * zoomFactor; + const initialModelCenter = this.chartTransform.viewToModelPosition(initialMidpoint); + + const xMin = initialModelCenter.x - (initialModelCenter.x - initialXRange.min) * zoomFactor; + const xMax = initialModelCenter.x + (initialXRange.max - initialModelCenter.x) * zoomFactor; + const yMin = initialModelCenter.y - (initialModelCenter.y - initialYRange.min) * zoomFactor; + const yMax = initialModelCenter.y + (initialYRange.max - initialModelCenter.y) * zoomFactor; this.chartTransform.setModelXRange(new Range(xMin, xMax)); this.chartTransform.setModelYRange(new Range(yMin, yMax)); - this.dataManager.updateTickSpacing( - this.chartTransform.modelXRange, - this.chartTransform.modelYRange, - ); + this.dataManager.updateTickSpacing(this.chartTransform.modelXRange, this.chartTransform.modelYRange); } }, up: (event) => { - if (event.pointer.type !== "touch") return; + if (event.pointer.type !== "touch") { + return; + } activePointers.delete(event.pointer); if (activePointers.size < 2) { initialDistance = null; @@ -192,7 +172,9 @@ export default class ZoomGestureHandler { }, cancel: (event) => { - if (event.pointer.type !== "touch") return; + if (event.pointer.type !== "touch") { + return; + } activePointers.delete(event.pointer); if (activePointers.size < 2) { initialDistance = null; @@ -213,11 +195,7 @@ export default class ZoomGestureHandler { * @param centerPoint - Zoom anchor in local view coordinates * @param setManualFlag - When true, suppresses subsequent auto-rescaling */ - public zoom( - factor: number, - centerPoint: Vector2, - setManualFlag: boolean = true, - ): void { + public zoom(factor: number, centerPoint: Vector2, setManualFlag: boolean = true): void { if (setManualFlag) { this.dataManager.setManuallyZoomed(true); } diff --git a/src/screen-name/model/KinematicsComputer.ts b/src/screen-name/model/KinematicsComputer.ts index 725fed4..cdbe8da 100644 --- a/src/screen-name/model/KinematicsComputer.ts +++ b/src/screen-name/model/KinematicsComputer.ts @@ -6,7 +6,7 @@ * from Track.ts — no Axon Properties, no SceneryStack dependencies. */ -import type { Track, TrackKinematics, KinematicPoint } from "./Track.js"; +import type { KinematicPoint, Track, TrackKinematics } from "./Track.js"; /** * Scalar finite difference at index i within an array of n values. @@ -27,13 +27,16 @@ function finiteDifference( i: number, n: number, ): number | null { - if (n < 2) return null; - const [prevIdx, nextIdx] = - i === 0 ? [0, 1] : i === n - 1 ? [n - 2, n - 1] : [i - 1, i + 1]; + if (n < 2) { + return null; + } + const [prevIdx, nextIdx] = i === 0 ? [0, 1] : i === n - 1 ? [n - 2, n - 1] : [i - 1, i + 1]; const prev = getValue(prevIdx); const next = getValue(nextIdx); const dt = getTime(nextIdx) - getTime(prevIdx); - if (prev === null || next === null || dt <= 0) return null; + if (prev === null || next === null || dt <= 0) { + return null; + } return (next - prev) / dt; } @@ -66,12 +69,8 @@ export function computeTrackKinematics(track: Track): TrackKinematics { const vyArr = points.map((_, i) => finiteDifference(getY, getTime, i, n)); // Second pass: accelerations via finite difference of velocity - const axArr = points.map((_, i) => - finiteDifference((j) => vxArr[j] ?? null, getTime, i, n), - ); - const ayArr = points.map((_, i) => - finiteDifference((j) => vyArr[j] ?? null, getTime, i, n), - ); + const axArr = points.map((_, i) => finiteDifference((j) => vxArr[j] ?? null, getTime, i, n)); + const ayArr = points.map((_, i) => finiteDifference((j) => vyArr[j] ?? null, getTime, i, n)); const kinematicPoints: KinematicPoint[] = points.map((pt, i) => { const vx = vxArr[i] ?? null; @@ -88,8 +87,7 @@ export function computeTrackKinematics(track: Track): TrackKinematics { speed: vx !== null && vy !== null ? Math.sqrt(vx * vx + vy * vy) : null, ax, ay, - accelerationMagnitude: - ax !== null && ay !== null ? Math.sqrt(ax * ax + ay * ay) : null, + accelerationMagnitude: ax !== null && ay !== null ? Math.sqrt(ax * ax + ay * ay) : null, }; }); diff --git a/src/screen-name/model/ModelViewTransformFactory.ts b/src/screen-name/model/ModelViewTransformFactory.ts index a2df898..8349c89 100644 --- a/src/screen-name/model/ModelViewTransformFactory.ts +++ b/src/screen-name/model/ModelViewTransformFactory.ts @@ -6,11 +6,8 @@ * no SceneryStack UI dependencies. */ -import { Matrix3, Transform3, Vector2 } from "scenerystack/dot"; -import { - MIN_CALIB_DISTANCE, - MIN_PIXEL_DISTANCE, -} from "../../TrackLabConstants.js"; +import { Matrix3, Transform3, type Vector2 } from "scenerystack/dot"; +import { MIN_CALIB_DISTANCE, MIN_PIXEL_DISTANCE } from "../../TrackLabConstants.js"; /** * Builds a Transform3 from the coordinate-system tool and calibration tool. diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index 1636770..73b2cc3 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -1,11 +1,5 @@ -import { - BooleanProperty, - DerivedProperty, - NumberProperty, - Property, - type TReadOnlyProperty, -} from "scenerystack/axon"; -import { Range, Transform3, Vector2 } from "scenerystack/dot"; +import { BooleanProperty, DerivedProperty, NumberProperty, Property, type TReadOnlyProperty } from "scenerystack/axon"; +import { Range, type Transform3, Vector2 } from "scenerystack/dot"; import { TRACK_COLORS } from "../../TrackLabColors.js"; import { CALIB_HALF_LENGTH, @@ -17,9 +11,9 @@ import { VIDEO_WIDTH, } from "../../TrackLabConstants.js"; import { OpenCVTracker } from "../../tracking/OpenCVTracker.js"; -import type { Track, TrackKinematics, TrackPoint } from "./Track.js"; import { computeTrackKinematics } from "./KinematicsComputer.js"; import { buildModelViewTransform } from "./ModelViewTransformFactory.js"; +import type { Track, TrackKinematics, TrackPoint } from "./Track.js"; // ── Calibration unit type ────────────────────────────────────────────────── export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const; @@ -40,14 +34,8 @@ export const PLAYBACK_RATE_RANGE = new Range(0.1, 4); // ── Initial tool positions (view / pixel space) ─────────────────────────── // These default positions are computed from the shared video layout constants. -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 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_LENGTH, 0); const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY(CALIB_HALF_LENGTH, 0); @@ -77,16 +65,15 @@ export class SimModel { // ── Playback speed multiplier (1 = normal, 0.5 = slow, 2 = fast) ──────── // The view maps its TimeSpeed enum to this value; the model stays free of // any scenery-phet dependency. - public readonly playbackRateProperty = new NumberProperty( - DEFAULT_PLAYBACK_RATE, - { - range: PLAYBACK_RATE_RANGE, - }, - ); + public readonly playbackRateProperty = new NumberProperty(DEFAULT_PLAYBACK_RATE, { + range: PLAYBACK_RATE_RANGE, + }); // Derived frame duration for convenience - public readonly frameDurationProperty: TReadOnlyProperty = - new DerivedProperty([this.frameRateProperty], (fps) => 1 / fps); + public readonly frameDurationProperty: TReadOnlyProperty = new DerivedProperty( + [this.frameRateProperty], + (fps) => 1 / fps, + ); // ── OpenCV Tracker (computational service) ──────────────────────────── public readonly tracker = new OpenCVTracker(VIDEO_WIDTH, VIDEO_HEIGHT); @@ -100,54 +87,54 @@ export class SimModel { public readonly autoTrackingProperty = new BooleanProperty(false); // ── Coordinate system tool state (view / pixel space) ───────────────── - public readonly coordOriginProperty = new Property( - COORD_ORIGIN_INITIAL.copy(), - ); + 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 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"); // ── Derived unit strings (for display in graphs and tables) ──────────── - public readonly distanceUnitProperty: TReadOnlyProperty = - new DerivedProperty([this.calibUnitProperty], (unit) => unit); + public readonly distanceUnitProperty: TReadOnlyProperty = new DerivedProperty( + [this.calibUnitProperty], + (unit) => unit, + ); - public readonly velocityUnitProperty: TReadOnlyProperty = - new DerivedProperty([this.calibUnitProperty], (unit) => `${unit}/s`); + public readonly velocityUnitProperty: TReadOnlyProperty = new DerivedProperty( + [this.calibUnitProperty], + (unit) => `${unit}/s`, + ); - public readonly accelerationUnitProperty: TReadOnlyProperty = - new DerivedProperty([this.calibUnitProperty], (unit) => `${unit}/s²`); + public readonly accelerationUnitProperty: TReadOnlyProperty = new DerivedProperty( + [this.calibUnitProperty], + (unit) => `${unit}/s²`, + ); // ── 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), - ); + 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), + ); // When coord system or calibration changes, recompute track points so they // stay at the same pixel positions on the video (invariant under MVT changes). private prevModelViewTransform: Transform3 | null = null; // ── Video loaded (true once a finite-duration video is loaded) ─────────── - public readonly videoLoadedProperty: TReadOnlyProperty = - new DerivedProperty([this.durationProperty], (d) => d > 0); + public readonly videoLoadedProperty: TReadOnlyProperty = new DerivedProperty( + [this.durationProperty], + (d) => d > 0, + ); // ── Manual particle tracks ──────────────────────────────────────────── // INVARIANT: every TrackPoint's (x, y) is expressed in the coordinate @@ -171,23 +158,20 @@ export class SimModel { // point array reference has changed since the last derivation. Because // addPointToTrack() always creates a new points array, reference equality // is sufficient to detect modifications. - private readonly kinematicsCache = new Map< - string, - { points: Track["points"]; kinematics: TrackKinematics } - >(); - - public readonly trackKinematicsProperty: TReadOnlyProperty< - readonly TrackKinematics[] - > = new DerivedProperty([this.tracksProperty], (tracks) => - tracks.map((track) => { - const cached = this.kinematicsCache.get(track.id); - if (cached && cached.points === track.points) { - return cached.kinematics; - } - const kinematics = computeTrackKinematics(track); - this.kinematicsCache.set(track.id, { points: track.points, kinematics }); - return kinematics; - }), + private readonly kinematicsCache = new Map(); + + public readonly trackKinematicsProperty: TReadOnlyProperty = new DerivedProperty( + [this.tracksProperty], + (tracks) => + tracks.map((track) => { + const cached = this.kinematicsCache.get(track.id); + if (cached && cached.points === track.points) { + return cached.kinematics; + } + const kinematics = computeTrackKinematics(track); + this.kinematicsCache.set(track.id, { points: track.points, kinematics }); + return kinematics; + }), ); // Symbols are assigned sequentially (A → Z) and intentionally not reused // after a track is removed. Stable, unique symbols matter for data export @@ -204,24 +188,18 @@ export class SimModel { // infinite recursion: after the clamped value is written, the listener // fires again but finds the condition false and exits. this.coordOriginProperty.lazyLink((pos) => { - const clampedX = Math.max( - COORD_ORIGIN_BOUNDS_MIN_X, - Math.min(COORD_ORIGIN_BOUNDS_MAX_X, pos.x), - ); - const clampedY = Math.max( - COORD_ORIGIN_BOUNDS_MIN_Y, - Math.min(COORD_ORIGIN_BOUNDS_MAX_Y, pos.y), - ); + const clampedX = Math.max(COORD_ORIGIN_BOUNDS_MIN_X, Math.min(COORD_ORIGIN_BOUNDS_MAX_X, pos.x)); + const clampedY = Math.max(COORD_ORIGIN_BOUNDS_MIN_Y, Math.min(COORD_ORIGIN_BOUNDS_MAX_Y, pos.y)); if (clampedX !== pos.x || clampedY !== pos.y) { this.coordOriginProperty.value = pos.copy().setXY(clampedX, clampedY); } }); - this.modelViewTransformProperty.lazyLink((newMVT) => { + this.modelViewTransformProperty.lazyLink((newMvt) => { if (this.prevModelViewTransform !== null) { - this.retransformTrackPoints(this.prevModelViewTransform, newMVT); + this.retransformTrackPoints(this.prevModelViewTransform, newMvt); } - this.prevModelViewTransform = newMVT; + this.prevModelViewTransform = newMvt; }); } @@ -256,18 +234,17 @@ export class SimModel { * Writing raw pixel coordinates or stale model coordinates directly into * `tracksProperty` will silently corrupt the track data. */ - private retransformTrackPoints( - prevMVT: Transform3, - newMVT: Transform3, - ): void { + private retransformTrackPoints(prevMvt: Transform3, newMvt: Transform3): void { const tracks = this.tracksProperty.value; - if (tracks.length === 0) return; + if (tracks.length === 0) { + return; + } this.tracksProperty.value = tracks.map((track) => ({ ...track, points: track.points.map((pt) => { - const pixelPos = prevMVT.transformPosition2(new Vector2(pt.x, pt.y)); - const newModelPt = newMVT.inversePosition2(pixelPos); + const pixelPos = prevMvt.transformPosition2(new Vector2(pt.x, pt.y)); + const newModelPt = newMvt.inversePosition2(pixelPos); return { ...pt, x: newModelPt.x, y: newModelPt.y }; }), })); @@ -278,15 +255,15 @@ export class SimModel { * unique color. Does nothing once all 26 letter slots are exhausted. */ public addTrack(): void { - if (this.nextSymbolCode > TRACK_SYMBOL_LAST_CODE) return; // 'Z' is the last allowed symbol + if (this.nextSymbolCode > TRACK_SYMBOL_LAST_CODE) { + return; // 'Z' is the last allowed symbol + } const symbol = String.fromCharCode(this.nextSymbolCode); - const colorIndex = - (this.nextSymbolCode - TRACK_SYMBOL_FIRST_CODE) % TRACK_COLORS.length; + const colorIndex = (this.nextSymbolCode - TRACK_SYMBOL_FIRST_CODE) % TRACK_COLORS.length; const trackColor = TRACK_COLORS[colorIndex]; const color = trackColor ? trackColor.toCSS() : "#000000"; this.nextSymbolCode++; - this.canAddTrackProperty.value = - this.nextSymbolCode <= TRACK_SYMBOL_LAST_CODE; + this.canAddTrackProperty.value = this.nextSymbolCode <= TRACK_SYMBOL_LAST_CODE; const track: Track = { id: `track-${symbol}`, @@ -308,9 +285,7 @@ export class SimModel { if (this.activeTrackIdProperty.value === id) { this.activeTrackIdProperty.value = null; } - this.tracksProperty.value = this.tracksProperty.value.filter( - (t) => t.id !== id, - ); + this.tracksProperty.value = this.tracksProperty.value.filter((t) => t.id !== id); } /** @@ -325,15 +300,11 @@ export class SimModel { * @param x - Horizontal position in model coordinates. * @param y - Vertical position in model coordinates. */ - public addPointToTrack( - id: string, - frame: number, - time: number, - x: number, - y: number, - ): void { + public addPointToTrack(id: string, frame: number, time: number, x: number, y: number): void { const tracks = this.tracksProperty.value.map((track) => { - if (track.id !== id) return track; + if (track.id !== id) { + return track; + } // If the user re-digitizes a point at the same frame (e.g. to correct a // misclick), replace the existing coordinates rather than silently diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index ce0de18..ba8769b 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -1,14 +1,7 @@ import type { TReadOnlyProperty } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; -import { - DragListener, - Line, - Node, - Path, - Rectangle, - Text, -} from "scenerystack/scenery"; +import { DragListener, Line, Node, Path, Rectangle, Text } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; @@ -50,9 +43,7 @@ export class AutoTrackerNode extends Node { // ── Trail: O(1) ring buffer ──────────────────────────────────────────── // Using a fixed-size circular buffer instead of a plain array so that the // oldest-point eviction at 30 Hz is O(1) rather than O(n) (Array.shift). - private readonly trailBuf: Array<{ x: number; y: number }> = new Array( - MAX_TRAIL, - ); + private readonly trailBuf: Array<{ x: number; y: number }> = new Array(MAX_TRAIL); private trailHead = 0; // index of the slot where the NEXT write will land private trailSize = 0; // number of valid entries (0 … MAX_TRAIL) /** Frames already recorded to the active track; cleared on track change or reset. */ @@ -142,14 +133,11 @@ export class AutoTrackerNode extends Node { lineWidth: CROSSHAIR_LINE_WIDTH, visible: false, }); - this.crosshairCircle = new Path( - Shape.circle(0, 0, CROSSHAIR_CIRCLE_RADIUS), - { - stroke: crosshairStroke, - lineWidth: CROSSHAIR_LINE_WIDTH, - visible: false, - }, - ); + this.crosshairCircle = new Path(Shape.circle(0, 0, CROSSHAIR_CIRCLE_RADIUS), { + stroke: crosshairStroke, + lineWidth: CROSSHAIR_LINE_WIDTH, + visible: false, + }); this.addChild(this.trailPath); this.addChild(this.crosshairCircle); this.addChild(this.crosshairH); @@ -174,7 +162,9 @@ export class AutoTrackerNode extends Node { this.selectionRect.visible = true; }, drag: (event) => { - if (!this.selecting) return; + if (!this.selecting) { + return; + } const p = this.globalToLocalPoint(event.pointer.point); this.selectionRect.setRect( Math.min(this.selStart.x, p.x), @@ -184,7 +174,9 @@ export class AutoTrackerNode extends Node { ); }, end: (event) => { - if (!this.selecting) return; + if (!this.selecting) { + return; + } this.selecting = false; this.selectionRect.visible = false; @@ -233,19 +225,14 @@ export class AutoTrackerNode extends Node { // nowhere to record points. const activeId = this.model.activeTrackIdProperty.value; const trackStillExists = - activeId !== null && - this.model.tracksProperty.value.some((t) => t.id === activeId); + activeId !== null && this.model.tracksProperty.value.some((t) => t.id === activeId); if (!trackStillExists) { this.model.tracker.dispose(); this.hintText.visible = true; } }) - .catch((err) => { + .catch((_err) => { if (this.initVersion === capturedVersion) { - console.error( - "[AutoTracker] Tracking initialisation failed:", - err, - ); this.hintText.visible = true; } }); @@ -259,14 +246,20 @@ export class AutoTrackerNode extends Node { // ── Track on every video frame ──────────────────────────────────────── const onFrame = () => { - if (!this.visible || !this.model.tracker.ready) return; + if (!(this.visible && this.model.tracker.ready)) { + return; + } const pt = this.model.tracker.track(videoElement); - if (!pt) return; + if (!pt) { + return; + } // O(1) ring-buffer write: overwrite the oldest slot when full. this.trailBuf[this.trailHead] = pt; this.trailHead = (this.trailHead + 1) % MAX_TRAIL; - if (this.trailSize < MAX_TRAIL) this.trailSize++; + if (this.trailSize < MAX_TRAIL) { + this.trailSize++; + } this.updateTrackerVisuals(pt); // ── Record position to model if a track is active ───────────────── @@ -283,8 +276,7 @@ export class AutoTrackerNode extends Node { if (!this.recordedFrames.has(frame)) { // 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); + const modelPt = model.modelViewTransformProperty.value.inversePosition2(globalPt); model.addPointToTrack(activeId, frame, time, modelPt.x, modelPt.y); this.recordedFrames.add(frame); } @@ -306,9 +298,13 @@ export class AutoTrackerNode extends Node { // ── Show/hide based on combined "video loaded && autoTracking" ──────── const autoTrackingShownListener = (shown: boolean) => { - if (!shown) this.reset(); + if (!shown) { + this.reset(); + } this.visible = shown; - if (shown) this.hintText.visible = true; + if (shown) { + this.hintText.visible = true; + } }; autoTrackingShownProperty.link(autoTrackingShownListener); this.boundAutoTrackingShownProperty = autoTrackingShownProperty; @@ -328,7 +324,9 @@ export class AutoTrackerNode extends Node { for (let i = 0; i < this.trailSize; i++) { const idx = (this.trailHead - this.trailSize + i + MAX_TRAIL) % MAX_TRAIL; const p = this.trailBuf[idx]; - if (p) shape.circle(p.x, p.y, TRAIL_DOT_RADIUS); + if (p) { + shape.circle(p.x, p.y, TRAIL_DOT_RADIUS); + } } this.trailPath.shape = shape; this.trailPath.visible = true; diff --git a/src/screen-name/view/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index b59f4db..8a1a18b 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -2,23 +2,10 @@ import { Color } from "scenerystack"; import type { TReadOnlyProperty } from "scenerystack/axon"; import { DerivedProperty, Multilink } from "scenerystack/axon"; import { Shape } from "scenerystack/kite"; -import { - Circle, - HBox, - Line, - Node, - RichDragListener, - Text, -} from "scenerystack/scenery"; +import { Circle, HBox, Line, Node, RichDragListener, Text } from "scenerystack/scenery"; import { Keypad, PhetFont } from "scenerystack/scenery-phet"; import { KeypadDialog } from "scenerystack/sim"; -import { - ButtonNode, - ComboBox, - type ComboBoxItem, - Panel, - TextPushButton, -} from "scenerystack/sun"; +import { ButtonNode, ComboBox, type ComboBoxItem, Panel, TextPushButton } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; import type { SimModel } from "../model/SimModel.js"; @@ -63,11 +50,7 @@ export class CalibrationToolNode extends Node { * @param listParent - Scene-graph node used as the popup list parent for the unit ComboBox. * @param model - Provides calibration properties and receives user-entered values. */ - public constructor( - videoLoadedProperty: TReadOnlyProperty, - listParent: Node, - model: SimModel, - ) { + public constructor(videoLoadedProperty: TReadOnlyProperty, listParent: Node, model: SimModel) { super(); // ── Connecting line with shadow for visibility on all backgrounds ──── @@ -115,11 +98,7 @@ export class CalibrationToolNode extends Node { }); const endpoint1 = makeEndpoint("Calibration Point 1"); const endpoint2 = makeEndpoint("Calibration Point 2"); - const endpointTouchArea = Shape.circle( - 0, - 0, - ENDPOINT_RADIUS + ENDPOINT_TOUCH_DILATION, - ); + const endpointTouchArea = Shape.circle(0, 0, ENDPOINT_RADIUS + ENDPOINT_TOUCH_DILATION); endpoint1.mouseArea = endpointTouchArea; endpoint1.touchArea = endpointTouchArea; endpoint2.mouseArea = endpointTouchArea; @@ -138,10 +117,7 @@ export class CalibrationToolNode extends Node { tandem: Tandem.OPT_OUT, }); // Pattern shown inside the dialog as "Range: {{min}} – {{max}} " - const rangePatternProperty = new DerivedProperty( - [model.calibUnitProperty], - (unit) => `{{min}} – {{max}} ${unit}`, - ); + const rangePatternProperty = new DerivedProperty([model.calibUnitProperty], (unit) => `{{min}} – {{max}} ${unit}`); // ── Midpoint panel ──────────────────────────────────────────────────── // Button showing current value + unit; clicking it opens the keypad. @@ -162,34 +138,30 @@ export class CalibrationToolNode extends Node { }, model.calibDistanceProperty.range, rangePatternProperty, - () => {}, + () => { + /* no-op: keypad close callback not needed */ + }, ); }, tandem: Tandem.OPT_OUT, }); // Unit selector - const unitItems: ComboBoxItem<(typeof CALIBRATION_UNITS)[number]>[] = - CALIBRATION_UNITS.map((unit) => ({ - value: unit, - createNode: () => - new Text(unit, { - font: FONT, - fill: TrackLabColors.textOnDarkProperty, - }), - tandemName: `${unit}Item`, - })); - const unitComboBox = new ComboBox( - model.calibUnitProperty, - unitItems, - listParent, - { - buttonFill: TrackLabColors.comboBoxButtonFillProperty, - listFill: TrackLabColors.comboBoxListFillProperty, - highlightFill: TrackLabColors.comboBoxHighlightFillProperty, - tandem: Tandem.OPT_OUT, - }, - ); + const unitItems: ComboBoxItem<(typeof CALIBRATION_UNITS)[number]>[] = CALIBRATION_UNITS.map((unit) => ({ + value: unit, + createNode: () => + new Text(unit, { + font: FONT, + fill: TrackLabColors.textOnDarkProperty, + }), + tandemName: `${unit}Item`, + })); + const unitComboBox = new ComboBox(model.calibUnitProperty, unitItems, listParent, { + buttonFill: TrackLabColors.comboBoxButtonFillProperty, + listFill: TrackLabColors.comboBoxListFillProperty, + highlightFill: TrackLabColors.comboBoxHighlightFillProperty, + tandem: Tandem.OPT_OUT, + }); const midpointPanel = new Panel( new HBox({ @@ -210,14 +182,11 @@ export class CalibrationToolNode extends Node { // ── 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, - }, - ); + 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 ─────────────────────────────── @@ -243,9 +212,7 @@ export class CalibrationToolNode extends Node { // 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; + const endpointFill = tooClose ? ENDPOINT_WARNING_COLOR : TrackLabColors.calibrationFillProperty.value; endpoint1.fill = endpointFill; endpoint2.fill = endpointFill; overlapWarning.visible = tooClose; @@ -257,10 +224,7 @@ export class CalibrationToolNode extends Node { // A single Multilink replaces two separate link() calls so that geometry // is rebuilt once per change event regardless of which endpoint moved, // and disposal is managed in one place. - const calibMultilink = Multilink.multilink( - [model.calibPoint1Property, model.calibPoint2Property], - updateGeometry, - ); + const calibMultilink = Multilink.multilink([model.calibPoint1Property, model.calibPoint2Property], updateGeometry); // ── Drag listeners for endpoints ────────────────────────────────────── endpoint1.addInputListener( diff --git a/src/screen-name/view/ControlPanel.ts b/src/screen-name/view/ControlPanel.ts index d0b44a3..d2b4692 100644 --- a/src/screen-name/view/ControlPanel.ts +++ b/src/screen-name/view/ControlPanel.ts @@ -23,19 +23,13 @@ const PANEL_Y_MARGIN = 12; /** Two small XY arrows. */ function axesIcon(): Node { - const xArrow = new ArrowNode( - 0, - ICON_SIZE * 0.7, - ICON_SIZE * 0.85, - ICON_SIZE * 0.7, - { - fill: TrackLabColors.axisXColorProperty, - stroke: null, - headWidth: ICON_ARROW_HEAD_SIZE, - headHeight: ICON_ARROW_HEAD_SIZE, - tailWidth: ICON_ARROW_TAIL_WIDTH, - }, - ); + const xArrow = new ArrowNode(0, ICON_SIZE * 0.7, ICON_SIZE * 0.85, ICON_SIZE * 0.7, { + fill: TrackLabColors.axisXColorProperty, + stroke: null, + headWidth: ICON_ARROW_HEAD_SIZE, + headHeight: ICON_ARROW_HEAD_SIZE, + tailWidth: ICON_ARROW_TAIL_WIDTH, + }); const yArrow = new ArrowNode(0, ICON_SIZE * 0.7, 0, ICON_SIZE * 0.05, { fill: TrackLabColors.axisYColorProperty, stroke: null, @@ -127,10 +121,7 @@ function trackingIcon(): Node { // ── Helper ──────────────────────────────────────────────────────────────── -function makeRow( - icon: Node, - property: SimModel["axesVisibleProperty"], -): Checkbox { +function makeRow(icon: Node, property: SimModel["axesVisibleProperty"]): Checkbox { return new Checkbox(property, icon, { checkboxColor: TrackLabColors.checkboxColorProperty, checkboxColorBackground: TrackLabColors.checkboxColorBackgroundProperty, @@ -149,18 +140,11 @@ export class ControlPanel extends Panel { * @param model - Provides the boolean visibility properties bound to each checkbox. * @param trackLabPreferences - Determines whether the auto-tracking checkbox is shown. */ - public constructor( - model: SimModel, - trackLabPreferences: TrackLabPreferencesModel, - ) { - const autoTrackingCheckbox = makeRow( - trackingIcon(), - model.autoTrackingProperty, - ); + public constructor(model: SimModel, trackLabPreferences: TrackLabPreferencesModel) { + const autoTrackingCheckbox = makeRow(trackingIcon(), model.autoTrackingProperty); // The auto-tracking checkbox is only visible if the preference allows it. // When the preference is disabled, the checkbox is completely hidden from the panel. - autoTrackingCheckbox.visibleProperty = - trackLabPreferences.enableAutoTrackingProperty; + autoTrackingCheckbox.visibleProperty = trackLabPreferences.enableAutoTrackingProperty; const rows = new VBox({ children: [ diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index 4063d8f..e802321 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -7,6 +7,7 @@ import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import type { SimModel } from "../model/SimModel.js"; + const ARROW_LENGTH = 120; const HANDLE_FRACTION = 1 / 3; const FONT = new PhetFont({ size: 14, weight: "bold" }); @@ -44,10 +45,7 @@ export class CoordinateSystemNode extends Node { * @param videoLoadedProperty - Controls visibility; node is hidden until a video is loaded. * @param model - Provides and receives coordOriginProperty / coordAngleProperty. */ - public constructor( - videoLoadedProperty: TReadOnlyProperty, - model: SimModel, - ) { + public constructor(videoLoadedProperty: TReadOnlyProperty, model: SimModel) { super(); const coordStrings = StringManager.getInstance().getCoordSystem(); @@ -107,11 +105,7 @@ export class CoordinateSystemNode extends Node { focusable: true, accessibleName: "Rotation Handle", }); - const handleTouchArea = Shape.circle( - 0, - 0, - HANDLE_RADIUS + HANDLE_TOUCH_DILATION, - ); + const handleTouchArea = Shape.circle(0, 0, HANDLE_RADIUS + HANDLE_TOUCH_DILATION); handleDisk.mouseArea = handleTouchArea; handleDisk.touchArea = handleTouchArea; rotatingNode.addChild(handleDisk); @@ -133,10 +127,7 @@ export class CoordinateSystemNode extends Node { }); // Expand touch/mouse area for easier pickup (origin + axes region) positionNode.boundsProperty.lazyLink(() => { - const dilated = positionNode.localBounds.dilatedXY( - ORIGIN_TOUCH_DILATION, - ORIGIN_TOUCH_DILATION, - ); + const dilated = positionNode.localBounds.dilatedXY(ORIGIN_TOUCH_DILATION, ORIGIN_TOUCH_DILATION); positionNode.mouseArea = dilated; positionNode.touchArea = dilated; }); @@ -181,8 +172,7 @@ export class CoordinateSystemNode extends Node { dragSpeed: ROTATE_DRAG_SPEED, shiftDragSpeed: ROTATE_SHIFT_DRAG_SPEED, drag: (_event, listener) => { - model.coordAngleProperty.value += - listener.modelDelta.x * DEG_TO_RAD; + model.coordAngleProperty.value += listener.modelDelta.x * DEG_TO_RAD; }, }, tandem: Tandem.OPT_OUT, diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index 12dbccd..e1fe891 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -78,11 +78,7 @@ function buildDataRows(tracks: readonly Track[]): DataRow[] { /** * Generate CSV content from tracks. */ -function generateCSV( - tracks: readonly Track[], - unit: string, - labels: TableLabels, -): string { +function generateCsv(tracks: readonly Track[], unit: string, labels: TableLabels): string { const dataRows = buildDataRows(tracks); // Header row @@ -95,17 +91,11 @@ function generateCSV( // Data rows for (const row of dataRows) { - const cells: string[] = [ - String(row.frame), - row.time.toFixed(CSV_DECIMAL_PLACES), - ]; + const cells: string[] = [String(row.frame), row.time.toFixed(CSV_DECIMAL_PLACES)]; for (const track of tracks) { const val = row.values.get(track.id); if (val) { - cells.push( - val.x.toFixed(CSV_DECIMAL_PLACES), - val.y.toFixed(CSV_DECIMAL_PLACES), - ); + cells.push(val.x.toFixed(CSV_DECIMAL_PLACES), val.y.toFixed(CSV_DECIMAL_PLACES)); } else { cells.push("", ""); } @@ -139,7 +129,7 @@ type TableColors = { * Build an HTML table element for the data. * Height adjusts based on row count, with min/max constraints. */ -function buildHTMLTable( +function buildHtmlTable( tracks: readonly Track[], unit: string, colors: TableColors, @@ -254,7 +244,9 @@ function buildHTMLTable( } else { for (let i = 0; i < dataRows.length; i++) { const row = dataRows[i]; - if (!row) continue; + if (!row) { + continue; + } const tr = document.createElement("tr"); tr.style.background = i % 2 === 0 ? colors.rowOdd : colors.rowEven; @@ -384,8 +376,8 @@ export class DataTableNode extends Panel { }); // ── Create scrollable HTML table ───────────────────────────────────────── - const tableWrapper = buildHTMLTable([], "m", getTableColors(), getLabels()); - const tableDOMNode = new DOM(tableWrapper, { allowInput: true }); + const tableWrapper = buildHtmlTable([], "m", getTableColors(), getLabels()); + const tableDomNode = new DOM(tableWrapper, { allowInput: true }); // ── Export button ──────────────────────────────────────────────────────── const exportButton = new RectangularPushButton({ @@ -409,7 +401,7 @@ export class DataTableNode extends Panel { listener: () => { const tracks = model.tracksProperty.value; const unit = unitProperty.value; - const csv = generateCSV(tracks, unit, getLabels()); + const csv = generateCsv(tracks, unit, getLabels()); // Create download — no DOM insertion needed in modern browsers. const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); @@ -438,7 +430,7 @@ export class DataTableNode extends Panel { // ── Main content ───────────────────────────────────────────────────────── const content = new VBox({ - children: [titleRow, tableDOMNode], + children: [titleRow, tableDomNode], spacing: CONTENT_SPACING, align: "left", }); @@ -458,7 +450,7 @@ export class DataTableNode extends Panel { // Replaces the entire table DOM and refreshes cached references. const doFullRebuild = (tracks: readonly Track[], unit: string) => { const colors = getTableColors(); - const newWrapper = buildHTMLTable(tracks, unit, colors, getLabels()); + const newWrapper = buildHtmlTable(tracks, unit, colors, getLabels()); this.tableWrapper.innerHTML = ""; if (newWrapper.firstChild) { @@ -469,8 +461,7 @@ export class DataTableNode extends Panel { // Cache reference and rebuild the frame→row map. const tbody = this.tableWrapper.querySelector("tbody"); - this.tableBodyRef = - tbody instanceof HTMLTableSectionElement ? tbody : null; + this.tableBodyRef = tbody instanceof HTMLTableSectionElement ? tbody : null; this.frameRowMap.clear(); this.maxRenderedFrame = -Infinity; @@ -519,8 +510,7 @@ export class DataTableNode extends Panel { // sort order of the table would break; fall back to full rebuild in that // rare case (out-of-order manual digitizing on an earlier frame). const hasOutOfOrder = dataRows.some( - (row) => - !this.frameRowMap.has(row.frame) && row.frame < this.maxRenderedFrame, + (row) => !this.frameRowMap.has(row.frame) && row.frame < this.maxRenderedFrame, ); if (hasOutOfOrder) { doFullRebuild(tracks, unit); @@ -545,28 +535,26 @@ export class DataTableNode extends Panel { const val = row.values.get(track.id); const xCell = cells[cellIdx]; const yCell = cells[cellIdx + 1]; - if (xCell) - xCell.textContent = val - ? val.x.toFixed(CELL_DECIMAL_PLACES) - : "—"; - if (yCell) - yCell.textContent = val - ? val.y.toFixed(CELL_DECIMAL_PLACES) - : "—"; + if (xCell) { + xCell.textContent = val ? val.x.toFixed(CELL_DECIMAL_PLACES) : "—"; + } + if (yCell) { + yCell.textContent = val ? val.y.toFixed(CELL_DECIMAL_PLACES) : "—"; + } cellIdx += 2; } } else { // Append a brand-new row at the bottom. const rowIndex = this.frameRowMap.size; // 0-based index of this row - const tr = buildSingleDataRow( + const newRow = buildSingleDataRow( row, tracks, cellStyle, rowIndex % 2 !== 0, // isEven flag: index 0 → rowOdd, index 1 → rowEven, … colors, ); - this.tableBodyRef.appendChild(tr); - this.frameRowMap.set(row.frame, tr); + this.tableBodyRef.appendChild(newRow); + this.frameRowMap.set(row.frame, newRow); if (row.frame > this.maxRenderedFrame) { this.maxRenderedFrame = row.frame; } @@ -584,13 +572,10 @@ export class DataTableNode extends Panel { // Colour profile and locale changes require a full rebuild because cell // colours and label strings are baked into the DOM; they are not captured // by the track-ID / unit structural-change check above. - const fullRebuild = () => - doFullRebuild(model.tracksProperty.value, unitProperty.value); + const fullRebuild = () => doFullRebuild(model.tracksProperty.value, unitProperty.value); const tableHeaderBgListener = () => fullRebuild(); - TrackLabColors.tableHeaderBackgroundProperty.lazyLink( - tableHeaderBgListener, - ); + TrackLabColors.tableHeaderBackgroundProperty.lazyLink(tableHeaderBgListener); const frameStringListener = () => fullRebuild(); dataTableStrings.frameStringProperty.lazyLink(frameStringListener); @@ -604,9 +589,7 @@ export class DataTableNode extends Panel { this.disposeDataTable = () => { model.tracksProperty.unlink(tracksListener); unitProperty.unlink(unitListener); - TrackLabColors.tableHeaderBackgroundProperty.unlink( - tableHeaderBgListener, - ); + TrackLabColors.tableHeaderBackgroundProperty.unlink(tableHeaderBgListener); dataTableStrings.frameStringProperty.unlink(frameStringListener); videoLoadedProperty.unlink(videoLoadedListener); exportButton.dispose(); diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index cd106ad..edaa2c1 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -1,13 +1,6 @@ import { Vector2 } from "scenerystack/dot"; import { Shape } from "scenerystack/kite"; -import { - DOM, - FireListener, - Line, - Node, - Path, - Rectangle, -} from "scenerystack/scenery"; +import { DOM, FireListener, Line, Node, Path, Rectangle } from "scenerystack/scenery"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; import { VIDEO_HEIGHT, VIDEO_WIDTH } from "../../TrackLabConstants.js"; @@ -33,20 +26,13 @@ const MARK_DOT_RADIUS = 2; // radius of each digitized-point dot drawn on the vi export class DigitizingOverlayNode extends Node { private readonly disposeDigitizingOverlay: () => void; - public constructor( - videoElement: HTMLVideoElement, - model: SimModel, - onPointAdded: () => void, - ) { + public constructor(videoElement: HTMLVideoElement, model: SimModel, onPointAdded: () => void) { super(); // Cached CSS color strings for canvas drawing (updated via property links) - let magBorderColor = - TrackLabColors.digitizingMagnifierBorderProperty.value.toCSS(); - let magCrosshairColor = - TrackLabColors.digitizingMagnifierCrosshairProperty.value.toCSS(); - let magShadowColor = - TrackLabColors.digitizingMagnifierShadowProperty.value.toCSS(); + let magBorderColor = TrackLabColors.digitizingMagnifierBorderProperty.value.toCSS(); + let magCrosshairColor = TrackLabColors.digitizingMagnifierCrosshairProperty.value.toCSS(); + let magShadowColor = TrackLabColors.digitizingMagnifierShadowProperty.value.toCSS(); // Custom cursor: large circle + 4 segments that stop at the empty centre const cursorCircle = new Path(Shape.circle(0, 0, OUTER_R), { @@ -73,13 +59,7 @@ export class DigitizingOverlayNode extends Node { const cursorNode = new Node({ visible: false, pickable: false, - children: [ - cursorCircle, - cursorLineLeft, - cursorLineRight, - cursorLineTop, - cursorLineBottom, - ], + children: [cursorCircle, cursorLineLeft, cursorLineRight, cursorLineTop, cursorLineBottom], }); // ── Magnifier (zoomed view near the cursor) ───────────────────────────── @@ -104,9 +84,7 @@ export class DigitizingOverlayNode extends Node { const magCrosshairListener = (color: import("scenerystack").Color) => { magCrosshairColor = color.toCSS(); }; - TrackLabColors.digitizingMagnifierCrosshairProperty.link( - magCrosshairListener, - ); + TrackLabColors.digitizingMagnifierCrosshairProperty.link(magCrosshairListener); const magShadowListener = (color: import("scenerystack").Color) => { magShadowColor = color.toCSS(); @@ -115,8 +93,9 @@ export class DigitizingOverlayNode extends Node { TrackLabColors.digitizingMagnifierShadowProperty.link(magShadowListener); const magCtx = magCanvas.getContext("2d"); - if (!magCtx) + if (!magCtx) { throw new Error("Could not get 2D context from magnifier canvas"); + } const magnifierNode = new DOM(magCanvas, { allowInput: false }); magnifierNode.visible = false; @@ -179,14 +158,8 @@ export class DigitizingOverlayNode extends Node { return cachedVideoBounds; }; - const updateMagnifier = ( - localX: number, - localY: number, - crosshairX: number, - crosshairY: number, - ) => { - const { renderedW, renderedH, offsetX, offsetY, videoW, videoH } = - getRenderedVideoBounds(); + const updateMagnifier = (localX: number, localY: number, crosshairX: number, crosshairY: number) => { + const { renderedW, renderedH, offsetX, offsetY, videoW, videoH } = getRenderedVideoBounds(); const videoX = ((localX - offsetX) / renderedW) * videoW; const videoY = ((localY - offsetY) / renderedH) * videoH; @@ -203,30 +176,14 @@ export class DigitizingOverlayNode extends Node { magCtx.arc(MAG_SIZE / 2, MAG_SIZE / 2, MAG_SIZE / 2, 0, Math.PI * 2); magCtx.clip(); - magCtx.drawImage( - videoElement, - sx, - sy, - srcW, - srcH, - 0, - 0, - MAG_SIZE, - MAG_SIZE, - ); + magCtx.drawImage(videoElement, sx, sy, srcW, srcH, 0, 0, MAG_SIZE, MAG_SIZE); magCtx.restore(); magCtx.strokeStyle = magBorderColor; magCtx.lineWidth = MAG_BORDER_WIDTH; magCtx.beginPath(); - magCtx.arc( - MAG_SIZE / 2, - MAG_SIZE / 2, - MAG_SIZE / 2 - MAG_BORDER_WIDTH / 2, - 0, - Math.PI * 2, - ); + magCtx.arc(MAG_SIZE / 2, MAG_SIZE / 2, MAG_SIZE / 2 - MAG_BORDER_WIDTH / 2, 0, Math.PI * 2); magCtx.stroke(); magCtx.strokeStyle = magCrosshairColor; @@ -253,20 +210,12 @@ export class DigitizingOverlayNode extends Node { digitizingOverlay.addInputListener({ move: (event) => { - const localPt = digitizingOverlay.globalToLocalPoint( - event.pointer.point, - ); + const localPt = digitizingOverlay.globalToLocalPoint(event.pointer.point); cursorNode.translation = localPt; cursorNode.visible = true; - const magX = Math.max( - 0, - Math.min(localPt.x - MAG_SIZE / 2, VIDEO_WIDTH - MAG_SIZE), - ); - const magY = Math.max( - 0, - Math.min(localPt.y - MAG_SIZE / 2, VIDEO_HEIGHT - MAG_SIZE), - ); + const magX = Math.max(0, Math.min(localPt.x - MAG_SIZE / 2, VIDEO_WIDTH - MAG_SIZE)); + const magY = Math.max(0, Math.min(localPt.y - MAG_SIZE / 2, VIDEO_HEIGHT - MAG_SIZE)); magnifierNode.x = magX; magnifierNode.y = magY; @@ -294,9 +243,7 @@ export class DigitizingOverlayNode extends Node { const rebuildMarks = () => { const frameDuration = model.frameDurationProperty.value; - const currentFrame = Math.round( - model.currentTimeProperty.value / frameDuration, - ); + const currentFrame = Math.round(model.currentTimeProperty.value / frameDuration); const mvt = model.modelViewTransformProperty.value; const tracks = model.tracksProperty.value; const activeTrackIds = new Set(tracks.map((t) => t.id)); @@ -312,9 +259,7 @@ export class DigitizingOverlayNode extends Node { const shape = new Shape(); for (const point of track.points) { if (point.frame <= currentFrame) { - const localPt = mvt.transformPosition2( - new Vector2(point.x, point.y), - ); + const localPt = mvt.transformPosition2(new Vector2(point.x, point.y)); shape.circle(localPt.x, localPt.y, MARK_DOT_RADIUS); } } @@ -345,30 +290,36 @@ export class DigitizingOverlayNode extends Node { const activeTrackListener = (activeId: string | null) => { digitizingOverlay.visible = activeId !== null; - if (!activeId) cursorNode.visible = false; + if (!activeId) { + cursorNode.visible = false; + } }; model.activeTrackIdProperty.link(activeTrackListener); const magnifyListener = (magnify: boolean) => { - if (!magnify) magnifierNode.visible = false; + if (!magnify) { + magnifierNode.visible = false; + } }; model.magnifyVideoProperty.link(magnifyListener); digitizingOverlay.addInputListener( new FireListener({ fire: (event) => { - if (!event) return; + if (!event) { + return; + } const activeId = model.activeTrackIdProperty.value; - if (!activeId) return; + if (!activeId) { + return; + } - const track = model.tracksProperty.value.find( - (t) => t.id === activeId, - ); - if (!track) return; + const track = model.tracksProperty.value.find((t) => t.id === activeId); + if (!track) { + return; + } - const localPt = digitizingOverlay.globalToLocalPoint( - event.pointer.point, - ); + const localPt = digitizingOverlay.globalToLocalPoint(event.pointer.point); const time = model.currentTimeProperty.value; const frame = Math.round(time * model.frameRateProperty.value); @@ -389,15 +340,9 @@ 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, - ); + TrackLabColors.digitizingMagnifierBorderProperty.unlink(magBorderListener); + TrackLabColors.digitizingMagnifierCrosshairProperty.unlink(magCrosshairListener); + TrackLabColors.digitizingMagnifierShadowProperty.unlink(magShadowListener); model.currentTimeProperty.unlink(currentTimeListener); model.tracksProperty.unlink(tracksListener); model.modelViewTransformProperty.unlink(mvtListener); diff --git a/src/screen-name/view/KeyboardShortcutsNode.ts b/src/screen-name/view/KeyboardShortcutsNode.ts index 5c0ddec..37d8418 100644 --- a/src/screen-name/view/KeyboardShortcutsNode.ts +++ b/src/screen-name/view/KeyboardShortcutsNode.ts @@ -15,8 +15,7 @@ import trackLab from "../../TrackLabNamespace.js"; export class KeyboardShortcutsNode extends TwoColumnKeyboardHelpContent { public constructor() { const stringManager = StringManager.getInstance(); - const keyboardShortcutsStrings = - stringManager.getKeyboardShortcutsStrings(); + const keyboardShortcutsStrings = stringManager.getKeyboardShortcutsStrings(); // Create sections for simulation controls const simulationControlsSection = new KeyboardHelpSection( @@ -42,23 +41,17 @@ export class KeyboardShortcutsNode extends TwoColumnKeyboardHelpContent { ); // 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"), - ), - ], - ); + 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], { diff --git a/src/screen-name/view/KinematicsGraphNode.ts b/src/screen-name/view/KinematicsGraphNode.ts index be52367..f38baf5 100644 --- a/src/screen-name/view/KinematicsGraphNode.ts +++ b/src/screen-name/view/KinematicsGraphNode.ts @@ -105,7 +105,7 @@ export class KinematicsGraphNode extends VBox { const initialXProperty = plottableProperties[1]; const initialYProperty = plottableProperties[2]; - if (!initialXProperty || !initialYProperty) { + if (!(initialXProperty && initialYProperty)) { throw new Error("Failed to initialize plottable properties"); } @@ -129,9 +129,7 @@ export class KinematicsGraphNode extends VBox { this.trackSelectorContainer = new Node(); // Update combo box when tracks change (link fires immediately, building initial selector) - const tracksListener = ( - tracks: readonly import("../model/Track.js").Track[], - ) => { + const tracksListener = (tracks: readonly import("../model/Track.js").Track[]) => { // Dispose old combo box FIRST to disconnect it from the property // (prevents assertion error when property value changes) if (this.currentComboBox) { @@ -144,10 +142,7 @@ export class KinematicsGraphNode extends VBox { const firstTrack = tracks[0]; if (tracks.length === 0 || !firstTrack) { this.selectedTrackProperty.value = null; - } else if ( - currentId === null || - !tracks.some((t) => t.id === currentId) - ) { + } else if (currentId === null || !tracks.some((t) => t.id === currentId)) { this.selectedTrackProperty.value = firstTrack.id; } @@ -211,15 +206,10 @@ export class KinematicsGraphNode extends VBox { }); const trackComboBoxItems = this.createTrackComboBoxItems(); - this.currentComboBox = new ComboBox( - this.selectedTrackProperty, - trackComboBoxItems, - this.listParent, - { - xMargin: 8, - yMargin: 4, - }, - ); + this.currentComboBox = new ComboBox(this.selectedTrackProperty, trackComboBoxItems, this.listParent, { + xMargin: 8, + yMargin: 4, + }); const trackSelector = new HBox({ spacing: 8, @@ -263,12 +253,16 @@ export class KinematicsGraphNode extends VBox { this.graph.clearData(); const selectedId = this.selectedTrackProperty.value; - if (selectedId === null) return; + if (selectedId === null) { + return; + } const kinematics = this.model.trackKinematicsProperty.value; const trackData = kinematics.find((tk) => tk.id === selectedId); - if (!trackData || trackData.points.length === 0) return; + if (!trackData || trackData.points.length === 0) { + return; + } const dataPoints = trackData.points.map((pt) => ({ t: pt.time, diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 0a28041..0244309 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -1,11 +1,7 @@ import { DerivedProperty, EnumerationProperty } from "scenerystack/axon"; import { Dimension2, Range } from "scenerystack/dot"; import { HBox, Text, VBox } from "scenerystack/scenery"; -import { - PhetFont, - TimeControlNode, - TimeSpeed, -} from "scenerystack/scenery-phet"; +import { PhetFont, TimeControlNode, TimeSpeed } from "scenerystack/scenery-phet"; import { ButtonNode, RectangularPushButton, Slider } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import TrackLabColors from "../../TrackLabColors.js"; @@ -47,9 +43,7 @@ export class PlaybackControlsNode extends HBox { [TimeSpeed.NORMAL, SPEED_NORMAL], [TimeSpeed.SLOW, SPEED_SLOW], ]); - const rateToSpeed = new Map( - Array.from(speedMap.entries()).map(([k, v]) => [v, k]), - ); + const rateToSpeed = new Map(Array.from(speedMap.entries()).map(([k, v]) => [v, k])); const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL); // view → model @@ -113,25 +107,24 @@ export class PlaybackControlsNode extends HBox { // ── Time and frame info display ──────────────────────────────────────── const formatDuration = (seconds: number): string => { - if (!Number.isFinite(seconds) || seconds <= 0) return "0:00"; + if (!Number.isFinite(seconds) || seconds <= 0) { + return "0:00"; + } const mins = Math.floor(seconds / 60); const secs = Math.floor(seconds % 60); return `${mins}:${String(secs).padStart(2, "0")}`; }; - const totalTimeTextProperty = new DerivedProperty( - [model.durationProperty], - (duration: number) => formatDuration(duration), + const totalTimeTextProperty = new DerivedProperty([model.durationProperty], (duration: number) => + formatDuration(duration), ); const frameCountTextProperty = new DerivedProperty( - [ - model.currentTimeProperty, - model.durationProperty, - model.frameRateProperty, - ], + [model.currentTimeProperty, model.durationProperty, model.frameRateProperty], (time: number, duration: number, frameRate: number) => { - if (duration <= 0) return "0/0"; + if (duration <= 0) { + return "0/0"; + } // 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, matching the approach used in AutoTrackerNode. diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index e75c7b9..0658ef7 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -33,11 +33,7 @@ export class SimScreenView extends ScreenView { * @param trackLabPreferences - User preference flags (e.g. auto-tracking toggle). * @param options - Optional ScreenView configuration passed to the superclass. */ - public constructor( - model: SimModel, - trackLabPreferences: TrackLabPreferencesModel, - options?: ScreenViewOptions, - ) { + public constructor(model: SimModel, trackLabPreferences: TrackLabPreferencesModel, options?: ScreenViewOptions) { super(options); // Combined visibility: video loaded AND user-toggled model flag. @@ -67,34 +63,22 @@ export class SimScreenView extends ScreenView { // SimModel from the tool state properties above). this.videoPlayerNode = new VideoPlayerNode(model, this); this.videoPlayerNode.left = controlPanel.right + 20; - this.videoPlayerNode.centerY = - this.layoutBounds.centerY + VIDEO_PLAYER_Y_OFFSET; + this.videoPlayerNode.centerY = this.layoutBounds.centerY + VIDEO_PLAYER_Y_OFFSET; this.addChild(this.videoPlayerNode); // ── Coordinate system overlay (above video, below camera modal) ───────── // Reads/writes model.coordOriginProperty and model.coordAngleProperty. - const coordinateSystemNode = new CoordinateSystemNode( - axesShownProperty, - model, - ); + const coordinateSystemNode = new CoordinateSystemNode(axesShownProperty, model); this.addChild(coordinateSystemNode); // ── Calibration tool overlay (above video, below camera modal) ───────── // Reads/writes model.calibPoint1/2Property, model.calibDistanceProperty, // and model.calibUnitProperty. - const calibrationToolNode = new CalibrationToolNode( - calibrationShownProperty, - this, - model, - ); + const calibrationToolNode = new CalibrationToolNode(calibrationShownProperty, this, model); this.addChild(calibrationToolNode); // ── Data table (top, a bit to the left) ────────────────────────────── - const dataTableNode = new DataTableNode( - model, - model.videoLoadedProperty, - model.calibUnitProperty, - ); + const dataTableNode = new DataTableNode(model, model.videoLoadedProperty, model.calibUnitProperty); this.addChild(dataTableNode); dataTableNode.left = this.videoPlayerNode.right + 20; dataTableNode.top = this.layoutBounds.top + 10; diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index e246a49..d90212f 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -14,26 +14,10 @@ */ import { Color } from "scenerystack"; -import { - BooleanProperty, - DerivedProperty, - type TReadOnlyProperty, -} from "scenerystack/axon"; -import { - Circle, - Line, - Node, - Rectangle, - Text, - VBox, -} from "scenerystack/scenery"; +import { BooleanProperty, DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon"; +import { Circle, Line, Node, Rectangle, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; -import { - ButtonNode, - Checkbox, - Panel, - RectangularPushButton, -} from "scenerystack/sun"; +import { ButtonNode, Checkbox, Panel, RectangularPushButton } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; @@ -116,20 +100,12 @@ class TrackRowNode extends Node { const ROW_CY = ROW_HEIGHT / 2; // ── Rounded background (purely visual, not pickable) ────────────────── - const bg = new Rectangle( - 0, - 0, - PANEL_WIDTH, - ROW_HEIGHT, - ROW_CORNER_RADIUS, - ROW_CORNER_RADIUS, - { - fill: trackColor.withAlpha(ROW_BG_ALPHA), - stroke: trackColor.withAlpha(ROW_STROKE_ALPHA), - lineWidth: ROW_STROKE_WIDTH, - pickable: false, - }, - ); + const bg = new Rectangle(0, 0, PANEL_WIDTH, ROW_HEIGHT, ROW_CORNER_RADIUS, ROW_CORNER_RADIUS, { + fill: trackColor.withAlpha(ROW_BG_ALPHA), + stroke: trackColor.withAlpha(ROW_STROKE_ALPHA), + lineWidth: ROW_STROKE_WIDTH, + pickable: false, + }); // ── Colour badge with symbol letter ─────────────────────────────────── const badge = new Circle(BADGE_R, { @@ -146,9 +122,7 @@ class TrackRowNode extends Node { symbolLabel.centerY = ROW_CY; // ── Checkbox: activates this track for video digitizing ─────────────── - const isDigitizingProperty = new BooleanProperty( - model.activeTrackIdProperty.value === track.id, - ); + const isDigitizingProperty = new BooleanProperty(model.activeTrackIdProperty.value === track.id); // Sync checkbox from model (when another track becomes active, uncheck this one). // Axon Properties deduplicate same-value writes, so no infinite loop can occur. @@ -167,14 +141,10 @@ class TrackRowNode extends Node { }; isDigitizingProperty.lazyLink(digitizingListener); - const checkbox = new Checkbox( - isDigitizingProperty, - new Rectangle(0, 0, 0, 0), - { - boxWidth: CHECKBOX_BOX_WIDTH, - tandem: Tandem.OPT_OUT, - }, - ); + const checkbox = new Checkbox(isDigitizingProperty, new Rectangle(0, 0, 0, 0), { + boxWidth: CHECKBOX_BOX_WIDTH, + tandem: Tandem.OPT_OUT, + }); checkbox.left = CHECKBOX_X; checkbox.centerY = ROW_CY; @@ -218,10 +188,7 @@ class TrackRowNode extends Node { export class TrackListPanel extends Panel { private readonly disposeTrackListPanel: () => void; - public constructor( - model: SimModel, - videoLoadedProperty: TReadOnlyProperty, - ) { + public constructor(model: SimModel, videoLoadedProperty: TReadOnlyProperty) { const trackListStrings = StringManager.getInstance().getTrackList(); // Width enforcer: invisible rectangle keeps the panel wide even when the @@ -292,7 +259,9 @@ export class TrackListPanel extends Panel { let lastIds = ""; const tracksListener = (tracks: readonly Track[]) => { const ids = tracks.map((t) => t.id).join(","); - if (ids === lastIds) return; + if (ids === lastIds) { + return; + } lastIds = ids; // Dispose old track rows before creating new ones for (const child of trackListVBox.children) { @@ -300,9 +269,7 @@ export class TrackListPanel extends Panel { child.dispose(); } } - trackListVBox.children = tracks.map( - (track) => new TrackRowNode(track, model), - ); + trackListVBox.children = tracks.map((track) => new TrackRowNode(track, model)); }; model.tracksProperty.link(tracksListener); diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index 7bca91f..c4c37c6 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -73,18 +73,10 @@ export class VideoPlayerNode extends Node { [model.videoLoadedProperty, model.autoTrackingProperty], (loaded, tracking) => loaded && tracking, ); - const autoTrackerNode = new AutoTrackerNode( - this.videoElement, - autoTrackingShownProperty, - model, - ); + const autoTrackerNode = new AutoTrackerNode(this.videoElement, autoTrackingShownProperty, model); // ── Manual digitizing overlay ───────────────────────────────────────── - const digitizingOverlayNode = new DigitizingOverlayNode( - this.videoElement, - model, - () => this.stepForward(), - ); + const digitizingOverlayNode = new DigitizingOverlayNode(this.videoElement, model, () => this.stepForward()); const videoLayer = new Node({ children: [videoNode, autoTrackerNode, digitizingOverlayNode], @@ -165,9 +157,7 @@ export class VideoPlayerNode extends Node { // Store cleanup function this.disposeVideoPlayer = () => { - TrackLabColors.videoBackgroundColorProperty.unlink( - videoBackgroundListener, - ); + TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener); model.isPlayingProperty.unlink(isPlayingListener); model.playbackRateProperty.unlink(playbackRateListener); this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata); diff --git a/src/screen-name/view/VideoSourceControlNode.ts b/src/screen-name/view/VideoSourceControlNode.ts index 2ffe6e4..ab91082 100644 --- a/src/screen-name/view/VideoSourceControlNode.ts +++ b/src/screen-name/view/VideoSourceControlNode.ts @@ -120,16 +120,11 @@ export class VideoSourceControlNode extends HBox { })), ]; - const videoComboBox = new ComboBox( - selectedVideoProperty, - comboItems, - listParent, - { - buttonFill: TrackLabColors.comboBoxButtonFillProperty, - listFill: TrackLabColors.comboBoxListFillProperty, - highlightFill: TrackLabColors.comboBoxHighlightFillProperty, - }, - ); + const videoComboBox = new ComboBox(selectedVideoProperty, comboItems, listParent, { + buttonFill: TrackLabColors.comboBoxButtonFillProperty, + listFill: TrackLabColors.comboBoxListFillProperty, + highlightFill: TrackLabColors.comboBoxHighlightFillProperty, + }); selectedVideoProperty.lazyLink((filename) => { if (filename) { diff --git a/src/screen-name/view/WebcamPanel.ts b/src/screen-name/view/WebcamPanel.ts index 0edb72d..dd785cf 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -1,12 +1,7 @@ import { Property } from "scenerystack/axon"; import { Shape } from "scenerystack/kite"; import { DOM, HBox, Node, Path, Text, VBox } from "scenerystack/scenery"; -import { - CloseButton, - PhetFont, - RefreshButton, - StopIconShape, -} from "scenerystack/scenery-phet"; +import { CloseButton, PhetFont, RefreshButton, StopIconShape } from "scenerystack/scenery-phet"; import { ButtonNode, cameraSolidShape, @@ -18,16 +13,8 @@ import { import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; -import { - WEBCAM_PREVIEW_HEIGHT, - WEBCAM_PREVIEW_WIDTH, -} from "../../TrackLabConstants.js"; -import { - estimateVideoFrameRate, - type FPSEstimate, - fixWebmDuration, - WebcamRecorder, -} from "../../webcam.js"; +import { WEBCAM_PREVIEW_HEIGHT, WEBCAM_PREVIEW_WIDTH } from "../../TrackLabConstants.js"; +import { estimateVideoFrameRate, type FPSEstimate, fixWebmDuration, WebcamRecorder } from "../../webcam.js"; import { FRAME_RATE_RANGE, type SimModel } from "../model/SimModel.js"; const FONT = new PhetFont(14); @@ -94,14 +81,11 @@ export class WebcamPanel extends Node { this.cameraSelect = document.createElement("select"); this.cameraSelect.style.font = "14px sans-serif"; this.cameraSelect.style.padding = "4px"; - const cameraSelectDOM = new DOM(this.cameraSelect, { allowInput: true }); + const cameraSelectDom = new DOM(this.cameraSelect, { allowInput: true }); this.cameraSelect.addEventListener("change", async () => { if (!this.recorder.isRecording()) { - await this.recorder.startPreview( - this.previewElement, - this.cameraSelect.value || undefined, - ); + await this.recorder.startPreview(this.previewElement, this.cameraSelect.value || undefined); } }); @@ -115,7 +99,7 @@ export class WebcamPanel extends Node { TrackLabColors.videoBackgroundColorProperty.link((c) => { this.previewElement.style.background = c.toCSS(); }); - const previewDOM = new DOM(this.previewElement, { allowInput: false }); + const previewDom = new DOM(this.previewElement, { allowInput: false }); // ── Review video ────────────────────────────────────────────────────── this.reviewElement = document.createElement("video"); @@ -127,7 +111,7 @@ export class WebcamPanel extends Node { TrackLabColors.videoBackgroundColorProperty.link((c) => { this.reviewElement.style.background = c.toCSS(); }); - const reviewDOM = new DOM(this.reviewElement, { allowInput: true }); + const reviewDom = new DOM(this.reviewElement, { allowInput: true }); // ── Status ──────────────────────────────────────────────────────────── this.statusText = new Text("", { @@ -215,24 +199,20 @@ export class WebcamPanel extends Node { fill: TrackLabColors.textMutedProperty, }); - const fpsSpinner = new NumberSpinner( - this.model.frameRateProperty, - new Property(FRAME_RATE_RANGE), - { - deltaValue: 1, - numberDisplayOptions: { - decimalPlaces: 0, - textOptions: { font: SMALL_FONT }, - minBackgroundWidth: FPS_SPINNER_MIN_WIDTH, - }, - arrowsPosition: "leftRight", - arrowButtonOptions: { - scale: FPS_SPINNER_SCALE, - buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, - }, - tandem: Tandem.OPT_OUT, + const fpsSpinner = new NumberSpinner(this.model.frameRateProperty, new Property(FRAME_RATE_RANGE), { + deltaValue: 1, + numberDisplayOptions: { + decimalPlaces: 0, + textOptions: { font: SMALL_FONT }, + minBackgroundWidth: FPS_SPINNER_MIN_WIDTH, }, - ); + arrowsPosition: "leftRight", + arrowButtonOptions: { + scale: FPS_SPINNER_SCALE, + buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, + }, + tandem: Tandem.OPT_OUT, + }); const fpsControl = new HBox({ children: [fpsLabel, fpsSpinner], @@ -249,11 +229,11 @@ export class WebcamPanel extends Node { this.previewLayer = new VBox({ children: [ new HBox({ - children: [cameraIcon, cameraSelectDOM], + children: [cameraIcon, cameraSelectDom], spacing: CAMERA_ROW_SPACING, align: "center", }), - previewDOM, + previewDom, new HBox({ children: [cancelButton, startButton, stopButton], spacing: LAYER_SPACING, @@ -269,7 +249,7 @@ export class WebcamPanel extends Node { // ── Layer: review ───────────────────────────────────────────────────── this.reviewLayer = new VBox({ children: [ - reviewDOM, + reviewDom, this.fpsEstimateText, fpsControl, new HBox({ @@ -289,12 +269,7 @@ export class WebcamPanel extends Node { accessibleName: "Record from Webcam", }); const content = new VBox({ - children: [ - titleIcon, - this.previewLayer, - this.reviewLayer, - this.statusText, - ], + children: [titleIcon, this.previewLayer, this.reviewLayer, this.statusText], spacing: LAYER_SPACING, align: "center", }); @@ -339,10 +314,7 @@ export class WebcamPanel extends Node { } await this.populateCameras(); - await this.recorder.startPreview( - this.previewElement, - this.cameraSelect.value || undefined, - ); + await this.recorder.startPreview(this.previewElement, this.cameraSelect.value || undefined); this.clearStatus(); this._startButton.enabled = true; } @@ -412,7 +384,7 @@ export class WebcamPanel extends Node { // CRITICAL: Capture stream FPS BEFORE stopping the stream! // Once tracks are stopped, getSettings() returns stale/invalid data. const stream = this.recorder.getStream(); - const streamFPS = stream ? this.recorder.getFrameRate() : null; + const streamFps = stream ? this.recorder.getFrameRate() : null; this.recordedBlob = await this.recorder.stopRecording(); this.recorder.stopPreview(); // This stops all tracks - stream settings now invalid! @@ -425,24 +397,20 @@ export class WebcamPanel extends Node { this.setStatus("Estimating frame rate..."); try { // Pass the pre-captured stream FPS if available - if (streamFPS && streamFPS > 0) { + if (streamFps && streamFps > 0) { this.fpsEstimate = { - fps: Math.round(streamFPS), + fps: Math.round(streamFps), confidence: "high", method: "stream settings", }; } else { // Fallback to empirical measurement from the recorded video - this.fpsEstimate = await estimateVideoFrameRate( - this.reviewElement, - null, - ); + this.fpsEstimate = await estimateVideoFrameRate(this.reviewElement, null); } this.updateFPSEstimateDisplay(); // Set the estimated FPS as the initial value this.model.frameRateProperty.value = this.fpsEstimate.fps; - } catch (error) { - console.warn("Failed to estimate FPS:", error); + } catch (_error) { this.fpsEstimateText.string = ""; } @@ -452,17 +420,14 @@ export class WebcamPanel extends Node { private async goToPreview(): Promise { this.resetPreviewUI(); this._startButton.enabled = false; - await this.recorder.startPreview( - this.previewElement, - this.cameraSelect.value || undefined, - ); + await this.recorder.startPreview(this.previewElement, this.cameraSelect.value || undefined); this._startButton.enabled = true; } - private async useVideo( - cb: (blob: Blob, duration: number) => void, - ): Promise { - if (!this.recordedBlob) return; + private async useVideo(cb: (blob: Blob, duration: number) => void): Promise { + if (!this.recordedBlob) { + return; + } this.setStatus(this.webcamStrings.fixingMetadataStringProperty.value); let blob = this.recordedBlob; @@ -487,16 +452,11 @@ export class WebcamPanel extends Node { const { fps, confidence, method } = this.fpsEstimate; // Create confidence indicator - const confidenceSymbol = - confidence === "high" ? "✓" : confidence === "medium" ? "~" : "?"; + const confidenceSymbol = confidence === "high" ? "✓" : confidence === "medium" ? "~" : "?"; // Format the display string const confidenceText = - confidence === "high" - ? "High confidence" - : confidence === "medium" - ? "Medium confidence" - : "Low confidence"; + confidence === "high" ? "High confidence" : confidence === "medium" ? "Medium confidence" : "Low confidence"; this.fpsEstimateText.string = `Estimated: ${fps} fps ${confidenceSymbol} (${confidenceText}, ${method})`; } @@ -508,12 +468,7 @@ export class WebcamPanel extends Node { .toString() .padStart(2, "0"); const s = (secs % 60).toString().padStart(2, "0"); - this.setStatus( - this.webcamStrings.recordingStringProperty.value.replace( - "{{time}}", - `${m}:${s}`, - ), - ); + this.setStatus(this.webcamStrings.recordingStringProperty.value.replace("{{time}}", `${m}:${s}`)); }, 1000); } diff --git a/src/tracking/OpenCVTracker.ts b/src/tracking/OpenCVTracker.ts index 56115c4..fdf4bcc 100644 --- a/src/tracking/OpenCVTracker.ts +++ b/src/tracking/OpenCVTracker.ts @@ -29,15 +29,10 @@ interface MinMaxLocResult { } /** Typed surface of the OpenCV.js module used by this tracker. */ -interface CV { +interface Cv { // Constructors readonly Mat: new () => CvMat; - readonly Rect: new ( - x: number, - y: number, - width: number, - height: number, - ) => CvRect; + readonly Rect: new (x: number, y: number, width: number, height: number) => CvRect; // Factory from browser ImageData matFromImageData(imageData: ImageData): CvMat; @@ -47,12 +42,7 @@ interface CV { readonly COLOR_RGBA2GRAY: number; // Template matching - matchTemplate( - image: CvMat, - templ: CvMat, - result: CvMat, - method: number, - ): void; + matchTemplate(image: CvMat, templ: CvMat, result: CvMat, method: number): void; minMaxLoc(src: CvMat): MinMaxLocResult; readonly TM_CCOEFF_NORMED: number; @@ -62,17 +52,17 @@ interface CV { // ───────────────────────────────────────────────────────────────────────────── -let cvPromise: Promise | null = null; +let cvPromise: Promise | null = null; const CV_LOAD_TIMEOUT_MS = 30_000; /** Type predicate: confirms the WASM module has a usable `Mat` constructor. */ -function isCVReady(v: unknown): v is CV { +function isCvReady(v: unknown): v is Cv { // biome-ignore lint/complexity/useLiteralKeys: noPropertyAccessFromIndexSignature requires bracket notation for index signatures return typeof (v as Record)["Mat"] === "function"; } -function loadCV(): Promise { +function loadCv(): Promise { if (!cvPromise) { cvPromise = import("@techstark/opencv-js").then(async (mod) => { // The package ships no TypeScript typings so `mod` is `any`. Extract the @@ -85,26 +75,25 @@ function loadCV(): Promise { } // WASM may already be ready (e.g. in test environments). - if (isCVReady(cv)) { + if (isCvReady(cv)) { return cv; } // Wait for the Emscripten runtime to initialise, with a timeout so we // never hang indefinitely. - return new Promise((resolve, reject) => { + return new Promise((resolve, reject) => { const timer = setTimeout(() => { reject(new Error("OpenCV WASM initialisation timed out")); }, CV_LOAD_TIMEOUT_MS); - (cv as { onRuntimeInitialized?: () => void }).onRuntimeInitialized = - () => { - clearTimeout(timer); - if (isCVReady(cv)) { - resolve(cv); - } else { - reject(new Error("OpenCV module did not initialise correctly")); - } - }; + (cv as { onRuntimeInitialized?: () => void }).onRuntimeInitialized = () => { + clearTimeout(timer); + if (isCvReady(cv)) { + resolve(cv); + } else { + reject(new Error("OpenCV module did not initialise correctly")); + } + }; }); }); @@ -124,7 +113,7 @@ export type TrackerRegion = { x: number; y: number; w: number; h: number }; * each subsequent frame using normalised cross-correlation (TM_CCOEFF_NORMED). */ export class OpenCVTracker { - private cv: CV | null = null; + private cv: Cv | null = null; private templateMat: CvMat | null = null; private readonly offscreen: HTMLCanvasElement; private readonly ctx: CanvasRenderingContext2D; @@ -138,7 +127,9 @@ export class OpenCVTracker { this.offscreen.width = videoWidth; this.offscreen.height = videoHeight; const ctx = this.offscreen.getContext("2d"); - if (!ctx) throw new Error("Could not get 2D context from offscreen canvas"); + if (!ctx) { + throw new Error("Could not get 2D context from offscreen canvas"); + } this.ctx = ctx; } @@ -155,16 +146,9 @@ export class OpenCVTracker { private captureFrame(video: HTMLVideoElement): ImageData { this.ctx.drawImage(video, 0, 0); try { - return this.ctx.getImageData( - 0, - 0, - this.offscreen.width, - this.offscreen.height, - ); + 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.", - ); + const err = new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers."); throw Object.assign(err, { cause: e }); } } @@ -173,15 +157,12 @@ export class OpenCVTracker { * Capture the template from the currently visible video frame inside `region`, * loading OpenCV (WASM) on first call. */ - public async initFromVideo( - video: HTMLVideoElement, - region: TrackerRegion, - ): Promise { + public async initFromVideo(video: HTMLVideoElement, region: TrackerRegion): Promise { // Capture into a local const so TypeScript can narrow CV through the // subsequent captureFrame() call (class fields can't be narrowed across // method calls). // biome-ignore lint/suspicious/noAssignInExpressions: Assignment + local const needed for TypeScript narrowing - const cv = (this.cv = await loadCV()); + const cv = (this.cv = await loadCv()); const imageData = this.captureFrame(video); const frame = cv.matFromImageData(imageData); @@ -189,7 +170,9 @@ export class OpenCVTracker { try { cv.cvtColor(frame, gray, cv.COLOR_RGBA2GRAY); - if (this.templateMat) this.templateMat.delete(); + if (this.templateMat) { + this.templateMat.delete(); + } // Clamp the origin first, then use the clamped values when bounding the // width and height. Without this, a negative region.x / region.y makes @@ -197,12 +180,8 @@ export class OpenCVTracker { // read outside the source image and crash. const clampedX = Math.round(Math.max(0, region.x)); const clampedY = Math.round(Math.max(0, region.y)); - const roiW = Math.round( - Math.min(region.w, this.offscreen.width - clampedX), - ); - const roiH = Math.round( - Math.min(region.h, this.offscreen.height - clampedY), - ); + const roiW = Math.round(Math.min(region.w, this.offscreen.width - clampedX)); + const roiH = Math.round(Math.min(region.h, this.offscreen.height - clampedY)); if (roiW <= 0 || roiH <= 0) { throw new Error(`Invalid ROI dimensions: ${roiW}x${roiH}`); } @@ -223,18 +202,14 @@ export class OpenCVTracker { // remainder of the method (class fields can't be narrowed across calls). const cv = this.cv; const templateMat = this.templateMat; - if (!cv || !templateMat) return null; + if (!(cv && templateMat)) { + return null; + } let imageData: ImageData; try { imageData = this.captureFrame(video); - } catch (e) { - // Cross-origin video without CORS headers — skip this frame and warn - // so developers can diagnose the source of the failure. - console.warn( - "[OpenCVTracker] Frame capture failed — video may be cross-origin:", - e, - ); + } catch (_e) { return null; } const frame = cv.matFromImageData(imageData); diff --git a/src/webcam.ts b/src/webcam.ts index 87265bd..74fabdb 100644 --- a/src/webcam.ts +++ b/src/webcam.ts @@ -22,7 +22,9 @@ export interface FrameRateCapabilities { */ export function getFrameRateFromStream(stream: MediaStream): number | null { const track = stream.getVideoTracks()[0]; - if (!track) return null; + if (!track) { + return null; + } const settings = track.getSettings(); const rate = settings.frameRate; return typeof rate === "number" && Number.isFinite(rate) ? rate : null; @@ -32,15 +34,16 @@ export function getFrameRateFromStream(stream: MediaStream): number | null { * Get frame rate capabilities from a MediaStream's video track. * Returns null if no video track or capabilities are not available. */ -export function getFrameRateCapabilitiesFromStream( - stream: MediaStream, -): FrameRateCapabilities | null { +export function getFrameRateCapabilitiesFromStream(stream: MediaStream): FrameRateCapabilities | null { const track = stream.getVideoTracks()[0]; - if (!track) return null; + if (!track) { + return null; + } const caps = track.getCapabilities(); const fr = caps.frameRate; - if (!fr || typeof fr.min !== "number" || typeof fr.max !== "number") + if (!fr || typeof fr.min !== "number" || typeof fr.max !== "number") { return null; + } return { min: fr.min, max: fr.max }; } @@ -49,12 +52,7 @@ export function getFrameRateCapabilitiesFromStream( * Prioritizes WebM formats for best browser support, falls back to MP4. */ export function getSupportedMimeType(): string { - const types = [ - "video/webm;codecs=vp9", - "video/webm;codecs=vp8", - "video/webm", - "video/mp4", - ]; + const types = ["video/webm;codecs=vp9", "video/webm;codecs=vp8", "video/webm", "video/mp4"]; return types.find((type) => MediaRecorder.isTypeSupported(type)) || ""; } @@ -62,9 +60,7 @@ export function getSupportedMimeType(): string { * Fix WebM blob duration by seeking to the end to force browser to calculate it. * WebM files from MediaRecorder often have Infinity duration until seeked. */ -export function fixWebmDuration( - blob: Blob, -): Promise<{ blob: Blob; duration: number }> { +export function fixWebmDuration(blob: Blob): Promise<{ blob: Blob; duration: number }> { return new Promise((resolve, reject) => { const video = document.createElement("video"); video.preload = "metadata"; @@ -74,8 +70,8 @@ export function fixWebmDuration( // Shared cleanup: cancel the timeout and revoke the blob URL. // Called exactly once from whichever path settles the promise first. - const cleanup = (timeoutId: ReturnType) => { - clearTimeout(timeoutId); + const cleanup = (tid: ReturnType) => { + clearTimeout(tid); URL.revokeObjectURL(blobUrl); }; @@ -141,8 +137,7 @@ export class WebcamRecorder { track.stop(); } return true; - } catch (err) { - console.error("Camera permission denied:", err); + } catch (_err) { return false; } } @@ -161,17 +156,11 @@ export class WebcamRecorder { * @param deviceId - Optional device ID to use a specific camera * @param frameRate - Optional frame rate constraints (e.g. { ideal: 60, max: 60 }) */ - async startPreview( - previewEl: HTMLVideoElement, - deviceId?: string, - frameRate?: FrameRateConstraints, - ): Promise { + async startPreview(previewEl: HTMLVideoElement, deviceId?: string, frameRate?: FrameRateConstraints): Promise { // Stop any existing stream first this.stopPreview(); - const videoConstraints: MediaTrackConstraints = deviceId - ? { deviceId: { exact: deviceId } } - : {}; + const videoConstraints: MediaTrackConstraints = deviceId ? { deviceId: { exact: deviceId } } : {}; if (frameRate) { videoConstraints.frameRate = frameRate; } @@ -278,7 +267,9 @@ export class WebcamRecorder { */ getFrameRate(): number | null { const track = this.stream?.getVideoTracks()[0]; - if (!track) return null; + if (!track) { + return null; + } const settings = track.getSettings(); const rate = settings.frameRate; return typeof rate === "number" && Number.isFinite(rate) ? rate : null; @@ -290,11 +281,14 @@ export class WebcamRecorder { */ getFrameRateCapabilities(): FrameRateCapabilities | null { const track = this.stream?.getVideoTracks()[0]; - if (!track) return null; + if (!track) { + return null; + } const caps = track.getCapabilities(); const fr = caps.frameRate; - if (!fr || typeof fr.min !== "number" || typeof fr.max !== "number") + if (!fr || typeof fr.min !== "number" || typeof fr.max !== "number") { return null; + } return { min: fr.min, max: fr.max }; } @@ -335,10 +329,7 @@ export class WebcamRecorder { * @param durationMs - Duration to measure in ms (default 1000) * @returns Promise resolving to the measured FPS */ -export function measureEmpiricalFrameRate( - video: HTMLVideoElement, - durationMs: number = 1000, -): Promise { +export function measureEmpiricalFrameRate(video: HTMLVideoElement, durationMs: number = 1000): Promise { return new Promise((resolve, reject) => { if (video.readyState < 2) { reject(new Error("Video must be playing and have enough data")); @@ -383,10 +374,10 @@ export async function estimateVideoFrameRate( ): Promise { // Method 1: Try to get FPS from the stream settings (most reliable for webcam) if (stream) { - const streamFPS = getFrameRateFromStream(stream); - if (streamFPS && streamFPS > 0) { + const streamFps = getFrameRateFromStream(stream); + if (streamFps && streamFps > 0) { return { - fps: Math.round(streamFPS), + fps: Math.round(streamFps), confidence: "high", method: "stream settings", }; @@ -409,18 +400,16 @@ export async function estimateVideoFrameRate( } // Measure for 1 second - const empiricalFPS = await measureEmpiricalFrameRate(video, 1000); + const empiricalFps = await measureEmpiricalFrameRate(video, 1000); // Determine confidence based on how close to common frame rates const commonRates = [15, 24, 25, 29.97, 30, 50, 60]; - const roundedFPS = Math.round(empiricalFPS); + const roundedFps = Math.round(empiricalFps); const closestCommon = commonRates.reduce((prev, curr) => - Math.abs(curr - empiricalFPS) < Math.abs(prev - empiricalFPS) - ? curr - : prev, + Math.abs(curr - empiricalFps) < Math.abs(prev - empiricalFps) ? curr : prev, ); - const deviation = Math.abs(empiricalFPS - closestCommon); + const deviation = Math.abs(empiricalFps - closestCommon); let confidence: "high" | "medium" | "low" = "medium"; if (deviation < 1) { @@ -432,12 +421,12 @@ export async function estimateVideoFrameRate( } return { - fps: roundedFPS, + fps: roundedFps, confidence, method: "empirical measurement", }; - } catch (error) { - console.warn("Failed to measure empirical frame rate:", error); + } catch (_error) { + /* measurement failed — fall through to default */ } // Method 3: Fallback to default assumption diff --git a/tsconfig.json b/tsconfig.json index 74f493c..fbd8f4e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -10,7 +10,13 @@ "noUncheckedIndexedAccess": true, "noPropertyAccessFromIndexSignature": true, "noImplicitReturns": true, - "exactOptionalPropertyTypes": true + "exactOptionalPropertyTypes": true, + "noUnusedLocals": true, + "noUnusedParameters": true, + "noFallthroughCasesInSwitch": true, + "forceConsistentCasingInFileNames": true, + "isolatedModules": true, + "verbatimModuleSyntax": true }, "include": ["src"] } diff --git a/vite.config.js b/vite.config.js index 4432ba5..a1a9ec5 100644 --- a/vite.config.js +++ b/vite.config.js @@ -12,17 +12,21 @@ function serveVideos() { name: "serve-videos", configureServer(server) { server.middlewares.use((req, res, next) => { - if (!req.url?.startsWith("/videos/")) return next(); + if (!req.url?.startsWith("/videos/")) { + return next(); + } - const filename = decodeURIComponent( - req.url.slice("/videos/".length).split("?")[0], - ); + const filename = decodeURIComponent(req.url.slice("/videos/".length).split("?")[0]); const videosDir = path.resolve("videos"); const filePath = path.resolve(videosDir, filename); // Prevent directory traversal - if (!filePath.startsWith(videosDir + path.sep)) return next(); - if (!fs.existsSync(filePath)) return next(); + if (!filePath.startsWith(videosDir + path.sep)) { + return next(); + } + if (!fs.existsSync(filePath)) { + return next(); + } const stat = fs.statSync(filePath); const total = stat.size; @@ -57,7 +61,9 @@ function serveVideos() { closeBundle() { const src = path.resolve("videos"); const dest = path.resolve("dist", "videos"); - if (!fs.existsSync(dest)) fs.mkdirSync(dest, { recursive: true }); + if (!fs.existsSync(dest)) { + fs.mkdirSync(dest, { recursive: true }); + } for (const file of fs.readdirSync(src)) { fs.copyFileSync(path.join(src, file), path.join(dest, file)); }