diff --git a/src/TrackLabColors.ts b/src/TrackLabColors.ts index 55a88da..30970d7 100644 --- a/src/TrackLabColors.ts +++ b/src/TrackLabColors.ts @@ -90,16 +90,8 @@ const TrackLabColors = { ), // Axes (X red, Y green) - axisXColorProperty: profileColor( - "axisX", - new Color(255, 68, 68, 0.85), - new Color(238, 51, 51, 0.85), - ), - axisYColorProperty: profileColor( - "axisY", - new Color(68, 204, 68, 0.85), - new Color(51, 187, 51, 0.85), - ), + axisXColorProperty: profileColor("axisX", new Color(255, 68, 68, 0.85), new Color(238, 51, 51, 0.85)), + axisYColorProperty: profileColor("axisY", new Color(68, 204, 68, 0.85), new Color(51, 187, 51, 0.85)), // Calibration tool (bright colors with shadows for visibility on all backgrounds) calibrationFillProperty: profileColor( diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index 87d6b7f..6386305 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -421,6 +421,41 @@ export class StringManager { }; } + /** + * Get info dialog string properties + */ + public getInfoDialog(): { + titleStringProperty: ReadOnlyProperty; + loadVideoTitleStringProperty: ReadOnlyProperty; + loadVideoBodyStringProperty: ReadOnlyProperty; + coordinateSystemTitleStringProperty: ReadOnlyProperty; + coordinateSystemBodyStringProperty: ReadOnlyProperty; + calibrationTitleStringProperty: ReadOnlyProperty; + calibrationBodyStringProperty: ReadOnlyProperty; + addTrackTitleStringProperty: ReadOnlyProperty; + addTrackBodyStringProperty: ReadOnlyProperty; + digitizeTitleStringProperty: ReadOnlyProperty; + digitizeBodyStringProperty: ReadOnlyProperty; + autoTrackTitleStringProperty: ReadOnlyProperty; + autoTrackBodyStringProperty: ReadOnlyProperty; + } { + return { + titleStringProperty: this.stringProperties.infoDialog.titleStringProperty, + loadVideoTitleStringProperty: this.stringProperties.infoDialog.loadVideoTitleStringProperty, + loadVideoBodyStringProperty: this.stringProperties.infoDialog.loadVideoBodyStringProperty, + coordinateSystemTitleStringProperty: this.stringProperties.infoDialog.coordinateSystemTitleStringProperty, + coordinateSystemBodyStringProperty: this.stringProperties.infoDialog.coordinateSystemBodyStringProperty, + calibrationTitleStringProperty: this.stringProperties.infoDialog.calibrationTitleStringProperty, + calibrationBodyStringProperty: this.stringProperties.infoDialog.calibrationBodyStringProperty, + addTrackTitleStringProperty: this.stringProperties.infoDialog.addTrackTitleStringProperty, + addTrackBodyStringProperty: this.stringProperties.infoDialog.addTrackBodyStringProperty, + digitizeTitleStringProperty: this.stringProperties.infoDialog.digitizeTitleStringProperty, + digitizeBodyStringProperty: this.stringProperties.infoDialog.digitizeBodyStringProperty, + autoTrackTitleStringProperty: this.stringProperties.infoDialog.autoTrackTitleStringProperty, + autoTrackBodyStringProperty: this.stringProperties.infoDialog.autoTrackBodyStringProperty, + }; + } + /** * Get video file label string properties */ diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 622bec5..2423408 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -138,6 +138,21 @@ "pucksCollide": "Pucks Collide", "springWars": "Spring Wars" }, + "infoDialog": { + "title": "How to Digitize a Track", + "loadVideoTitle": "1. Load a Video", + "loadVideoBody": "Select a video from the dropdown at the top \u2014 choose a sample clip, a webcam recording, or an uploaded file.", + "coordinateSystemTitle": "2. Set Up the Coordinate System", + "coordinateSystemBody": "Enable Axes in the control panel. Drag the origin to set the (0, 0) point, then rotate the axes to align with your scene.", + "calibrationTitle": "3. Calibrate the Scale", + "calibrationBody": "Enable Calibration in the control panel. Drag the two endpoints onto a known reference distance in the video, then enter the real-world length.", + "addTrackTitle": "4. Add a Track", + "addTrackBody": "In the Tracks panel, click \u201c+ Add Track\u201d to create a new track labelled A, B, C, \u2026", + "digitizeTitle": "5. Digitize Frame by Frame", + "digitizeBody": "Select a track and click on the object\u2019s position in the video. The video advances one frame automatically after each click.", + "autoTrackTitle": "6. Auto-Track (optional)", + "autoTrackBody": "Enable Auto-Tracking in the control panel, then drag a selection box around the object. TrackLab will track it across the remaining frames." + }, "a11y": { "videoPlayer": "Video player", "videoScrubber": "Video timeline — drag to seek", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 8bb4aba..d6e82a6 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -138,6 +138,21 @@ "pucksCollide": "Rondelles en collision", "springWars": "Guerres de ressorts" }, + "infoDialog": { + "title": "Comment num\u00e9riser une piste", + "loadVideoTitle": "1. Charger une vid\u00e9o", + "loadVideoBody": "S\u00e9lectionnez une vid\u00e9o dans le menu d\u00e9roulant en haut \u2014 choisissez un exemple, un enregistrement webcam ou un fichier import\u00e9.", + "coordinateSystemTitle": "2. Configurer le syst\u00e8me de coordonn\u00e9es", + "coordinateSystemBody": "Activez les Axes dans le panneau de contr\u00f4le. Faites glisser l\u2019origine pour d\u00e9finir le point (0,\u00a00), puis faites pivoter les axes pour les aligner sur votre sc\u00e8ne.", + "calibrationTitle": "3. Calibrer l\u2019\u00e9chelle", + "calibrationBody": "Activez la Calibration dans le panneau de contr\u00f4le. Faites glisser les deux extr\u00e9mit\u00e9s sur une distance de r\u00e9f\u00e9rence connue dans la vid\u00e9o, puis entrez la longueur r\u00e9elle.", + "addTrackTitle": "4. Ajouter une piste", + "addTrackBody": "Dans le panneau Pistes, cliquez sur \u00ab\u00a0+ Ajouter une piste\u00a0\u00bb pour cr\u00e9er une nouvelle piste \u00e9tiquet\u00e9e A, B, C, \u2026", + "digitizeTitle": "5. Num\u00e9riser image par image", + "digitizeBody": "S\u00e9lectionnez une piste et cliquez sur la position de l\u2019objet dans la vid\u00e9o. La vid\u00e9o avance automatiquement d\u2019une image apr\u00e8s chaque clic.", + "autoTrackTitle": "6. Suivi automatique (optionnel)", + "autoTrackBody": "Activez le Suivi automatique dans le panneau de contr\u00f4le, puis faites glisser un cadre de s\u00e9lection autour de l\u2019objet. TrackLab le suivra sur les images restantes." + }, "a11y": { "videoPlayer": "Lecteur vid\u00e9o", "videoScrubber": "Ligne de temps vid\u00e9o \u2014 glisser pour naviguer", diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 0b24c90..7ec1e5a 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -254,7 +254,8 @@ export class AutoTrackerNode extends Node { // biome-ignore lint/suspicious/noConsole: error logging for tracker init failure console.error("AutoTracker: failed to initialise OpenCV tracker:", err); if (this.initVersion === capturedVersion) { - const message = err instanceof Error ? err.message : autoTrackerStrings.trackingInitFailedStringProperty.value; + const message = + err instanceof Error ? err.message : autoTrackerStrings.trackingInitFailedStringProperty.value; this.errorText.string = message; this.errorText.visible = true; this.hintText.visible = true; diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index 5348ff0..917780e 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -175,7 +175,8 @@ function buildHtmlTable( // ── Accessible caption (visually hidden but read by screen readers) ──────── const caption = document.createElement("caption"); caption.textContent = a11y.tableCaption; - caption.style.cssText = "position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"; + caption.style.cssText = + "position: absolute; width: 1px; height: 1px; overflow: hidden; clip: rect(0,0,0,0); white-space: nowrap;"; table.appendChild(caption); // ── Header row ───────────────────────────────────────────────────────────── diff --git a/src/screen-name/view/InfoDialogNode.ts b/src/screen-name/view/InfoDialogNode.ts new file mode 100644 index 0000000..725b106 --- /dev/null +++ b/src/screen-name/view/InfoDialogNode.ts @@ -0,0 +1,114 @@ +/** + * InfoDialogNode.ts + * + * Modal dialog explaining the main steps for digitizing a track in TrackLab. + * Toggled by the InfoButton in the lower-left corner of the screen. + */ + +import type { ReadOnlyProperty } from "scenerystack/axon"; +import { Node, RichText, Text, VBox } from "scenerystack/scenery"; +import { CloseButton, PhetFont } from "scenerystack/scenery-phet"; +import { Panel } from "scenerystack/sun"; +import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../i18n/StringManager.js"; +import TrackLabColors from "../../TrackLabColors.js"; +import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; +import trackLab from "../../TrackLabNamespace.js"; + +// ── Layout constants ────────────────────────────────────────────────────────── +const CONTENT_WIDTH = 370; // inner width of the panel content area +const PANEL_X_MARGIN = 18; +const PANEL_Y_MARGIN = 16; +const TITLE_FONT = new PhetFont({ size: 15, weight: "bold" }); +const STEP_TITLE_FONT = new PhetFont({ size: 13, weight: "bold" }); +const STEP_BODY_FONT = new PhetFont(13); +const STEPS_SPACING = 12; // vertical gap between steps +const STEP_INNER_SPACING = 2; // gap between step title and body text +const CLOSE_BUTTON_ICON_LENGTH = 10; + +// ── Helpers ─────────────────────────────────────────────────────────────────── + +/** One step: bold heading above a softer-colored description. */ +function makeStep(titleProp: ReadOnlyProperty, bodyProp: ReadOnlyProperty): Node { + const titleText = new Text(titleProp, { + font: STEP_TITLE_FONT, + fill: TrackLabColors.textOnDarkProperty, + maxWidth: CONTENT_WIDTH, + }); + + const bodyText = new RichText(bodyProp, { + font: STEP_BODY_FONT, + fill: TrackLabColors.textMutedProperty, + lineWrap: CONTENT_WIDTH, + }); + + return new VBox({ + children: [titleText, bodyText], + spacing: STEP_INNER_SPACING, + align: "left", + }); +} + +// ── InfoDialogNode ──────────────────────────────────────────────────────────── + +/** + * Floating panel explaining how to digitize a track. + * + * Hidden by default (`visible = false`). Show by setting `visible = true`; + * the internal close button hides it again. + */ +export class InfoDialogNode extends Node { + public constructor() { + super({ visible: false }); + + const strings = StringManager.getInstance().getInfoDialog(); + + // ── Header: title + close button ───────────────────────────────────────── + const titleText = new Text(strings.titleStringProperty, { + font: TITLE_FONT, + fill: TrackLabColors.textOnDarkProperty, + }); + + const closeButton = new CloseButton({ + listener: () => { + this.visible = false; + }, + baseColor: TrackLabColors.buttonBaseDarkProperty, + iconLength: CLOSE_BUTTON_ICON_LENGTH, + tandem: Tandem.OPT_OUT, + }); + + // Lay out title and close button side-by-side, close button flush right. + const headerNode = new Node({ children: [titleText, closeButton] }); + closeButton.right = CONTENT_WIDTH; + closeButton.centerY = titleText.centerY; + titleText.maxWidth = CONTENT_WIDTH - closeButton.width - 8; + + // ── Steps ──────────────────────────────────────────────────────────────── + const content = new VBox({ + children: [ + headerNode, + makeStep(strings.loadVideoTitleStringProperty, strings.loadVideoBodyStringProperty), + makeStep(strings.coordinateSystemTitleStringProperty, strings.coordinateSystemBodyStringProperty), + makeStep(strings.calibrationTitleStringProperty, strings.calibrationBodyStringProperty), + makeStep(strings.addTrackTitleStringProperty, strings.addTrackBodyStringProperty), + makeStep(strings.digitizeTitleStringProperty, strings.digitizeBodyStringProperty), + makeStep(strings.autoTrackTitleStringProperty, strings.autoTrackBodyStringProperty), + ], + spacing: STEPS_SPACING, + align: "left", + }); + + const panel = new Panel(content, { + fill: TrackLabColors.panelFillProperty, + stroke: TrackLabColors.panelStrokeProperty, + cornerRadius: PANEL_CORNER_RADIUS, + xMargin: PANEL_X_MARGIN, + yMargin: PANEL_Y_MARGIN, + }); + + this.addChild(panel); + } +} + +trackLab.register("InfoDialogNode", InfoDialogNode); diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 0fd7551..2e4f2f6 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -13,7 +13,9 @@ import { ButtonNode, RectangularPushButton, Slider } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; + const a11yStrings = StringManager.getInstance().getA11y(); + import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 21fc5d1..abd3222 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -8,8 +8,9 @@ import { DerivedProperty } from "scenerystack/axon"; import { Vector2 } from "scenerystack/dot"; import { DragListener } from "scenerystack/scenery"; -import { ResetAllButton } from "scenerystack/scenery-phet"; +import { InfoButton, ResetAllButton } from "scenerystack/scenery-phet"; import { ScreenView, type ScreenViewOptions } from "scenerystack/sim"; +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"; @@ -17,6 +18,7 @@ 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 { TrackListPanel } from "./TrackListPanel.js"; import { VideoPlayerNode } from "./VideoPlayerNode.js"; @@ -118,6 +120,19 @@ export class SimScreenView extends ScreenView { const kinematicsGraph = new KinematicsGraphNode(model, this, trackLabPreferences); this.addChild(kinematicsGraph); + // ── Info dialog (explains digitizing workflow) ──────────────────────────── + const infoDialogNode = new InfoDialogNode(); + this.addChild(infoDialogNode); + + // ── Info button (lower-left corner, same vertical level as reset button) ─ + const infoButton = new InfoButton({ + listener: () => { + infoDialogNode.visible = !infoDialogNode.visible; + }, + tandem: Tandem.OPT_OUT, + }); + this.addChild(infoButton); + // ── Webcam panel (topmost when visible, above coord/calibration overlays) ─ const webcamPanel = this.videoPlayerNode.webcamPanel; this.addChild(webcamPanel); @@ -138,6 +153,14 @@ export class SimScreenView extends ScreenView { resetAllButton.right = visibleBounds.maxX - RESET_BUTTON_MARGIN; resetAllButton.bottom = visibleBounds.maxY - RESET_BUTTON_MARGIN; + // Info button: lower-left corner, mirroring the reset button margin. + infoButton.left = visibleBounds.minX + RESET_BUTTON_MARGIN; + infoButton.centerY = resetAllButton.centerY; + + // Info dialog: centered horizontally, positioned just above the info button. + infoDialogNode.centerX = this.layoutBounds.centerX; + infoDialogNode.bottom = infoButton.top - RESET_BUTTON_MARGIN; + // Data table: shift left by extraWidth so it stays within the layout area // and doesn't drift into the extra visible space claimed by the graph. dataTableNode.left = this.videoPlayerNode.right + DATA_TABLE_LEFT_SPACING - extraWidth; diff --git a/src/screen-name/view/TrackListPanel.ts b/src/screen-name/view/TrackListPanel.ts index a5d9524..040c205 100644 --- a/src/screen-name/view/TrackListPanel.ts +++ b/src/screen-name/view/TrackListPanel.ts @@ -21,7 +21,9 @@ import { ButtonNode, Checkbox, Panel, RectangularPushButton } from "scenerystack import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; + const a11yStrings = StringManager.getInstance().getA11y(); + import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import type { Track } from "../model/Track.js"; diff --git a/src/screen-name/view/WebcamPanel.ts b/src/screen-name/view/WebcamPanel.ts index 45c1bca..2ad04b3 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -20,7 +20,12 @@ import { import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; -import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, WEBCAM_PREVIEW_HEIGHT, WEBCAM_PREVIEW_WIDTH } from "../../TrackLabConstants.js"; +import { + BUTTON_X_MARGIN, + BUTTON_Y_MARGIN, + 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"; @@ -372,7 +377,8 @@ export class WebcamPanel extends Node { cameras.forEach((cam, i) => { const opt = document.createElement("option"); opt.value = cam.deviceId; - opt.textContent = cam.label || this.webcamStrings.cameraLabelStringProperty.value.replace("{{number}}", String(i + 1)); + opt.textContent = + cam.label || this.webcamStrings.cameraLabelStringProperty.value.replace("{{number}}", String(i + 1)); this.cameraSelect.appendChild(opt); }); }