From d675d5ed1ef09cabcb0bf330c5e87ead22d802c6 Mon Sep 17 00:00:00 2001 From: Claude Date: Mon, 23 Feb 2026 23:12:28 +0000 Subject: [PATCH] Add measurement tools preference with measuring tape and angle tool overlays - Add "Enable Measurement Tools" checkbox to the Preferences dialog (TrackLabPreferencesModel + TrackLabPreferencesNode) - Add i18n strings for the new preference (EN + FR) - Add measurement tool state to SimModel: visibility flags and pixel-space position properties for both tools (tapPoint1/2, angleVertex/Arm1/Arm2) - Create MeasuringTapeNode: two draggable endpoints connected by a yellow dashed tape line; midpoint label shows real-world distance via MVT - Create AngleToolNode: vertex + two arm endpoints with a purple arc and degree label showing the interior angle - Create MeasurementToolsPanel: compact toggle panel (tape icon + angle icon checkboxes) positioned above the Info button, shown only when the preference is enabled - Wire everything into SimScreenView with DerivedProperty visibility guards (video must be loaded before overlays appear) https://claude.ai/code/session_019Lr1B4c4bUbQ4HgiZxG4Mb --- src/i18n/StringManager.ts | 5 + src/i18n/strings_en.json | 4 +- src/i18n/strings_fr.json | 4 +- src/preferences/TrackLabPreferencesModel.ts | 11 + src/preferences/TrackLabPreferencesNode.ts | 33 ++- src/screen-name/model/SimModel.ts | 29 +++ src/screen-name/view/AngleToolNode.ts | 234 ++++++++++++++++++ src/screen-name/view/MeasurementToolsPanel.ts | 123 +++++++++ src/screen-name/view/MeasuringTapeNode.ts | 165 ++++++++++++ src/screen-name/view/SimScreenView.ts | 28 +++ 10 files changed, 633 insertions(+), 3 deletions(-) create mode 100644 src/screen-name/view/AngleToolNode.ts create mode 100644 src/screen-name/view/MeasurementToolsPanel.ts create mode 100644 src/screen-name/view/MeasuringTapeNode.ts diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 6386305..7249383 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -196,6 +196,8 @@ export class StringManager { showVelocityDescriptionStringProperty: ReadOnlyProperty; showAccelerationStringProperty: ReadOnlyProperty; showAccelerationDescriptionStringProperty: ReadOnlyProperty; + enableMeasurementToolsStringProperty: ReadOnlyProperty; + enableMeasurementToolsDescriptionStringProperty: ReadOnlyProperty; } { return { simulationStringProperty: this.stringProperties.preferences.simulationStringProperty, @@ -207,6 +209,9 @@ export class StringManager { showAccelerationStringProperty: this.stringProperties.preferences.showAccelerationStringProperty, showAccelerationDescriptionStringProperty: this.stringProperties.preferences.showAccelerationDescriptionStringProperty, + enableMeasurementToolsStringProperty: this.stringProperties.preferences.enableMeasurementToolsStringProperty, + enableMeasurementToolsDescriptionStringProperty: + this.stringProperties.preferences.enableMeasurementToolsDescriptionStringProperty, }; } diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 145b0f7..d431a97 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -57,7 +57,9 @@ "showVelocity": "Show Velocity", "showVelocityDescription": "When enabled, velocity components (vx, vy, speed) are available as choices on the kinematics graph axes.", "showAcceleration": "Show Acceleration", - "showAccelerationDescription": "When enabled, acceleration components (ax, ay, |a|) are available as choices on the kinematics graph axes." + "showAccelerationDescription": "When enabled, acceleration components (ax, ay, |a|) are available as choices on the kinematics graph axes.", + "enableMeasurementTools": "Enable Measurement Tools", + "enableMeasurementToolsDescription": "Shows a measuring tape and angle tool panel for direct measurements on the video." }, "autoTracker": { "dragToSelect": "Drag on video to select object to track", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 0cdfe8e..dd97b59 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -57,7 +57,9 @@ "showVelocity": "Afficher la vitesse", "showVelocityDescription": "Lorsque activ\u00e9, les composantes de vitesse (vx, vy, rapidit\u00e9) sont disponibles comme choix sur les axes du graphique cin\u00e9matique.", "showAcceleration": "Afficher l'acc\u00e9l\u00e9ration", - "showAccelerationDescription": "Lorsque activ\u00e9, les composantes d'acc\u00e9l\u00e9ration (ax, ay, |a|) sont disponibles comme choix sur les axes du graphique cin\u00e9matique." + "showAccelerationDescription": "Lorsque activ\u00e9, les composantes d'acc\u00e9l\u00e9ration (ax, ay, |a|) sont disponibles comme choix sur les axes du graphique cin\u00e9matique.", + "enableMeasurementTools": "Activer les outils de mesure", + "enableMeasurementToolsDescription": "Affiche un panneau avec un ruban de mesure et un rapporteur pour des mesures directes sur la vid\u00e9o." }, "autoTracker": { "dragToSelect": "Faire glisser sur la vid\u00e9o pour s\u00e9lectionner l'objet \u00e0 suivre", diff --git a/src/preferences/TrackLabPreferencesModel.ts b/src/preferences/TrackLabPreferencesModel.ts index 1b19961..7b01fd2 100644 --- a/src/preferences/TrackLabPreferencesModel.ts +++ b/src/preferences/TrackLabPreferencesModel.ts @@ -26,6 +26,13 @@ export class TrackLabPreferencesModel { */ public readonly showAccelerationInGraphProperty: BooleanProperty; + /** + * Whether the measurement tools panel (measuring tape + angle tool) is visible. + * When false, the panel is completely hidden. + * When true, the panel appears above the info button. + */ + public readonly enableMeasurementToolsProperty: BooleanProperty; + public constructor() { // By default, auto-tracking checkbox is hidden this.enableAutoTrackingProperty = new BooleanProperty(false); @@ -33,11 +40,15 @@ export class TrackLabPreferencesModel { // By default, velocity and acceleration are shown on the graph this.showVelocityInGraphProperty = new BooleanProperty(true); this.showAccelerationInGraphProperty = new BooleanProperty(false); + + // By default, measurement tools are hidden + this.enableMeasurementToolsProperty = new BooleanProperty(false); } public reset(): void { this.enableAutoTrackingProperty.reset(); this.showVelocityInGraphProperty.reset(); this.showAccelerationInGraphProperty.reset(); + this.enableMeasurementToolsProperty.reset(); } } diff --git a/src/preferences/TrackLabPreferencesNode.ts b/src/preferences/TrackLabPreferencesNode.ts index a441e98..ac0b817 100644 --- a/src/preferences/TrackLabPreferencesNode.ts +++ b/src/preferences/TrackLabPreferencesNode.ts @@ -97,10 +97,41 @@ export class TrackLabPreferencesNode extends VBox { }, ); + const enableMeasurementToolsCheckbox = new Checkbox( + preferencesModel.enableMeasurementToolsProperty, + new VBox({ + align: "left", + spacing: 2, + children: [ + new Text(prefStrings.enableMeasurementToolsStringProperty, { + font: new PhetFont(14), + fill: TrackLabColors.preferencesTextProperty, + }), + new Text(prefStrings.enableMeasurementToolsDescriptionStringProperty, { + font: new PhetFont(11), + fill: TrackLabColors.preferencesTextSecondaryProperty, + maxWidth: 500, + }), + ], + }), + { + checkboxColor: TrackLabColors.checkboxPreferencesColorProperty, + checkboxColorBackground: TrackLabColors.checkboxPreferencesColorBackgroundProperty, + spacing: 8, + }, + ); + super({ align: "left", spacing: 12, - children: [header, new HStrut(600), enableAutoTrackingCheckbox, showVelocityCheckbox, showAccelerationCheckbox], + children: [ + header, + new HStrut(600), + enableAutoTrackingCheckbox, + showVelocityCheckbox, + showAccelerationCheckbox, + enableMeasurementToolsCheckbox, + ], }); } } diff --git a/src/screen-name/model/SimModel.ts b/src/screen-name/model/SimModel.ts index d8663a2..4539f79 100644 --- a/src/screen-name/model/SimModel.ts +++ b/src/screen-name/model/SimModel.ts @@ -46,6 +46,15 @@ const CALIB_CENTER_INITIAL = new Vector2(VIDEO_CENTER_X, VIDEO_CENTER_Y + VIDEO_ const CALIB_P1_INITIAL = CALIB_CENTER_INITIAL.plusXY(-CALIB_HALF_LENGTH, 0); const CALIB_P2_INITIAL = CALIB_CENTER_INITIAL.plusXY(CALIB_HALF_LENGTH, 0); +// ── Initial measuring tape positions (view / pixel space) ───────────────── +const TAPE_P1_INITIAL = new Vector2(VIDEO_CENTER_X - 90, VIDEO_CENTER_Y + 100); +const TAPE_P2_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 100); + +// ── Initial angle tool positions (view / pixel space) ───────────────────── +const ANGLE_VERTEX_INITIAL = new Vector2(VIDEO_CENTER_X, VIDEO_CENTER_Y + 80); +const ANGLE_ARM1_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 20); +const ANGLE_ARM2_INITIAL = new Vector2(VIDEO_CENTER_X + 90, VIDEO_CENTER_Y + 140); + // ── Bounds for clamping the coordinate-system origin ───────────────────────── // The origin must stay within the video area so the axes are always visible. // These are layout / pixel-space bounds, matching the view-layer video rectangle. @@ -127,6 +136,19 @@ export class SimModel { public readonly magnifyVideoProperty = new BooleanProperty(false); public readonly autoTrackingProperty = new BooleanProperty(false); + // ── Measurement tools visibility ────────────────────────────────────── + public readonly measuringTapeVisibleProperty = new BooleanProperty(false); + public readonly angleToolVisibleProperty = new BooleanProperty(false); + + // ── Measuring tape endpoint positions (view / pixel space) ──────────── + public readonly tapPoint1Property = new Property(TAPE_P1_INITIAL.copy()); + public readonly tapPoint2Property = new Property(TAPE_P2_INITIAL.copy()); + + // ── Angle tool positions (view / pixel space) ───────────────────────── + public readonly angleVertexProperty = new Property(ANGLE_VERTEX_INITIAL.copy()); + public readonly angleArm1Property = new Property(ANGLE_ARM1_INITIAL.copy()); + public readonly angleArm2Property = new Property(ANGLE_ARM2_INITIAL.copy()); + // ── Coordinate system tool state (view / pixel space) ───────────────── public readonly coordOriginProperty = new Property(COORD_ORIGIN_INITIAL.copy()); public readonly coordAngleProperty = new NumberProperty(0); @@ -436,6 +458,13 @@ export class SimModel { this.calibrationVisibleProperty.reset(); this.magnifyVideoProperty.reset(); this.autoTrackingProperty.reset(); + this.measuringTapeVisibleProperty.reset(); + this.angleToolVisibleProperty.reset(); + this.tapPoint1Property.reset(); + this.tapPoint2Property.reset(); + this.angleVertexProperty.reset(); + this.angleArm1Property.reset(); + this.angleArm2Property.reset(); this.coordOriginProperty.reset(); this.coordAngleProperty.reset(); this.calibPoint1Property.reset(); diff --git a/src/screen-name/view/AngleToolNode.ts b/src/screen-name/view/AngleToolNode.ts new file mode 100644 index 0000000..cdb8d93 --- /dev/null +++ b/src/screen-name/view/AngleToolNode.ts @@ -0,0 +1,234 @@ +/** + * AngleToolNode.ts + * + * A custom angle measurement overlay for video analysis. Three draggable + * handles — a vertex and two arm endpoints — define two rays. An arc drawn at + * the vertex shows the enclosed angle, and a label displays the value in degrees. + * + * The vertex is drawn as a slightly larger filled circle; arm endpoints are + * smaller open circles. All three handles are independently draggable. + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Multilink } from "scenerystack/axon"; +import { Shape } from "scenerystack/kite"; +import { Circle, Line, Node, Path, RichDragListener, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { Panel } from "scenerystack/sun"; +import { Tandem } from "scenerystack/tandem"; +import TrackLabColors from "../../TrackLabColors.js"; +import type { SimModel } from "../model/SimModel.js"; + +// ── Visual constants ────────────────────────────────────────────────────────── +const ARM_COLOR = "rgb(170, 100, 255)"; +const ARM_SHADOW_COLOR = "rgba(0, 0, 0, 0.45)"; +const ARM_LINE_WIDTH = 2.5; +const ARM_SHADOW_WIDTH = 5; +const ARM_DASH: number[] = [8, 4]; + +const ARC_RADIUS = 28; +const ARC_LINE_WIDTH = 2; + +const VERTEX_RADIUS = 7; +const ENDPOINT_RADIUS = 5; +const TOUCH_DILATION = 12; + +const FONT = new PhetFont({ size: 12, weight: "bold" }); +const LABEL_OFFSET = ARC_RADIUS + 20; // distance from vertex to label centre + +const DRAG_SPEED = 200; +const SHIFT_DRAG_SPEED = 40; + +// Minimum arm length below which the arc and label are hidden to avoid +// degenerate geometry (zero-length arm → atan2 is undefined). +const MIN_ARM_LENGTH = 5; + +/** + * Three-handle angle tool. Drag the vertex to reposition the whole tool's + * origin; drag each arm tip to change the angle measured. + */ +export class AngleToolNode extends Node { + private readonly disposeAngleToolNode: () => void; + + public constructor(visibleProperty: TReadOnlyProperty, model: SimModel) { + super(); + + // ── Arm shadow lines ────────────────────────────────────────────────── + const shadow1 = new Line(0, 0, 0, 0, { + stroke: ARM_SHADOW_COLOR, + lineWidth: ARM_SHADOW_WIDTH, + }); + const shadow2 = new Line(0, 0, 0, 0, { + stroke: ARM_SHADOW_COLOR, + lineWidth: ARM_SHADOW_WIDTH, + }); + this.addChild(shadow1); + this.addChild(shadow2); + + // ── Arm lines ───────────────────────────────────────────────────────── + const arm1Line = new Line(0, 0, 0, 0, { + stroke: ARM_COLOR, + lineWidth: ARM_LINE_WIDTH, + lineDash: ARM_DASH, + }); + const arm2Line = new Line(0, 0, 0, 0, { + stroke: ARM_COLOR, + lineWidth: ARM_LINE_WIDTH, + lineDash: ARM_DASH, + }); + this.addChild(arm1Line); + this.addChild(arm2Line); + + // ── Arc at vertex ───────────────────────────────────────────────────── + const arcPath = new Path(null, { + stroke: ARM_COLOR, + lineWidth: ARC_LINE_WIDTH, + }); + this.addChild(arcPath); + + // ── Vertex handle ───────────────────────────────────────────────────── + const vertexNode = new Circle(VERTEX_RADIUS, { + fill: ARM_COLOR, + stroke: "rgba(0, 0, 0, 0.65)", + lineWidth: 1.5, + cursor: "grab", + tagName: "div", + focusable: true, + accessibleName: "Angle tool vertex", + }); + const vertexTouchArea = Shape.circle(0, 0, VERTEX_RADIUS + TOUCH_DILATION); + vertexNode.mouseArea = vertexTouchArea; + vertexNode.touchArea = vertexTouchArea; + this.addChild(vertexNode); + + // ── Arm endpoint handles ────────────────────────────────────────────── + const makeArmEndpoint = (accessibleName: string) => { + const node = new Circle(ENDPOINT_RADIUS, { + fill: "transparent", + stroke: ARM_COLOR, + lineWidth: 2, + cursor: "crosshair", + tagName: "div", + focusable: true, + accessibleName, + }); + const touchArea = Shape.circle(0, 0, ENDPOINT_RADIUS + TOUCH_DILATION); + node.mouseArea = touchArea; + node.touchArea = touchArea; + return node; + }; + const arm1Node = makeArmEndpoint("Angle arm 1"); + const arm2Node = makeArmEndpoint("Angle arm 2"); + this.addChild(arm1Node); + this.addChild(arm2Node); + + // ── Angle label ─────────────────────────────────────────────────────── + const angleText = new Text("---", { + font: FONT, + fill: TrackLabColors.textOnDarkProperty, + }); + const labelPanel = new Panel(angleText, { + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, + cornerRadius: 4, + xMargin: 6, + yMargin: 3, + }); + labelPanel.setScaleMagnitude(0.8); + this.addChild(labelPanel); + + // ── Geometry multilink ──────────────────────────────────────────────── + const geometryMultilink = Multilink.multilink( + [model.angleVertexProperty, model.angleArm1Property, model.angleArm2Property], + (vertex, arm1, arm2) => { + // Lines + shadow1.setLine(vertex.x, vertex.y, arm1.x, arm1.y); + shadow2.setLine(vertex.x, vertex.y, arm2.x, arm2.y); + arm1Line.setLine(vertex.x, vertex.y, arm1.x, arm1.y); + arm2Line.setLine(vertex.x, vertex.y, arm2.x, arm2.y); + + // Handle positions + vertexNode.translation = vertex; + arm1Node.translation = arm1; + arm2Node.translation = arm2; + + // Arm vectors relative to vertex + const v1 = arm1.minus(vertex); + const v2 = arm2.minus(vertex); + const len1 = v1.magnitude; + const len2 = v2.magnitude; + + if (len1 < MIN_ARM_LENGTH || len2 < MIN_ARM_LENGTH) { + arcPath.shape = null; + labelPanel.visible = false; + return; + } + + labelPanel.visible = true; + + // Arc spanning from arm1 direction to arm2 direction (shorter arc) + const a1 = Math.atan2(v1.y, v1.x); + const a2 = Math.atan2(v2.y, v2.x); + let diff = a2 - a1; + // Normalise diff to [-π, π] so we always draw the interior angle + while (diff > Math.PI) { + diff -= 2 * Math.PI; + } + while (diff < -Math.PI) { + diff += 2 * Math.PI; + } + + const anticlockwise = diff < 0; + arcPath.shape = new Shape().arc(vertex.x, vertex.y, ARC_RADIUS, a1, a2, anticlockwise); + + // Angle value + const angleDeg = (Math.abs(diff) * 180) / Math.PI; + angleText.string = `${angleDeg.toFixed(1)}\u00b0`; + + // Label along the bisector, outside the arc + const bisectorAngle = a1 + diff / 2; + labelPanel.centerX = vertex.x + LABEL_OFFSET * Math.cos(bisectorAngle); + labelPanel.centerY = vertex.y + LABEL_OFFSET * Math.sin(bisectorAngle); + }, + ); + + // ── Drag listeners ──────────────────────────────────────────────────── + vertexNode.addInputListener( + new RichDragListener({ + positionProperty: model.angleVertexProperty, + keyboardDragListenerOptions: { dragSpeed: DRAG_SPEED, shiftDragSpeed: SHIFT_DRAG_SPEED }, + tandem: Tandem.OPT_OUT, + }), + ); + arm1Node.addInputListener( + new RichDragListener({ + positionProperty: model.angleArm1Property, + keyboardDragListenerOptions: { dragSpeed: DRAG_SPEED, shiftDragSpeed: SHIFT_DRAG_SPEED }, + tandem: Tandem.OPT_OUT, + }), + ); + arm2Node.addInputListener( + new RichDragListener({ + positionProperty: model.angleArm2Property, + keyboardDragListenerOptions: { dragSpeed: DRAG_SPEED, shiftDragSpeed: SHIFT_DRAG_SPEED }, + tandem: Tandem.OPT_OUT, + }), + ); + + // ── Visibility ──────────────────────────────────────────────────────── + const visibleListener = (visible: boolean) => { + this.visible = visible; + }; + visibleProperty.link(visibleListener); + + this.disposeAngleToolNode = () => { + geometryMultilink.dispose(); + visibleProperty.unlink(visibleListener); + }; + } + + public override dispose(): void { + this.disposeAngleToolNode(); + super.dispose(); + } +} diff --git a/src/screen-name/view/MeasurementToolsPanel.ts b/src/screen-name/view/MeasurementToolsPanel.ts new file mode 100644 index 0000000..406b267 --- /dev/null +++ b/src/screen-name/view/MeasurementToolsPanel.ts @@ -0,0 +1,123 @@ +/** + * MeasurementToolsPanel.ts + * + * A compact panel positioned above the Info button that houses two toggle + * checkboxes — one for the measuring tape overlay, one for the angle tool + * overlay. Each checkbox uses an icon-only label styled consistently with + * the main ControlPanel. + * + * Visibility of this panel is driven by the "Enable Measurement Tools" + * preference flag (TrackLabPreferencesModel.enableMeasurementToolsProperty). + */ + +import { Shape } from "scenerystack/kite"; +import { Circle, Line, Node, Path, VBox } from "scenerystack/scenery"; +import { Checkbox, Panel } from "scenerystack/sun"; +import TrackLabColors from "../../TrackLabColors.js"; +import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; +import type { SimModel } from "../model/SimModel.js"; + +const ICON_SIZE = 20; +const PANEL_ROWS_SPACING = 12; +const PANEL_X_MARGIN = 12; +const PANEL_Y_MARGIN = 12; + +// ── Icons ───────────────────────────────────────────────────────────────────── + +/** Measuring tape: a horizontal line with a reel circle at one end and a tick + * mark at each end plus the midpoint to evoke a tape measure. */ +function measuringTapeIcon(): Node { + const cy = ICON_SIZE / 2; + const color = "rgb(240, 185, 55)"; + const x0 = 1; + const x1 = ICON_SIZE - 1; + const xMid = ICON_SIZE / 2; + return new Node({ + children: [ + // Tape body line + new Line(x0, cy, x1, cy, { stroke: color, lineWidth: 2.5 }), + // Reel circle (left end) + new Circle(4, { + fill: color, + stroke: "rgba(0,0,0,0.5)", + lineWidth: 1, + x: x0, + y: cy, + }), + // Inner dot of reel + new Circle(1.5, { fill: "rgba(0,0,0,0.5)", x: x0, y: cy }), + // Right tip circle + new Circle(2.5, { + fill: color, + stroke: "rgba(0,0,0,0.5)", + lineWidth: 1, + x: x1, + y: cy, + }), + // Tick marks at left, mid, right + new Line(x0, cy - 4, x0, cy + 4, { stroke: color, lineWidth: 1.5 }), + new Line(xMid, cy - 2.5, xMid, cy + 2.5, { stroke: color, lineWidth: 1.5 }), + new Line(x1, cy - 4, x1, cy + 4, { stroke: color, lineWidth: 1.5 }), + ], + }); +} + +/** Angle tool: two lines meeting at a bottom-left vertex with a small arc + * showing the angle between them. */ +function angleToolIcon(): Node { + const color = "rgb(170, 100, 255)"; + // Vertex at bottom-left, arm1 goes up-right, arm2 goes right + const vx = 2; + const vy = ICON_SIZE - 2; + const a1x = ICON_SIZE - 2; + const a1y = 2; + const a2x = ICON_SIZE - 2; + const a2y = ICON_SIZE - 2; + const arcRadius = 7; + const angle1 = Math.atan2(a1y - vy, a1x - vx); + const angle2 = Math.atan2(a2y - vy, a2x - vx); + const arcShape = new Shape().arc(vx, vy, arcRadius, angle1, angle2, false); + return new Node({ + children: [ + new Line(vx, vy, a1x, a1y, { stroke: color, lineWidth: 1.5 }), + new Line(vx, vy, a2x, a2y, { stroke: color, lineWidth: 1.5 }), + new Path(arcShape, { stroke: color, lineWidth: 1.5 }), + ], + }); +} + +// ── Helper ───────────────────────────────────────────────────────────────────── + +function makeRow(icon: Node, property: SimModel["measuringTapeVisibleProperty"]): Checkbox { + return new Checkbox(property, icon, { + checkboxColor: TrackLabColors.checkboxColorProperty, + checkboxColorBackground: TrackLabColors.checkboxColorBackgroundProperty, + }); +} + +// ── MeasurementToolsPanel ──────────────────────────────────────────────────── + +/** + * Compact toggle panel for the measuring tape and angle tool overlays. + * Positioned above the Info button by SimScreenView. + */ +export class MeasurementToolsPanel extends Panel { + public constructor(model: SimModel) { + const rows = new VBox({ + children: [ + makeRow(measuringTapeIcon(), model.measuringTapeVisibleProperty), + makeRow(angleToolIcon(), model.angleToolVisibleProperty), + ], + spacing: PANEL_ROWS_SPACING, + align: "left", + }); + + super(rows, { + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, + }); + } +} diff --git a/src/screen-name/view/MeasuringTapeNode.ts b/src/screen-name/view/MeasuringTapeNode.ts new file mode 100644 index 0000000..9c5e4d0 --- /dev/null +++ b/src/screen-name/view/MeasuringTapeNode.ts @@ -0,0 +1,165 @@ +/** + * MeasuringTapeNode.ts + * + * A custom measuring tape overlay for video analysis. Two draggable endpoints + * connected by a tape-styled line; a label at the midpoint displays the + * real-world distance computed via the current model-view transform. + * + * The reel endpoint (endpoint 1) is slightly larger and has a centre dot to + * visually distinguish it from the tip (endpoint 2). + */ + +import type { TReadOnlyProperty } from "scenerystack/axon"; +import { Multilink } from "scenerystack/axon"; +import { Shape } from "scenerystack/kite"; +import { Circle, Line, Node, RichDragListener, Text } from "scenerystack/scenery"; +import { PhetFont } from "scenerystack/scenery-phet"; +import { Panel } from "scenerystack/sun"; +import { Tandem } from "scenerystack/tandem"; +import TrackLabColors from "../../TrackLabColors.js"; +import type { SimModel } from "../model/SimModel.js"; + +// ── Visual constants ────────────────────────────────────────────────────────── +const TAPE_COLOR = "rgb(240, 185, 55)"; +const TAPE_SHADOW_COLOR = "rgba(0, 0, 0, 0.45)"; +const TAPE_LINE_WIDTH = 4; +const TAPE_SHADOW_WIDTH = 7; +const TAPE_DASH: number[] = [10, 5]; + +const REEL_RADIUS = 7; +const TIP_RADIUS = 5; +const TOUCH_DILATION = 12; + +const FONT = new PhetFont({ size: 12, weight: "bold" }); +const LABEL_Y_OFFSET = 16; // pixels above the midpoint + +const DRAG_SPEED = 200; +const SHIFT_DRAG_SPEED = 40; + +/** + * Two-endpoint measuring tape overlay. The reel (endpoint 1) and tip (endpoint 2) + * are each independently draggable. A real-world distance label sits above the + * midpoint of the tape. + */ +export class MeasuringTapeNode extends Node { + private readonly disposeMeasuringTapeNode: () => void; + + public constructor(visibleProperty: TReadOnlyProperty, model: SimModel) { + super(); + + // ── Shadow line (contrast on any background) ────────────────────────── + const shadowLine = new Line(0, 0, 0, 0, { + stroke: TAPE_SHADOW_COLOR, + lineWidth: TAPE_SHADOW_WIDTH, + }); + this.addChild(shadowLine); + + // ── Main tape line ──────────────────────────────────────────────────── + const tapeLine = new Line(0, 0, 0, 0, { + stroke: TAPE_COLOR, + lineWidth: TAPE_LINE_WIDTH, + lineDash: TAPE_DASH, + }); + this.addChild(tapeLine); + + // ── Reel endpoint (base of tape) ────────────────────────────────────── + const reelCircle = new Circle(REEL_RADIUS, { + fill: TAPE_COLOR, + stroke: "rgba(0, 0, 0, 0.65)", + lineWidth: 1.5, + cursor: "grab", + tagName: "div", + focusable: true, + accessibleName: "Measuring tape base", + }); + const reelDot = new Circle(2.5, { fill: "rgba(0, 0, 0, 0.65)" }); + const reelNode = new Node({ children: [reelCircle, reelDot] }); + const reelTouchArea = Shape.circle(0, 0, REEL_RADIUS + TOUCH_DILATION); + reelCircle.mouseArea = reelTouchArea; + reelCircle.touchArea = reelTouchArea; + this.addChild(reelNode); + + // ── Tip endpoint ────────────────────────────────────────────────────── + const tipNode = new Circle(TIP_RADIUS, { + fill: TAPE_COLOR, + stroke: "rgba(0, 0, 0, 0.65)", + lineWidth: 1.5, + cursor: "crosshair", + tagName: "div", + focusable: true, + accessibleName: "Measuring tape tip", + }); + const tipTouchArea = Shape.circle(0, 0, TIP_RADIUS + TOUCH_DILATION); + tipNode.mouseArea = tipTouchArea; + tipNode.touchArea = tipTouchArea; + this.addChild(tipNode); + + // ── Distance label ──────────────────────────────────────────────────── + const distanceText = new Text("---", { + font: FONT, + fill: TrackLabColors.textOnDarkProperty, + }); + const labelPanel = new Panel(distanceText, { + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, + cornerRadius: 4, + xMargin: 6, + yMargin: 3, + }); + labelPanel.setScaleMagnitude(0.8); + this.addChild(labelPanel); + + // ── Geometry + label multilinks ─────────────────────────────────────── + const geometryMultilink = Multilink.multilink([model.tapPoint1Property, model.tapPoint2Property], (p1, p2) => { + shadowLine.setLine(p1.x, p1.y, p2.x, p2.y); + tapeLine.setLine(p1.x, p1.y, p2.x, p2.y); + reelNode.translation = p1; + tipNode.translation = p2; + const mid = p1.blend(p2, 0.5); + labelPanel.centerX = mid.x; + labelPanel.bottom = mid.y - LABEL_Y_OFFSET; + }); + + const labelMultilink = Multilink.multilink( + [model.tapPoint1Property, model.tapPoint2Property, model.modelViewTransformProperty, model.calibUnitProperty], + (p1, p2, mvt, unit) => { + const m1 = mvt.inversePosition2(p1); + const m2 = mvt.inversePosition2(p2); + distanceText.string = `${m1.distance(m2).toFixed(2)} ${unit}`; + }, + ); + + // ── Drag listeners ──────────────────────────────────────────────────── + reelCircle.addInputListener( + new RichDragListener({ + positionProperty: model.tapPoint1Property, + keyboardDragListenerOptions: { dragSpeed: DRAG_SPEED, shiftDragSpeed: SHIFT_DRAG_SPEED }, + tandem: Tandem.OPT_OUT, + }), + ); + tipNode.addInputListener( + new RichDragListener({ + positionProperty: model.tapPoint2Property, + keyboardDragListenerOptions: { dragSpeed: DRAG_SPEED, shiftDragSpeed: SHIFT_DRAG_SPEED }, + tandem: Tandem.OPT_OUT, + }), + ); + + // ── Visibility ──────────────────────────────────────────────────────── + const visibleListener = (visible: boolean) => { + this.visible = visible; + }; + visibleProperty.link(visibleListener); + + this.disposeMeasuringTapeNode = () => { + geometryMultilink.dispose(); + labelMultilink.dispose(); + visibleProperty.unlink(visibleListener); + }; + } + + public override dispose(): void { + this.disposeMeasuringTapeNode(); + super.dispose(); + } +} diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 5244863..b1b2e95 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -14,12 +14,15 @@ import { Tandem } from "scenerystack/tandem"; import type { TrackLabPreferencesModel } from "../../preferences/TrackLabPreferencesModel.js"; import { CONTROL_PANEL_LEFT_MARGIN, DATA_TABLE_TOP_SPACING, RESET_BUTTON_MARGIN } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; +import { AngleToolNode } from "./AngleToolNode.js"; import { CalibrationToolNode } from "./CalibrationToolNode.js"; import { ControlPanel } from "./ControlPanel.js"; import { CoordinateSystemNode } from "./CoordinateSystemNode.js"; import { DataTableNode } from "./DataTableNode.js"; import { InfoDialogNode } from "./InfoDialogNode.js"; import { KinematicsGraphNode } from "./KinematicsGraphNode.js"; +import { MeasurementToolsPanel } from "./MeasurementToolsPanel.js"; +import { MeasuringTapeNode } from "./MeasuringTapeNode.js"; import { TrackListPanel } from "./TrackListPanel.js"; import { VideoPlayerNode } from "./VideoPlayerNode.js"; @@ -56,6 +59,14 @@ export class SimScreenView extends ScreenView { [model.videoLoadedProperty, model.calibrationVisibleProperty], (loaded, visible) => loaded && visible, ); + const measuringTapeShownProperty = new DerivedProperty( + [model.videoLoadedProperty, model.measuringTapeVisibleProperty], + (loaded, visible) => loaded && visible, + ); + const angleToolShownProperty = new DerivedProperty( + [model.videoLoadedProperty, model.angleToolVisibleProperty], + (loaded, visible) => loaded && visible, + ); // ── Control panel / tool checkboxes (upper left) ─────────────────────── const controlPanel = new ControlPanel(model, trackLabPreferences); @@ -91,6 +102,14 @@ export class SimScreenView extends ScreenView { const calibrationToolNode = new CalibrationToolNode(calibrationShownProperty, this, model); this.addChild(calibrationToolNode); + // ── Measuring tape overlay (above video) ────────────────────────────── + const measuringTapeNode = new MeasuringTapeNode(measuringTapeShownProperty, model); + this.addChild(measuringTapeNode); + + // ── Angle tool overlay (above video) ───────────────────────────────── + const angleToolNode = new AngleToolNode(angleToolShownProperty, model); + this.addChild(angleToolNode); + // ── Data table (top right, shifts left when window is wider than layoutBounds) ─ const dataTableNode = new DataTableNode(model, model.videoLoadedProperty, model.calibUnitProperty); this.addChild(dataTableNode); @@ -132,6 +151,11 @@ export class SimScreenView extends ScreenView { }); this.addChild(infoButton); + // ── Measurement tools panel (above the info button, preference-gated) ─ + const measurementToolsPanel = new MeasurementToolsPanel(model); + measurementToolsPanel.visibleProperty = trackLabPreferences.enableMeasurementToolsProperty; + this.addChild(measurementToolsPanel); + // ── Webcam panel (topmost when visible, above coord/calibration overlays) ─ const webcamPanel = this.videoPlayerNode.webcamPanel; this.addChild(webcamPanel); @@ -153,6 +177,10 @@ export class SimScreenView extends ScreenView { infoButton.left = visibleBounds.minX + RESET_BUTTON_MARGIN; infoButton.centerY = resetAllButton.centerY; + // Measurement tools panel: above the info button, left-aligned with it. + measurementToolsPanel.left = infoButton.left; + measurementToolsPanel.bottom = infoButton.top - RESET_BUTTON_MARGIN; + // Info dialog: centered horizontally, positioned just above the info button. infoDialogNode.centerX = this.layoutBounds.centerX; infoDialogNode.bottom = infoButton.top - RESET_BUTTON_MARGIN;