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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions src/i18n/StringManager.ts
Original file line number Diff line number Diff line change
Expand Up @@ -196,6 +196,8 @@ export class StringManager {
showVelocityDescriptionStringProperty: ReadOnlyProperty<string>;
showAccelerationStringProperty: ReadOnlyProperty<string>;
showAccelerationDescriptionStringProperty: ReadOnlyProperty<string>;
enableMeasurementToolsStringProperty: ReadOnlyProperty<string>;
enableMeasurementToolsDescriptionStringProperty: ReadOnlyProperty<string>;
} {
return {
simulationStringProperty: this.stringProperties.preferences.simulationStringProperty,
Expand All @@ -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,
};
}

Expand Down
4 changes: 3 additions & 1 deletion src/i18n/strings_en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion src/i18n/strings_fr.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
11 changes: 11 additions & 0 deletions src/preferences/TrackLabPreferencesModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,18 +26,29 @@ 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);

// 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();
}
}
33 changes: 32 additions & 1 deletion src/preferences/TrackLabPreferencesNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
],
});
}
}
29 changes: 29 additions & 0 deletions src/screen-name/model/SimModel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down Expand Up @@ -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<Vector2>(TAPE_P1_INITIAL.copy());
public readonly tapPoint2Property = new Property<Vector2>(TAPE_P2_INITIAL.copy());

// ── Angle tool positions (view / pixel space) ─────────────────────────
public readonly angleVertexProperty = new Property<Vector2>(ANGLE_VERTEX_INITIAL.copy());
public readonly angleArm1Property = new Property<Vector2>(ANGLE_ARM1_INITIAL.copy());
public readonly angleArm2Property = new Property<Vector2>(ANGLE_ARM2_INITIAL.copy());

// ── Coordinate system tool state (view / pixel space) ─────────────────
public readonly coordOriginProperty = new Property<Vector2>(COORD_ORIGIN_INITIAL.copy());
public readonly coordAngleProperty = new NumberProperty(0);
Expand Down Expand Up @@ -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();
Expand Down
234 changes: 234 additions & 0 deletions src/screen-name/view/AngleToolNode.ts
Original file line number Diff line number Diff line change
@@ -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<boolean>, 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();
}
}
Loading