diff --git a/src/TrackLabConstants.ts b/src/TrackLabConstants.ts index 727a527..0e02524 100644 --- a/src/TrackLabConstants.ts +++ b/src/TrackLabConstants.ts @@ -59,3 +59,8 @@ export const BUTTON_Y_MARGIN = 6; // ── Webcam panel ────────────────────────────────────────────────────────────── export const WEBCAM_PREVIEW_WIDTH = 480; // width of the preview and review video elements export const WEBCAM_PREVIEW_HEIGHT = 270; // height of the preview and review video elements + +// ── Overlay tool interaction ─────────────────────────────────────────────────── +// Opacity applied to coordinate system and calibration tool overlays while +// the user is actively digitizing, signalling that those tools are locked out. +export const DIGITIZING_DIM_OPACITY = 0.35; diff --git a/src/i18n/StringManager.ts b/src/i18n/StringManager.ts index d0206f9..7d15ab0 100644 --- a/src/i18n/StringManager.ts +++ b/src/i18n/StringManager.ts @@ -215,9 +215,28 @@ export class StringManager { */ public getAutoTracker(): { dragToSelectStringProperty: ReadOnlyProperty; + videoTrackingAreaStringProperty: ReadOnlyProperty; + trackingInitFailedStringProperty: ReadOnlyProperty; } { return { dragToSelectStringProperty: this.stringProperties.autoTracker.dragToSelectStringProperty, + videoTrackingAreaStringProperty: this.stringProperties.autoTracker.videoTrackingAreaStringProperty, + trackingInitFailedStringProperty: this.stringProperties.autoTracker.trackingInitFailedStringProperty, + }; + } + + /** + * Get calibration tool string properties + */ + public getCalibration(): { + pointsTooCloseStringProperty: ReadOnlyProperty; + calibrationPoint1StringProperty: ReadOnlyProperty; + calibrationPoint2StringProperty: ReadOnlyProperty; + } { + return { + pointsTooCloseStringProperty: this.stringProperties.calibration.pointsTooCloseStringProperty, + calibrationPoint1StringProperty: this.stringProperties.calibration.calibrationPoint1StringProperty, + calibrationPoint2StringProperty: this.stringProperties.calibration.calibrationPoint2StringProperty, }; } @@ -278,10 +297,29 @@ export class StringManager { public getCoordSystem(): { xAxisLabelStringProperty: ReadOnlyProperty; yAxisLabelStringProperty: ReadOnlyProperty; + rotationHandleStringProperty: ReadOnlyProperty; + coordinateSystemStringProperty: ReadOnlyProperty; } { return { xAxisLabelStringProperty: this.stringProperties.coordSystem.xAxisLabelStringProperty, yAxisLabelStringProperty: this.stringProperties.coordSystem.yAxisLabelStringProperty, + rotationHandleStringProperty: this.stringProperties.coordSystem.rotationHandleStringProperty, + coordinateSystemStringProperty: this.stringProperties.coordSystem.coordinateSystemStringProperty, + }; + } + + /** + * Get video source control string properties + */ + public getVideoSource(): { + downloadVideoStringProperty: ReadOnlyProperty; + openVideoFileStringProperty: ReadOnlyProperty; + recordWebcamStringProperty: ReadOnlyProperty; + } { + return { + downloadVideoStringProperty: this.stringProperties.videoSource.downloadVideoStringProperty, + openVideoFileStringProperty: this.stringProperties.videoSource.openVideoFileStringProperty, + recordWebcamStringProperty: this.stringProperties.videoSource.recordWebcamStringProperty, }; } @@ -294,6 +332,19 @@ export class StringManager { processingStringProperty: ReadOnlyProperty; fixingMetadataStringProperty: ReadOnlyProperty; recordingStringProperty: ReadOnlyProperty; + cancelStringProperty: ReadOnlyProperty; + startRecordingStringProperty: ReadOnlyProperty; + stopRecordingStringProperty: ReadOnlyProperty; + reRecordStringProperty: ReadOnlyProperty; + useVideoStringProperty: ReadOnlyProperty; + cameraStringProperty: ReadOnlyProperty; + recordFromWebcamStringProperty: ReadOnlyProperty; + estimatingFrameRateStringProperty: ReadOnlyProperty; + highConfidenceStringProperty: ReadOnlyProperty; + mediumConfidenceStringProperty: ReadOnlyProperty; + lowConfidenceStringProperty: ReadOnlyProperty; + estimatedFpsStringProperty: ReadOnlyProperty; + cameraLabelStringProperty: ReadOnlyProperty; } { return { requestingAccessStringProperty: this.stringProperties.webcam.requestingAccessStringProperty, @@ -301,6 +352,47 @@ export class StringManager { processingStringProperty: this.stringProperties.webcam.processingStringProperty, fixingMetadataStringProperty: this.stringProperties.webcam.fixingMetadataStringProperty, recordingStringProperty: this.stringProperties.webcam.recordingStringProperty, + cancelStringProperty: this.stringProperties.webcam.cancelStringProperty, + startRecordingStringProperty: this.stringProperties.webcam.startRecordingStringProperty, + stopRecordingStringProperty: this.stringProperties.webcam.stopRecordingStringProperty, + reRecordStringProperty: this.stringProperties.webcam.reRecordStringProperty, + useVideoStringProperty: this.stringProperties.webcam.useVideoStringProperty, + cameraStringProperty: this.stringProperties.webcam.cameraStringProperty, + recordFromWebcamStringProperty: this.stringProperties.webcam.recordFromWebcamStringProperty, + estimatingFrameRateStringProperty: this.stringProperties.webcam.estimatingFrameRateStringProperty, + highConfidenceStringProperty: this.stringProperties.webcam.highConfidenceStringProperty, + mediumConfidenceStringProperty: this.stringProperties.webcam.mediumConfidenceStringProperty, + lowConfidenceStringProperty: this.stringProperties.webcam.lowConfidenceStringProperty, + estimatedFpsStringProperty: this.stringProperties.webcam.estimatedFpsStringProperty, + cameraLabelStringProperty: this.stringProperties.webcam.cameraLabelStringProperty, + }; + } + + /** + * Get kinematics graph string properties + */ + public getKinematicsGraph(): { + trackSelectorLabelStringProperty: ReadOnlyProperty; + noTracksStringProperty: ReadOnlyProperty; + trackItemStringProperty: ReadOnlyProperty; + } { + return { + trackSelectorLabelStringProperty: this.stringProperties.kinematicsGraph.trackSelectorLabelStringProperty, + noTracksStringProperty: this.stringProperties.kinematicsGraph.noTracksStringProperty, + trackItemStringProperty: this.stringProperties.kinematicsGraph.trackItemStringProperty, + }; + } + + /** + * Get playback format string properties + */ + public getPlayback(): { + secondsUnitStringProperty: ReadOnlyProperty; + durationZeroStringProperty: ReadOnlyProperty; + } { + return { + secondsUnitStringProperty: this.stringProperties.playback.secondsUnitStringProperty, + durationZeroStringProperty: this.stringProperties.playback.durationZeroStringProperty, }; } diff --git a/src/i18n/strings_en.json b/src/i18n/strings_en.json index 7a52544..caa5209 100644 --- a/src/i18n/strings_en.json +++ b/src/i18n/strings_en.json @@ -60,7 +60,14 @@ "showAccelerationDescription": "When enabled, acceleration components (ax, ay, |a|) are available as choices on the kinematics graph axes." }, "autoTracker": { - "dragToSelect": "Drag on video to select object to track" + "dragToSelect": "Drag on video to select object to track", + "videoTrackingArea": "Video tracking area \u2014 drag to select object to track", + "trackingInitFailed": "Tracking initialisation failed. Try again." + }, + "calibration": { + "pointsTooClose": "Points too close \u2014 move apart to calibrate", + "calibrationPoint1": "Calibration Point 1", + "calibrationPoint2": "Calibration Point 2" }, "dataTable": { "title": "Data", @@ -75,21 +82,50 @@ }, "ui": { "fps": "fps:", - "selectVideo": "— select a video —", - "sampleVideos": "── Sample Videos ──", - "myRecordings": "── My Recordings ──", - "uploadedVideos": "── Uploaded Videos ──" + "selectVideo": "\u2014 select a video \u2014", + "sampleVideos": "\u2500\u2500 Sample Videos \u2500\u2500", + "myRecordings": "\u2500\u2500 My Recordings \u2500\u2500", + "uploadedVideos": "\u2500\u2500 Uploaded Videos \u2500\u2500" }, "coordSystem": { "xAxisLabel": "x", - "yAxisLabel": "y" + "yAxisLabel": "y", + "rotationHandle": "Rotation Handle", + "coordinateSystem": "Coordinate System" + }, + "videoSource": { + "downloadVideo": "Download Video", + "openVideoFile": "Open Video File", + "recordWebcam": "Record Webcam" }, "webcam": { "requestingAccess": "Requesting camera access\u2026", "accessDenied": "Camera access denied.", "processing": "Processing\u2026", "fixingMetadata": "Fixing video metadata\u2026", - "recording": "\u25CF Recording {{time}}" + "recording": "\u25CF Recording {{time}}", + "cancel": "Cancel", + "startRecording": "Start Recording", + "stopRecording": "Stop Recording", + "reRecord": "Re-record", + "useVideo": "Use Video", + "camera": "Camera", + "recordFromWebcam": "Record from Webcam", + "estimatingFrameRate": "Estimating frame rate\u2026", + "highConfidence": "High confidence", + "mediumConfidence": "Medium confidence", + "lowConfidence": "Low confidence", + "estimatedFps": "Estimated: {{fps}} fps {{symbol}} ({{confidence}}, {{method}})", + "cameraLabel": "Camera {{number}}" + }, + "kinematicsGraph": { + "trackSelectorLabel": "Track:", + "noTracks": "No tracks", + "trackItem": "Track {{symbol}}" + }, + "playback": { + "secondsUnit": "s", + "durationZero": "0.00 s" }, "videoFiles": { "ballOil": "Ball in Oil", diff --git a/src/i18n/strings_fr.json b/src/i18n/strings_fr.json index 37e189e..0155353 100644 --- a/src/i18n/strings_fr.json +++ b/src/i18n/strings_fr.json @@ -6,11 +6,11 @@ "controls": { "play": "Lecture", "pause": "Pause", - "reset": "Réinitialiser", + "reset": "R\u00e9initialiser", "step": "Pas", "speed": "Vitesse", "record": "Enregistrer", - "stop": "Arrêter", + "stop": "Arr\u00eater", "graphVs": "vs" }, "tracking": { @@ -18,16 +18,16 @@ "target": "Cible", "origin": "Origine", "calibrate": "Calibrer", - "scale": "Échelle", + "scale": "\u00c9chelle", "position": "Position", "velocity": "Vitesse", - "acceleration": "Accélération" + "acceleration": "Acc\u00e9l\u00e9ration" }, "video": { "openFile": "Ouvrir un fichier", "frameRate": "Images par seconde", "currentFrame": "Image", - "duration": "Durée", + "duration": "Dur\u00e9e", "export": "Exporter" }, "measurement": { @@ -35,37 +35,44 @@ "distance": "Distance", "angle": "Angle", "mass": "Masse", - "gravity": "Gravité" + "gravity": "Gravit\u00e9" }, "keyboardShortcuts": { "title": "Raccourcis clavier", - "simulationControls": "Contrôles de simulation", + "simulationControls": "Contr\u00f4les de simulation", "graphInteractions": "Interactions avec le graphique", "playPauseSimulation": "Lecture/Pause de la simulation", - "resetSimulation": "Réinitialiser la simulation", + "resetSimulation": "R\u00e9initialiser la simulation", "stepBackward": "Reculer d'un pas", "stepForward": "Avancer d'un pas", - "rewindToStart": "Revenir au début", - "resetZoom": "Réinitialiser le zoom", - "zoomInOut": "Zoom avant/arrière", - "panView": "Déplacer la vue" + "rewindToStart": "Revenir au d\u00e9but", + "resetZoom": "R\u00e9initialiser le zoom", + "zoomInOut": "Zoom avant/arri\u00e8re", + "panView": "D\u00e9placer la vue" }, "preferences": { "simulation": "Simulation", "enableAutoTracking": "Activer le suivi automatique", - "enableAutoTrackingDescription": "Lorsque activé, affiche la case à cocher du suivi automatique dans le panneau de contrôle, permettant le suivi automatique d'objets basé sur OpenCV. Lorsque désactivé, la case est cachée et seule la numérisation manuelle est disponible.", + "enableAutoTrackingDescription": "Lorsque activ\u00e9, affiche la case \u00e0 cocher du suivi automatique dans le panneau de contr\u00f4le, permettant le suivi automatique d'objets bas\u00e9 sur OpenCV. Lorsque d\u00e9sactiv\u00e9, la case est cach\u00e9e et seule la num\u00e9risation manuelle est disponible.", "showVelocity": "Afficher la vitesse", - "showVelocityDescription": "Lorsque activé, les composantes de vitesse (vx, vy, rapidité) sont disponibles comme choix sur les axes du graphique cinématique.", - "showAcceleration": "Afficher l'accélération", - "showAccelerationDescription": "Lorsque activé, les composantes d'accélération (ax, ay, |a|) sont disponibles comme choix sur les axes du graphique cinématique." + "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." }, "autoTracker": { - "dragToSelect": "Faire glisser sur la vidéo pour sélectionner l'objet à suivre" + "dragToSelect": "Faire glisser sur la vid\u00e9o pour s\u00e9lectionner l'objet \u00e0 suivre", + "videoTrackingArea": "Zone de suivi vid\u00e9o \u2014 faire glisser pour s\u00e9lectionner l'objet \u00e0 suivre", + "trackingInitFailed": "\u00c9chec de l'initialisation du suivi. R\u00e9essayez." + }, + "calibration": { + "pointsTooClose": "Points trop proches \u2014 les \u00e9carter pour calibrer", + "calibrationPoint1": "Point de calibration 1", + "calibrationPoint2": "Point de calibration 2" }, "dataTable": { - "title": "Données", + "title": "Donn\u00e9es", "csv": "CSV", - "noData": "Aucun point numérisé", + "noData": "Aucun point num\u00e9ris\u00e9", "frame": "Image", "timeSeconds": "Temps (s)" }, @@ -74,22 +81,51 @@ "tracks": "Pistes" }, "ui": { - "fps": "ips :", - "selectVideo": "— sélectionner une vidéo —", - "sampleVideos": "── Vidéos exemples ──", - "myRecordings": "── Mes enregistrements ──", - "uploadedVideos": "── Vidéos importées ──" + "fps": "ips\u00a0:", + "selectVideo": "\u2014 s\u00e9lectionner une vid\u00e9o \u2014", + "sampleVideos": "\u2500\u2500 Vid\u00e9os exemples \u2500\u2500", + "myRecordings": "\u2500\u2500 Mes enregistrements \u2500\u2500", + "uploadedVideos": "\u2500\u2500 Vid\u00e9os import\u00e9es \u2500\u2500" }, "coordSystem": { "xAxisLabel": "x", - "yAxisLabel": "y" + "yAxisLabel": "y", + "rotationHandle": "Poign\u00e9e de rotation", + "coordinateSystem": "Syst\u00e8me de coordonn\u00e9es" + }, + "videoSource": { + "downloadVideo": "T\u00e9l\u00e9charger la vid\u00e9o", + "openVideoFile": "Ouvrir un fichier vid\u00e9o", + "recordWebcam": "Enregistrer avec la webcam" }, "webcam": { - "requestingAccess": "Demande d'accès à la caméra\u2026", - "accessDenied": "Accès à la caméra refusé.", + "requestingAccess": "Demande d'acc\u00e8s \u00e0 la cam\u00e9ra\u2026", + "accessDenied": "Acc\u00e8s \u00e0 la cam\u00e9ra refus\u00e9.", "processing": "Traitement\u2026", - "fixingMetadata": "Correction des métadonnées vidéo\u2026", - "recording": "\u25CF Enregistrement {{time}}" + "fixingMetadata": "Correction des m\u00e9tadonn\u00e9es vid\u00e9o\u2026", + "recording": "\u25CF Enregistrement {{time}}", + "cancel": "Annuler", + "startRecording": "D\u00e9marrer l'enregistrement", + "stopRecording": "Arr\u00eater l'enregistrement", + "reRecord": "R\u00e9enregistrer", + "useVideo": "Utiliser la vid\u00e9o", + "camera": "Cam\u00e9ra", + "recordFromWebcam": "Enregistrer depuis la webcam", + "estimatingFrameRate": "Estimation de la fr\u00e9quence d'images\u2026", + "highConfidence": "Haute confiance", + "mediumConfidence": "Confiance moyenne", + "lowConfidence": "Faible confiance", + "estimatedFps": "Estim\u00e9\u00a0: {{fps}} ips {{symbol}} ({{confidence}}, {{method}})", + "cameraLabel": "Cam\u00e9ra {{number}}" + }, + "kinematicsGraph": { + "trackSelectorLabel": "Piste\u00a0:", + "noTracks": "Aucune piste", + "trackItem": "Piste {{symbol}}" + }, + "playback": { + "secondsUnit": "s", + "durationZero": "0,00 s" }, "videoFiles": { "ballOil": "Balle dans l'huile", diff --git a/src/screen-name/graph/ConfigurableGraph.ts b/src/screen-name/graph/ConfigurableGraph.ts index b1387d8..417e68f 100644 --- a/src/screen-name/graph/ConfigurableGraph.ts +++ b/src/screen-name/graph/ConfigurableGraph.ts @@ -552,14 +552,12 @@ export default class ConfigurableGraph extends Node { this.chartTransform.setViewHeight(newHeight); // 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(-Y_AXIS_INTERACTION_WIDTH, 0, Y_AXIS_INTERACTION_WIDTH, newHeight); + this.xAxisInteractionRegion.setRect(0, newHeight, newWidth, X_AXIS_INTERACTION_HEIGHT); // Update axis labels positions this.xAxisLabelNode.centerX = newWidth / 2; - this.xAxisLabelNode.top = newHeight + 35; + this.xAxisLabelNode.top = newHeight + AXIS_LABEL_OFFSET; this.yAxisLabelNode.centerY = newHeight / 2; // Update title panel position diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 403a370..0b24c90 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -25,6 +25,7 @@ const CROSSHAIR_LINE_WIDTH = 2; const CROSSHAIR_CIRCLE_RADIUS = 6; // small filled circle at crosshair centre const MIN_REGION_SIZE = 4; // minimum pixel width/height to begin tracking const TRAIL_DOT_RADIUS = 3; // radius of each past-position dot in the trail +const LABELS_SPACING = 8; // vertical gap between hint text and error text /** * Transparent SceneryStack overlay that sits directly on top of the video element. @@ -96,7 +97,7 @@ export class AutoTrackerNode extends Node { fill: "transparent", cursor: "crosshair", tagName: "div", - accessibleName: "Video tracking area — drag to select object to track", + accessibleName: autoTrackerStrings.videoTrackingAreaStringProperty, }); this.addChild(hitArea); @@ -116,7 +117,7 @@ export class AutoTrackerNode extends Node { // Stack hint and error vertically so both are centred in the video area. const centeredLabels = new VBox({ children: [this.hintText, this.errorText], - spacing: 8, + spacing: LABELS_SPACING, align: "center", center: new Vector2(VIDEO_WIDTH / 2, VIDEO_HEIGHT / 2), }); @@ -253,7 +254,7 @@ 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 : "Tracking initialisation failed. Try again."; + 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/CalibrationToolNode.ts b/src/screen-name/view/CalibrationToolNode.ts index 5467416..7e21fcb 100644 --- a/src/screen-name/view/CalibrationToolNode.ts +++ b/src/screen-name/view/CalibrationToolNode.ts @@ -14,8 +14,9 @@ import { Keypad, PhetFont } from "scenerystack/scenery-phet"; import { KeypadDialog } from "scenerystack/sim"; import { ButtonNode, ComboBox, type ComboBoxItem, Panel, TextPushButton } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; -import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN } from "../../TrackLabConstants.js"; +import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, DIGITIZING_DIM_OPACITY } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; import { CALIBRATION_UNITS } from "../model/SimModel.js"; @@ -61,6 +62,8 @@ export class CalibrationToolNode extends Node { public constructor(videoLoadedProperty: TReadOnlyProperty, listParent: Node, model: SimModel) { super(); + const calibrationStrings = StringManager.getInstance().getCalibration(); + // ── Connecting line with shadow for visibility on all backgrounds ──── // Shadow layer (rendered first, underneath) const calibrationLineShadow = new Line(0, 0, 0, 0, { @@ -94,7 +97,7 @@ export class CalibrationToolNode extends Node { this.addChild(endpoint2Shadow); // Main bright circles (rendered on top with interaction) - const makeEndpoint = (accessibleName: string) => + const makeEndpoint = (accessibleName: TReadOnlyProperty) => new Circle(ENDPOINT_RADIUS, { fill: TrackLabColors.calibrationFillProperty, stroke: TrackLabColors.calibrationStrokeProperty, @@ -104,8 +107,8 @@ export class CalibrationToolNode extends Node { focusable: true, accessibleName: accessibleName, }); - const endpoint1 = makeEndpoint("Calibration Point 1"); - const endpoint2 = makeEndpoint("Calibration Point 2"); + const endpoint1 = makeEndpoint(calibrationStrings.calibrationPoint1StringProperty); + const endpoint2 = makeEndpoint(calibrationStrings.calibrationPoint2StringProperty); const endpointTouchArea = Shape.circle(0, 0, ENDPOINT_RADIUS + ENDPOINT_TOUCH_DILATION); endpoint1.mouseArea = endpointTouchArea; endpoint1.touchArea = endpointTouchArea; @@ -192,7 +195,7 @@ 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", { + const overlapWarning = new Text(calibrationStrings.pointsTooCloseStringProperty, { font: WARNING_FONT, fill: ENDPOINT_WARNING_COLOR, visible: false, @@ -270,7 +273,7 @@ export class CalibrationToolNode extends Node { const onActiveTrackChange = (activeId: string | null) => { const isDigitizing = activeId !== null; this.pickable = !isDigitizing; - this.opacity = isDigitizing ? 0.35 : 1; + this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; model.activeTrackIdProperty.link(onActiveTrackChange); diff --git a/src/screen-name/view/CoordinateSystemNode.ts b/src/screen-name/view/CoordinateSystemNode.ts index f69e424..1b6dd22 100644 --- a/src/screen-name/view/CoordinateSystemNode.ts +++ b/src/screen-name/view/CoordinateSystemNode.ts @@ -13,6 +13,7 @@ import { ArrowNode, PhetFont } from "scenerystack/scenery-phet"; import { Tandem } from "scenerystack/tandem"; import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; +import { DIGITIZING_DIM_OPACITY } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; const ARROW_LENGTH = 120; @@ -141,7 +142,7 @@ export class CoordinateSystemNode extends Node { cursor: "crosshair", tagName: "div", focusable: true, - accessibleName: "Rotation Handle", + accessibleName: coordStrings.rotationHandleStringProperty, }); const handleTouchArea = Shape.circle(0, 0, HANDLE_RADIUS + HANDLE_TOUCH_DILATION); handleDisk.mouseArea = handleTouchArea; @@ -166,7 +167,7 @@ export class CoordinateSystemNode extends Node { cursor: "move", tagName: "div", focusable: true, - accessibleName: "Coordinate System", + accessibleName: coordStrings.coordinateSystemStringProperty, }); // Expand touch/mouse area for easier pickup (origin + axes region) positionNode.boundsProperty.lazyLink(() => { @@ -235,7 +236,7 @@ export class CoordinateSystemNode extends Node { const onActiveTrackChange = (activeId: string | null) => { const isDigitizing = activeId !== null; this.pickable = !isDigitizing; - this.opacity = isDigitizing ? 0.35 : 1; + this.opacity = isDigitizing ? DIGITIZING_DIM_OPACITY : 1; }; model.activeTrackIdProperty.link(onActiveTrackChange); diff --git a/src/screen-name/view/DataTableNode.ts b/src/screen-name/view/DataTableNode.ts index d5e20db..f3c537f 100644 --- a/src/screen-name/view/DataTableNode.ts +++ b/src/screen-name/view/DataTableNode.ts @@ -30,6 +30,7 @@ const MAX_TABLE_HEIGHT = 400; // Maximum height before scrolling (increased from const TITLE_FONT = new PhetFont({ size: 12, weight: "bold" }); const TABLE_FONT_SIZE = 11; // HTML table font size in px const EXPORT_BUTTON_FONT_SIZE = 9; +const DOWNLOAD_ICON_FONT_SIZE = 11; // font size for the ⬇ icon glyph // ── Precision ───────────────────────────────────────────────────────────────── // Both values are kept equal so exported CSV data matches what users see on screen. @@ -44,6 +45,15 @@ const CONTENT_SPACING = 6; // gap between title row and table DOM node const TITLE_ROW_SPACING = 8; // gap between title label and export button const EXPORT_BUTTON_ICON_SPACING = 3; +// ── HTML table CSS dimensions ───────────────────────────────────────────────── +const TABLE_WRAPPER_BORDER_RADIUS = 3; // px, border-radius on the scroll wrapper +const TABLE_HEADER_PADDING_Y = 4; // px, vertical padding in header cells +const TABLE_HEADER_PADDING_X = 8; // px, horizontal padding in header cells +const TABLE_EMPTY_CELL_PADDING_Y = 8; // px, vertical padding in the "no data" placeholder cell +const TABLE_EMPTY_CELL_PADDING_X = 16; // px, horizontal padding in the "no data" placeholder cell +const TABLE_CELL_PADDING_Y = 3; // px, vertical padding in data cells +const TABLE_CELL_PADDING_X = 6; // px, horizontal padding in data cells + // ── Helpers ────────────────────────────────────────────────────────────────── type DataRow = { @@ -142,7 +152,7 @@ function buildHtmlTable( min-height: ${MIN_TABLE_HEIGHT}px; max-height: ${MAX_TABLE_HEIGHT}px; border: 1px solid ${colors.gridStroke}; - border-radius: 3px; + border-radius: ${TABLE_WRAPPER_BORDER_RADIUS}px; background: ${colors.background}; `; @@ -162,7 +172,7 @@ function buildHtmlTable( background: ${colors.headerBg}; color: ${colors.headerText}; font-weight: bold; - padding: 4px 8px; + padding: ${TABLE_HEADER_PADDING_Y}px ${TABLE_HEADER_PADDING_X}px; border: 1px solid ${colors.gridStroke}; text-align: center; position: sticky; @@ -232,7 +242,7 @@ function buildHtmlTable( td.colSpan = Math.max(MIN_EMPTY_COL_COUNT, 2 + tracks.length * 2); td.textContent = labels.noData; td.style.cssText = ` - padding: 8px 16px; + padding: ${TABLE_EMPTY_CELL_PADDING_Y}px ${TABLE_EMPTY_CELL_PADDING_X}px; text-align: center; color: ${colors.emptyText}; font-style: italic; @@ -249,7 +259,7 @@ function buildHtmlTable( tr.style.background = i % 2 === 0 ? colors.rowOdd : colors.rowEven; const cellStyle = ` - padding: 3px 6px; + padding: ${TABLE_CELL_PADDING_Y}px ${TABLE_CELL_PADDING_X}px; border: 1px solid ${colors.gridStroke}; text-align: center; `; @@ -328,7 +338,7 @@ function buildSingleDataRow( function makeDownloadIcon(): Node { // Simple text-based icon return new Text("⬇", { - font: new PhetFont({ size: 11 }), + font: new PhetFont({ size: DOWNLOAD_ICON_FONT_SIZE }), fill: TrackLabColors.textOnDarkProperty, }); } @@ -517,7 +527,7 @@ export class DataTableNode extends Panel { } const colors = getTableColors(); - const cellStyle = `padding: 3px 6px; border: 1px solid ${colors.gridStroke}; text-align: center;`; + const cellStyle = `padding: ${TABLE_CELL_PADDING_Y}px ${TABLE_CELL_PADDING_X}px; border: 1px solid ${colors.gridStroke}; text-align: center;`; // Remove the "no data" placeholder row when the first real rows arrive. if (this.frameRowMap.size === 0 && dataRows.length > 0) { diff --git a/src/screen-name/view/DigitizingOverlayNode.ts b/src/screen-name/view/DigitizingOverlayNode.ts index 5216542..681a4c9 100644 --- a/src/screen-name/view/DigitizingOverlayNode.ts +++ b/src/screen-name/view/DigitizingOverlayNode.ts @@ -27,6 +27,11 @@ const MAG_CROSSHAIR_LINE_WIDTH = 1; const MARK_DOT_RADIUS = 2; // radius of each digitized-point dot drawn on the video +// Magnifier canvas box-shadow: "offsetX offsetY blur color" +const MAG_SHADOW_OFFSET_X = 0; // px, horizontal shadow offset +const MAG_SHADOW_OFFSET_Y = 2; // px, vertical shadow offset +const MAG_SHADOW_BLUR = 8; // px, shadow blur radius + /** * Manual digitizing overlay for placing track points on the video. * Renders a custom crosshair cursor and magnifier, and places dots on click. @@ -81,7 +86,7 @@ export class DigitizingOverlayNode extends Node { // Update shadow style when color property changes const updateShadowStyle = () => { - magCanvas.style.boxShadow = `0 2px 8px ${magShadowColor}`; + magCanvas.style.boxShadow = `${MAG_SHADOW_OFFSET_X}px ${MAG_SHADOW_OFFSET_Y}px ${MAG_SHADOW_BLUR}px ${magShadowColor}`; }; updateShadowStyle(); diff --git a/src/screen-name/view/KinematicsGraphNode.ts b/src/screen-name/view/KinematicsGraphNode.ts index 3a97cb0..2cb6f9f 100644 --- a/src/screen-name/view/KinematicsGraphNode.ts +++ b/src/screen-name/view/KinematicsGraphNode.ts @@ -10,6 +10,7 @@ import { Property } from "scenerystack/axon"; import { HBox, Node, Text, VBox } from "scenerystack/scenery"; import { PhetFont } from "scenerystack/scenery-phet"; import { ComboBox, type ComboBoxItem } from "scenerystack/sun"; +import { StringManager } from "../../i18n/StringManager.js"; import type { TrackLabPreferencesModel } from "../../preferences/TrackLabPreferencesModel.js"; import ConfigurableGraph from "../graph/ConfigurableGraph.js"; import { buildKinematicsPlottableGroups } from "../graph/kinematics-plottable-properties.js"; @@ -21,6 +22,13 @@ const GRAPH_WIDTH = 300; const GRAPH_HEIGHT = 200; const MAX_DATA_POINTS = 5000; +// Track selector UI +const VBOX_SPACING = 8; // vertical gap between track selector row and graph +const TRACK_SELECTOR_FONT = new PhetFont(12); // font for track selector label and combo box items +const TRACK_COMBO_X_MARGIN = 8; // horizontal margin inside track combo box +const TRACK_COMBO_Y_MARGIN = 4; // vertical margin inside track combo box +const TRACK_SELECTOR_SPACING = 8; // gap between "Track:" label and combo box + export class KinematicsGraphNode extends VBox { private readonly graph: ConfigurableGraph; private readonly model: SimModel; @@ -29,13 +37,15 @@ export class KinematicsGraphNode extends VBox { private readonly listParent: Node; private currentComboBox: ComboBox | null = null; private readonly disposeKinematicsGraph: () => void; + private readonly kinematicsGraphStrings; public constructor(model: SimModel, listParent: Node, preferencesModel: TrackLabPreferencesModel) { super({ - spacing: 8, + spacing: VBOX_SPACING, align: "left", }); + this.kinematicsGraphStrings = StringManager.getInstance().getKinematicsGraph(); this.model = model; this.listParent = listParent; this.selectedTrackProperty = new Property(null); @@ -172,18 +182,18 @@ export class KinematicsGraphNode extends VBox { * Note: The old combo box should be disposed before calling this method. */ private rebuildTrackSelector(): void { - const trackSelectorLabel = new Text("Track:", { - font: new PhetFont(12), + const trackSelectorLabel = new Text(this.kinematicsGraphStrings.trackSelectorLabelStringProperty, { + font: TRACK_SELECTOR_FONT, }); const trackComboBoxItems = this.createTrackComboBoxItems(); this.currentComboBox = new ComboBox(this.selectedTrackProperty, trackComboBoxItems, this.listParent, { - xMargin: 8, - yMargin: 4, + xMargin: TRACK_COMBO_X_MARGIN, + yMargin: TRACK_COMBO_Y_MARGIN, }); const trackSelector = new HBox({ - spacing: 8, + spacing: TRACK_SELECTOR_SPACING, children: [trackSelectorLabel, this.currentComboBox], }); @@ -200,7 +210,7 @@ export class KinematicsGraphNode extends VBox { return [ { value: null, - createNode: () => new Text("No tracks", { font: new PhetFont(12) }), + createNode: () => new Text(this.kinematicsGraphStrings.noTracksStringProperty, { font: TRACK_SELECTOR_FONT }), tandemName: "noTracksItem", }, ]; @@ -209,8 +219,8 @@ export class KinematicsGraphNode extends VBox { return tracks.map((track) => ({ value: track.id, createNode: () => - new Text(`Track ${track.symbol}`, { - font: new PhetFont(12), + new Text(this.kinematicsGraphStrings.trackItemStringProperty.value.replace("{{symbol}}", track.symbol), { + font: TRACK_SELECTOR_FONT, fill: track.color, }), tandemName: `track${track.symbol}Item`, diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index 059bb48..56c7bb0 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -11,6 +11,7 @@ import { HBox, Text, VBox } from "scenerystack/scenery"; import { PhetFont, TimeControlNode, TimeSpeed } from "scenerystack/scenery-phet"; import { ButtonNode, RectangularPushButton, Slider } from "scenerystack/sun"; import { Tandem } from "scenerystack/tandem"; +import { StringManager } from "../../i18n/StringManager.js"; import TrackLabColors from "../../TrackLabColors.js"; import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN } from "../../TrackLabConstants.js"; import type { SimModel } from "../model/SimModel.js"; @@ -26,6 +27,7 @@ const SCRUBBER_THUMB_WIDTH = 12; const SCRUBBER_THUMB_HEIGHT = 24; const INFO_DISPLAY_SPACING = 4; // gap between time label and frame counter const INFO_DISPLAY_WIDTH = 75; // fixed width to prevent layout shift when text changes +const REWIND_BUTTON_ICON_SIZE = 16; // font size for the rewind button icon glyph /** * Playback controls including time control, scrubber, and time/frame display. @@ -42,6 +44,8 @@ export class PlaybackControlsNode extends HBox { ) { super({ spacing: CONTROLS_SPACING, align: "center" }); + const playbackStrings = StringManager.getInstance().getPlayback(); + // ── Playback rate via TimeSpeed ──────────────────────────────────────── // timeSpeedProperty is view-local (the TimeSpeed enum is a scenery-phet type // that cannot live in the model). It syncs bidirectionally with the numeric @@ -116,9 +120,9 @@ export class PlaybackControlsNode extends HBox { // ── Time and frame info display ──────────────────────────────────────── const formatDuration = (seconds: number): string => { if (!Number.isFinite(seconds) || seconds <= 0) { - return "0.00 s"; + return playbackStrings.durationZeroStringProperty.value; } - return `${seconds.toFixed(2)} s`; + return `${seconds.toFixed(2)} ${playbackStrings.secondsUnitStringProperty.value}`; }; const totalTimeTextProperty = new DerivedProperty([model.durationProperty], (duration: number) => @@ -159,7 +163,7 @@ export class PlaybackControlsNode extends HBox { // ── Rewind-to-zero button ────────────────────────────────────────────── const rewindButton = new RectangularPushButton({ content: new Text("\u23EE", { - font: new PhetFont(16), + font: new PhetFont(REWIND_BUTTON_ICON_SIZE), fill: TrackLabColors.textOnDarkProperty, }), baseColor: TrackLabColors.buttonBaseDarkProperty, diff --git a/src/screen-name/view/SimScreenView.ts b/src/screen-name/view/SimScreenView.ts index 4ab5b39..21fc5d1 100644 --- a/src/screen-name/view/SimScreenView.ts +++ b/src/screen-name/view/SimScreenView.ts @@ -21,6 +21,12 @@ import { KinematicsGraphNode } from "./KinematicsGraphNode.js"; import { TrackListPanel } from "./TrackListPanel.js"; import { VideoPlayerNode } from "./VideoPlayerNode.js"; +// ── Layout constants ────────────────────────────────────────────────────────── +const SCREEN_TOP_MARGIN = 10; // inset from layout top edge for control panel and video +const VIDEO_PLAYER_LEFT_SPACING = 20; // gap between control panel right and video player left +const DATA_TABLE_LEFT_SPACING = 20; // gap between video player right and data table left +const KINEMATICS_GRAPH_BOTTOM_MARGIN = 150; // gap between kinematics graph bottom and reset button top + /** * Root layout for the simulation screen. * @@ -53,7 +59,7 @@ export class SimScreenView extends ScreenView { // ── Control panel / tool checkboxes (upper left) ─────────────────────── const controlPanel = new ControlPanel(model, trackLabPreferences); controlPanel.left = this.layoutBounds.left + CONTROL_PANEL_LEFT_MARGIN; - controlPanel.top = this.layoutBounds.top + 10; + controlPanel.top = this.layoutBounds.top + SCREEN_TOP_MARGIN; this.addChild(controlPanel); // ── Track list panel (beneath control panel) ───────────────────────── @@ -69,8 +75,8 @@ export class SimScreenView extends ScreenView { // Uses model.modelViewTransformProperty (a DerivedProperty computed inside // SimModel from the tool state properties above). this.videoPlayerNode = new VideoPlayerNode(model, this); - this.videoPlayerNode.left = controlPanel.right + 20; - this.videoPlayerNode.top = this.layoutBounds.top + 10; + this.videoPlayerNode.left = controlPanel.right + VIDEO_PLAYER_LEFT_SPACING; + this.videoPlayerNode.top = this.layoutBounds.top + SCREEN_TOP_MARGIN; this.addChild(this.videoPlayerNode); // ── Coordinate system overlay (above video, below camera modal) ───────── @@ -87,7 +93,7 @@ export class SimScreenView extends ScreenView { // ── Data table (top right, shifts left when window is wider than layoutBounds) ─ const dataTableNode = new DataTableNode(model, model.videoLoadedProperty, model.calibUnitProperty); this.addChild(dataTableNode); - dataTableNode.top = this.layoutBounds.top + 10; + dataTableNode.top = this.layoutBounds.top + SCREEN_TOP_MARGIN; // ── Reset all (bottom right) ───────────────────────────────────────── const resetAllButton = new ResetAllButton({ @@ -134,14 +140,14 @@ export class SimScreenView extends ScreenView { // 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 + 20 - extraWidth; + dataTableNode.left = this.videoPlayerNode.right + DATA_TABLE_LEFT_SPACING - extraWidth; // Kinematics graph: right edge tracks the visible right boundary. kinematicsGraph.right = visibleBounds.maxX; // Both of these depend on resetAllButton's final position (set above), // so they are wired here inside the same link. - kinematicsGraph.bottom = resetAllButton.top - 150; + kinematicsGraph.bottom = resetAllButton.top - KINEMATICS_GRAPH_BOTTOM_MARGIN; playbackControlsNode.centerY = resetAllButton.centerY; }); diff --git a/src/screen-name/view/VideoSourceControlNode.ts b/src/screen-name/view/VideoSourceControlNode.ts index 5eb71a6..388caf0 100644 --- a/src/screen-name/view/VideoSourceControlNode.ts +++ b/src/screen-name/view/VideoSourceControlNode.ts @@ -22,6 +22,7 @@ const LABEL_FONT = new PhetFont(14); const HEADER_FONT = new PhetFont({ size: 12, style: "italic" }); const CONTROLS_SPACING = 12; const HEADER_VALUE_PREFIX = "__header:"; +const DOWNLOAD_ICON_FONT_SIZE = 11; // font size for the download arrow icon glyph // Bundled video files with known frame rates (labels resolved from StringManager) type VideoFile = { @@ -52,6 +53,7 @@ export class VideoSourceControlNode extends HBox { const uiStrings = StringManager.getInstance().getUI(); const videoFileStrings = StringManager.getInstance().getVideoFiles(); + const videoSourceStrings = StringManager.getInstance().getVideoSource(); const VIDEO_FILES: VideoFile[] = [ { @@ -292,7 +294,7 @@ export class VideoSourceControlNode extends HBox { // ── Download button (visible for user-provided videos) ──────────────── const downloadIcon = new Text("\u2B07", { - font: new PhetFont({ size: 11 }), + font: new PhetFont({ size: DOWNLOAD_ICON_FONT_SIZE }), fill: TrackLabColors.textOnDarkProperty, }); const downloadButton = new RectangularPushButton({ @@ -302,7 +304,7 @@ export class VideoSourceControlNode extends HBox { xMargin: BUTTON_X_MARGIN, yMargin: BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, - accessibleName: "Download Video", + accessibleName: videoSourceStrings.downloadVideoStringProperty, listener: () => { const blob = model.currentWebcamBlobProperty.value; if (!blob) { @@ -375,7 +377,7 @@ export class VideoSourceControlNode extends HBox { xMargin: BUTTON_X_MARGIN, yMargin: BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, - accessibleName: "Open Video File", + accessibleName: videoSourceStrings.openVideoFileStringProperty, listener: () => { model.isPlayingProperty.value = false; fileInput.click(); @@ -409,7 +411,7 @@ export class VideoSourceControlNode extends HBox { yMargin: BUTTON_Y_MARGIN, iconFill: TrackLabColors.textOnDarkProperty, tandem: Tandem.OPT_OUT, - accessibleName: "Record Webcam", + accessibleName: videoSourceStrings.recordWebcamStringProperty, listener: async () => { model.isPlayingProperty.value = false; this.webcamPanel.visible = true; diff --git a/src/screen-name/view/WebcamPanel.ts b/src/screen-name/view/WebcamPanel.ts index b5f4029..45c1bca 100644 --- a/src/screen-name/view/WebcamPanel.ts +++ b/src/screen-name/view/WebcamPanel.ts @@ -40,6 +40,10 @@ const CAMERA_ROW_SPACING = 8; // HBox spacing between camera icon and select const FPS_CONTROL_SPACING = 6; // gap between "fps:" label and spinner const FPS_SPINNER_SCALE = 0.7; const FPS_SPINNER_MIN_WIDTH = 50; +const CAMERA_SELECT_FONT = "14px sans-serif"; // CSS font for the native element +const TIMER_INTERVAL_MS = 1000; // ms between recording-timer display updates +const SECONDS_PER_MINUTE = 60; // conversion factor for mm:ss timer formatting /** Configuration passed to WebcamPanel at construction time. */ type WebcamPanelOptions = { @@ -81,11 +85,12 @@ export class WebcamPanel extends Node { public constructor(options: WebcamPanelOptions) { super(); this.model = options.model; + this.webcamStrings = StringManager.getInstance().getWebcam(); // ── Camera select ───────────────────────────────────────────────────── this.cameraSelect = document.createElement("select"); - this.cameraSelect.style.font = "14px sans-serif"; - this.cameraSelect.style.padding = "4px"; + this.cameraSelect.style.font = CAMERA_SELECT_FONT; + this.cameraSelect.style.padding = CAMERA_SELECT_PADDING; const cameraSelectDom = new DOM(this.cameraSelect, { allowInput: true }); this.cameraSelect.addEventListener("change", async () => { @@ -136,7 +141,7 @@ export class WebcamPanel extends Node { buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, pathOptions: { stroke: TrackLabColors.textOnDarkProperty }, tandem: Tandem.OPT_OUT, - accessibleName: "Cancel", + accessibleName: this.webcamStrings.cancelStringProperty, listener: () => { this.cleanup(); options.onCancel(); @@ -153,7 +158,7 @@ export class WebcamPanel extends Node { xMargin: BUTTON_X_MARGIN, yMargin: BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, - accessibleName: "Start Recording", + accessibleName: this.webcamStrings.startRecordingStringProperty, listener: () => this.startRecording(), }); @@ -168,7 +173,7 @@ export class WebcamPanel extends Node { xMargin: BUTTON_X_MARGIN, yMargin: BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, - accessibleName: "Stop Recording", + accessibleName: this.webcamStrings.stopRecordingStringProperty, listener: () => this.stopRecording(), }); @@ -178,7 +183,7 @@ export class WebcamPanel extends Node { buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy, iconHeight: REFRESH_ICON_HEIGHT, tandem: Tandem.OPT_OUT, - accessibleName: "Re-record", + accessibleName: this.webcamStrings.reRecordStringProperty, listener: () => this.goToPreview(), }); @@ -193,7 +198,7 @@ export class WebcamPanel extends Node { xMargin: BUTTON_X_MARGIN, yMargin: BUTTON_Y_MARGIN, tandem: Tandem.OPT_OUT, - accessibleName: "Use Video", + accessibleName: this.webcamStrings.useVideoStringProperty, listener: () => this.useVideo(options.onVideoReady), }); @@ -229,7 +234,7 @@ export class WebcamPanel extends Node { const cameraIcon = new Path(cameraSolidShape, { scale: CAMERA_ICON_SCALE, fill: TrackLabColors.textMutedProperty, - accessibleName: "Camera", + accessibleName: this.webcamStrings.cameraStringProperty, }); this.previewLayer = new VBox({ children: [ @@ -271,7 +276,7 @@ export class WebcamPanel extends Node { const titleIcon = new Path(cameraSolidShape, { scale: TITLE_CAMERA_ICON_SCALE, fill: TrackLabColors.textOnDarkProperty, - accessibleName: "Record from Webcam", + accessibleName: this.webcamStrings.recordFromWebcamStringProperty, }); const content = new VBox({ children: [titleIcon, this.previewLayer, this.reviewLayer, this.statusText], @@ -292,7 +297,6 @@ export class WebcamPanel extends Node { // keep button refs for toggling this._startButton = startButton; this._stopButton = stopButton; - this.webcamStrings = StringManager.getInstance().getWebcam(); } private readonly _startButton: RectangularPushButton; @@ -368,7 +372,7 @@ export class WebcamPanel extends Node { cameras.forEach((cam, i) => { const opt = document.createElement("option"); opt.value = cam.deviceId; - opt.textContent = cam.label || `Camera ${i + 1}`; + opt.textContent = cam.label || this.webcamStrings.cameraLabelStringProperty.value.replace("{{number}}", String(i + 1)); this.cameraSelect.appendChild(opt); }); } @@ -399,7 +403,7 @@ export class WebcamPanel extends Node { this.reviewElement.src = URL.createObjectURL(this.recordedBlob); // Estimate FPS and update the display - this.setStatus("Estimating frame rate..."); + this.setStatus(this.webcamStrings.estimatingFrameRateStringProperty.value); try { // Pass the pre-captured stream FPS if available if (streamFps && streamFps > 0) { @@ -461,20 +465,28 @@ export class WebcamPanel extends Node { // Format the display string const confidenceText = - confidence === "high" ? "High confidence" : confidence === "medium" ? "Medium confidence" : "Low confidence"; - - this.fpsEstimateText.string = `Estimated: ${fps} fps ${confidenceSymbol} (${confidenceText}, ${method})`; + confidence === "high" + ? this.webcamStrings.highConfidenceStringProperty.value + : confidence === "medium" + ? this.webcamStrings.mediumConfidenceStringProperty.value + : this.webcamStrings.lowConfidenceStringProperty.value; + + this.fpsEstimateText.string = this.webcamStrings.estimatedFpsStringProperty.value + .replace("{{fps}}", String(fps)) + .replace("{{symbol}}", confidenceSymbol) + .replace("{{confidence}}", confidenceText) + .replace("{{method}}", method); } private startTimer(): void { this.timerInterval = setInterval(() => { - const secs = Math.floor((Date.now() - this.recordingStart) / 1000); - const m = Math.floor(secs / 60) + const secs = Math.floor((Date.now() - this.recordingStart) / TIMER_INTERVAL_MS); + const m = Math.floor(secs / SECONDS_PER_MINUTE) .toString() .padStart(2, "0"); - const s = (secs % 60).toString().padStart(2, "0"); + const s = (secs % SECONDS_PER_MINUTE).toString().padStart(2, "0"); this.setStatus(this.webcamStrings.recordingStringProperty.value.replace("{{time}}", `${m}:${s}`)); - }, 1000); + }, TIMER_INTERVAL_MS); } private stopTimer(): void {