From 704026f3697fc81d6f920aa15d19f326e4e47a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 13:08:53 +0000 Subject: [PATCH 1/3] Leverage TypeScript 5.x features: using, withResolvers, type predicates, template literals, satisfies - tsconfig: bump lib from ES2022 to ES2024 + ESNext.Disposable, enabling Symbol.dispose and Promise.withResolvers type definitions - webcam.ts: add withDispose() helper to adapt .close() APIs to Symbol.dispose; use `using decoder = withDispose(new ImageDecoder(...))` in getAnimatedWebPInfo so decoder.close() is guaranteed on early returns and exceptions; add [Symbol.dispose] to WebcamRecorder as an alias to cleanup(); rewrite stopRecording() and measureEmpiricalFrameRate() with Promise.withResolvers() to flatten the guard clauses out of the Promise constructor - PlottableProperty.ts: export isRecordPlottable / isLivePlottable type-guard functions with explicit p is T predicates (TS 5.5 can infer these, but explicit guards also make .filter(isRecordPlottable) produce RecordPlottable[]) - ConfigurableGraph.ts: replace inline "accessor" in x checks with the named type guards from PlottableProperty - OverlayToolsModel.ts: add VelocityUnit and AccelerationUnit template literal types derived from CalibrationUnit; narrow velocityUnitProperty and accelerationUnitProperty from TReadOnlyProperty to the precise types - brand.ts: switch from const Brand: TBrand = {} (widens literal types) to const Brand = {} satisfies TBrand (validates shape while preserving literals) https://claude.ai/code/session_014gWKu1ZSG9hxhwpFd776KV --- src/brand.ts | 4 +- src/screen-name/graph/ConfigurableGraph.ts | 6 +- src/screen-name/graph/PlottableProperty.ts | 6 ++ src/screen-name/model/OverlayToolsModel.ts | 12 ++- src/webcam.ts | 91 +++++++++++----------- tsconfig.json | 2 +- 6 files changed, 65 insertions(+), 56 deletions(-) diff --git a/src/brand.ts b/src/brand.ts index aebd5aa..b2873bc 100644 --- a/src/brand.ts +++ b/src/brand.ts @@ -11,7 +11,7 @@ import "./splash.js"; import type { TBrand } from "scenerystack/brand"; import { brand, madeWithSceneryStackOnDark, madeWithSceneryStackOnLight } from "scenerystack/brand"; -const Brand: TBrand = { +const Brand = { // Nickname for the brand, which should match the brand subdirectory name, grunt option for --brand as well as the // query parameter for ?brand. This is used in Joist to provide brand-specific logic, such as what to show in the // About dialog, decorative text around the PhET button, and whether to check for updates. @@ -32,6 +32,6 @@ const Brand: TBrand = { getLinks: () => [], logoOnBlackBackground: madeWithSceneryStackOnDark, logoOnWhiteBackground: madeWithSceneryStackOnLight, -}; +} satisfies TBrand; brand.register("Brand", Brand); diff --git a/src/screen-name/graph/ConfigurableGraph.ts b/src/screen-name/graph/ConfigurableGraph.ts index 8b8e5ec..e9c5026 100644 --- a/src/screen-name/graph/ConfigurableGraph.ts +++ b/src/screen-name/graph/ConfigurableGraph.ts @@ -63,7 +63,7 @@ import GraphControlsPanel from "./GraphControlsPanel.js"; import GraphDataManager from "./GraphDataManager.js"; import GraphInteractionHandler from "./GraphInteractionHandler.js"; import GraphRenderer from "./GraphRenderer.js"; -import type { PlottableProperty } from "./PlottableProperty.js"; +import { isRecordPlottable, type PlottableProperty } from "./PlottableProperty.js"; // Line-plot stroke width — only ConfigurableGraph creates LinePlots const PLOT_LINE_WIDTH = 2; @@ -80,8 +80,8 @@ function mapDataPoints( ): Array<{ x: number; y: number }> { const result: Array<{ x: number; y: number }> = []; for (const point of dataPoints) { - const x = "accessor" in xProperty ? xProperty.accessor(point) : 0; - const y = "accessor" in yProperty ? yProperty.accessor(point) : 0; + const x = isRecordPlottable(xProperty) ? xProperty.accessor(point) : 0; + const y = isRecordPlottable(yProperty) ? yProperty.accessor(point) : 0; if (!(Number.isNaN(x) || Number.isNaN(y))) { result.push({ x, y }); } diff --git a/src/screen-name/graph/PlottableProperty.ts b/src/screen-name/graph/PlottableProperty.ts index a3038dd..2646a2a 100644 --- a/src/screen-name/graph/PlottableProperty.ts +++ b/src/screen-name/graph/PlottableProperty.ts @@ -44,3 +44,9 @@ export type LivePlottable = PlottableBase & { /** Union of the two concrete plottable variants. */ export type PlottableProperty = RecordPlottable | LivePlottable; + +/** True when {@link p} sources its value from a data-record accessor. */ +export const isRecordPlottable = (p: PlottableProperty): p is RecordPlottable => "accessor" in p; + +/** True when {@link p} sources its value from a live reactive property. */ +export const isLivePlottable = (p: PlottableProperty): p is LivePlottable => "property" in p; diff --git a/src/screen-name/model/OverlayToolsModel.ts b/src/screen-name/model/OverlayToolsModel.ts index f11c859..3a25abf 100644 --- a/src/screen-name/model/OverlayToolsModel.ts +++ b/src/screen-name/model/OverlayToolsModel.ts @@ -16,6 +16,10 @@ import { buildModelViewTransform } from "./ModelViewTransformFactory.js"; // ── Calibration unit type ────────────────────────────────────────────────── export const CALIBRATION_UNITS = ["mm", "cm", "m", "km", "in", "ft"] as const; export type CalibrationUnit = (typeof CALIBRATION_UNITS)[number]; +/** Velocity unit derived from the calibration unit (e.g. `"m/s"`). */ +export type VelocityUnit = `${CalibrationUnit}/s`; +/** Acceleration unit derived from the calibration unit (e.g. `"m/s²"`). */ +export type AccelerationUnit = `${CalibrationUnit}/s²`; export const CALIBRATION_DISTANCE_RANGE = new Range(0.001, 100000); // ── Video-local coordinate helpers ────────────────────────────────────────── @@ -83,13 +87,13 @@ export class OverlayToolsModel { public readonly calibUnitProperty = new Property("m"); // ── Derived unit strings (for display in graphs and tables) ─────────────── - public readonly velocityUnitProperty: TReadOnlyProperty = new DerivedProperty( + public readonly velocityUnitProperty: TReadOnlyProperty = new DerivedProperty( [this.calibUnitProperty], - (unit) => `${unit}/s`, + (unit): VelocityUnit => `${unit}/s`, ); - public readonly accelerationUnitProperty: TReadOnlyProperty = new DerivedProperty( + public readonly accelerationUnitProperty: TReadOnlyProperty = new DerivedProperty( [this.calibUnitProperty], - (unit) => `${unit}/s²`, + (unit): AccelerationUnit => `${unit}/s²`, ); // ── Model-view transform (derived; the view never writes to this) ───────── diff --git a/src/webcam.ts b/src/webcam.ts index 99f1904..7a941c9 100644 --- a/src/webcam.ts +++ b/src/webcam.ts @@ -327,25 +327,22 @@ export class WebcamRecorder { * Stop recording and return the recorded video as a Blob. */ public stopRecording(): Promise { - return new Promise((resolve, reject) => { - if (!this.mediaRecorder) { - reject(new Error("No active recording.")); - return; - } - - this.mediaRecorder.onstop = () => { - const mimeType = this.mediaRecorder?.mimeType || "video/webm"; - const blob = new Blob(this.recordedChunks, { type: mimeType }); - this.recordedChunks = []; - resolve(blob); - }; - - this.mediaRecorder.onerror = (event) => { - reject(event); - }; - - this.mediaRecorder.stop(); - }); + const { promise, resolve, reject } = Promise.withResolvers(); + if (!this.mediaRecorder) { + reject(new Error("No active recording.")); + return promise; + } + this.mediaRecorder.onstop = () => { + const mimeType = this.mediaRecorder?.mimeType || "video/webm"; + const blob = new Blob(this.recordedChunks, { type: mimeType }); + this.recordedChunks = []; + resolve(blob); + }; + this.mediaRecorder.onerror = (event) => { + reject(event); + }; + this.mediaRecorder.stop(); + return promise; } /** @@ -420,6 +417,10 @@ export class WebcamRecorder { this.mediaRecorder = null; this.recordedChunks = []; } + + public [Symbol.dispose](): void { + this.cleanup(); + } } /** @@ -432,28 +433,24 @@ export class WebcamRecorder { * @returns Promise resolving to the measured FPS */ export function measureEmpiricalFrameRate(video: HTMLVideoElement, durationMs: number = 1000): Promise { - return new Promise((resolve, reject) => { - if (video.readyState < 2) { - reject(new Error("Video must be playing and have enough data")); + const { promise, resolve, reject } = Promise.withResolvers(); + if (video.readyState < 2) { + reject(new Error("Video must be playing and have enough data")); + return promise; + } + let frameCount = 0; + const startTime = performance.now(); + function countFrames(): void { + frameCount++; + const elapsed = performance.now() - startTime; + if (elapsed >= durationMs) { + resolve((frameCount / elapsed) * 1000); return; } - - let frameCount = 0; - const startTime = performance.now(); - - function countFrames(): void { - frameCount++; - const elapsed = performance.now() - startTime; - if (elapsed >= durationMs) { - const fps = (frameCount / elapsed) * 1000; - resolve(fps); - return; - } - requestAnimationFrame(countFrames); - } - requestAnimationFrame(countFrames); - }); + } + requestAnimationFrame(countFrames); + return promise; } export type FPSEstimate = { @@ -543,6 +540,15 @@ export async function estimateVideoFrameRate( const WEBP_DEFAULT_FPS = 30; +/** Wraps any object with a `.close()` method so it can be used with `using`. */ +function withDispose(resource: T): T & Disposable { + return Object.assign(resource, { + [Symbol.dispose]() { + resource.close(); + }, + }); +} + /** * Returns frame count, total duration (seconds), and average fps for an * animated WebP image using the ImageDecoder API (Chrome 94+). @@ -556,20 +562,14 @@ export async function getAnimatedWebPInfo( return null; } try { - const decoder = new ImageDecoder({ - data: blob.stream(), - type: "image/webp", - preferAnimation: true, - }); + using decoder = withDispose(new ImageDecoder({ data: blob.stream(), type: "image/webp", preferAnimation: true })); await decoder.tracks.ready; const track = decoder.tracks.selectedTrack; if (!track) { - decoder.close(); return null; } const frameCount = track.frameCount; if (frameCount <= 0) { - decoder.close(); return null; } // Sum per-frame durations (microseconds) to get total duration in seconds. @@ -579,7 +579,6 @@ export async function getAnimatedWebPInfo( totalMicroseconds += result.image.duration ?? 0; result.image.close(); } - decoder.close(); const duration = totalMicroseconds / 1_000_000; const fps = duration > 0 ? frameCount / duration : WEBP_DEFAULT_FPS; return { frameCount, duration, fps }; diff --git a/tsconfig.json b/tsconfig.json index db90277..0c83abd 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -1,7 +1,7 @@ { "compilerOptions": { "target": "ES2022", - "lib": ["ES2022", "DOM", "DOM.Iterable"], + "lib": ["ES2024", "ESNext.Disposable", "DOM", "DOM.Iterable"], "module": "ESNext", "skipLibCheck": true, "moduleResolution": "bundler", From edd58f68f94fabd3ef80ea9c3cd0961021daba19 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:17:20 +0000 Subject: [PATCH 2/3] Apply more modern JS features: AbortController, Error cause, Array.at, toSorted - VideoPlayerNode.ts: create a single AbortController for all video-element event listeners (error, loadstart, loadedmetadata x2, durationchange, ended, timeupdate) plus the document keydown listener; dispose() calls listenerController.abort() instead of 8 individual removeEventListener calls - AutoTrackerNode.ts: same pattern for the timeupdate/seeked pair; dispose() now calls listenerController.abort() instead of two removeEventListener calls - OpenCVTracker.ts: replace Object.assign(err, { cause: e }) with the native Error constructor second-argument form: new Error(msg, { cause: e }) (ES2022) - TrackingModel.ts: replace spread-then-sort with Array.toSorted() (ES2023), and tracks[tracks.length - 1] with tracks.at(-1) (ES2022) https://claude.ai/code/session_014gWKu1ZSG9hxhwpFd776KV --- src/screen-name/model/TrackingModel.ts | 8 +++---- src/screen-name/view/AutoTrackerNode.ts | 8 +++---- src/screen-name/view/VideoPlayerNode.ts | 31 ++++++++++++------------- src/tracking/OpenCVTracker.ts | 5 ++-- 4 files changed, 26 insertions(+), 26 deletions(-) diff --git a/src/screen-name/model/TrackingModel.ts b/src/screen-name/model/TrackingModel.ts index 4b3068d..386920c 100644 --- a/src/screen-name/model/TrackingModel.ts +++ b/src/screen-name/model/TrackingModel.ts @@ -104,9 +104,9 @@ export class TrackingModel { points: [], }; - const tracks = [...this.tracksProperty.value, track]; - tracks.sort((a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0)); - this.tracksProperty.value = tracks; + this.tracksProperty.value = [...this.tracksProperty.value, track].toSorted( + (a, b) => a.symbol.charCodeAt(0) - b.symbol.charCodeAt(0), + ); } /** @@ -151,7 +151,7 @@ export class TrackingModel { public addTrackAndActivate(): void { this.addTrack(); const tracks = this.tracksProperty.value; - const newest = tracks[tracks.length - 1]; + const newest = tracks.at(-1); if (newest) { this.activeTrackIdProperty.value = newest.id; } diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 463b532..4e9b129 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -324,8 +324,9 @@ export class AutoTrackerNode extends Node { this.pendingFrameId = requestAnimationFrame(processFrame); } }; - videoElement.addEventListener("timeupdate", onFrame); - videoElement.addEventListener("seeked", onFrame); + const listenerController = new AbortController(); + videoElement.addEventListener("timeupdate", onFrame, { signal: listenerController.signal }); + videoElement.addEventListener("seeked", onFrame, { signal: listenerController.signal }); // ── Show/hide based on combined "video loaded && autoTracking" ──────── const autoTrackingShownListener = (shown: boolean) => { @@ -341,8 +342,7 @@ export class AutoTrackerNode extends Node { // ── Centralised cleanup (mirrors the disposeXxx pattern used elsewhere) ─ this.disposeAutoTrackerNode = () => { - videoElement.removeEventListener("timeupdate", onFrame); - videoElement.removeEventListener("seeked", onFrame); + listenerController.abort(); this.cancelPendingFrame(); autoTrackingShownProperty.unlink(autoTrackingShownListener); videoDimensionsProperty.unlink(videoDimensionsListener); diff --git a/src/screen-name/view/VideoPlayerNode.ts b/src/screen-name/view/VideoPlayerNode.ts index d1c94fa..fd5096c 100644 --- a/src/screen-name/view/VideoPlayerNode.ts +++ b/src/screen-name/view/VideoPlayerNode.ts @@ -111,8 +111,14 @@ export class VideoPlayerNode extends Node { const onVideoLoadStart = () => { videoErrorText.visible = false; }; - this.videoElement.addEventListener("error", onVideoError); - this.videoElement.addEventListener("loadstart", onVideoLoadStart); + + // AbortController lets dispose() cancel all video-element listeners with a + // single controller.abort() instead of a matching removeEventListener call. + const listenerController = new AbortController(); + const { signal } = listenerController; + + this.videoElement.addEventListener("error", onVideoError, { signal }); + this.videoElement.addEventListener("loadstart", onVideoLoadStart, { signal }); const updateDuration = () => { const d = this.videoElement.duration; @@ -138,13 +144,13 @@ export class VideoPlayerNode extends Node { this.videoElement.currentTime = Number.MAX_SAFE_INTEGER; } }; - this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata); - this.videoElement.addEventListener("durationchange", updateDuration); + this.videoElement.addEventListener("loadedmetadata", onLoadedMetadata, { signal }); + this.videoElement.addEventListener("durationchange", updateDuration, { signal }); const onEnded = () => { model.playback.isPlayingProperty.value = false; }; - this.videoElement.addEventListener("ended", onEnded); + this.videoElement.addEventListener("ended", onEnded, { signal }); // ── Auto-tracking overlay ────────────────────────────────────────────── const autoTrackingShownProperty = new DerivedProperty( @@ -354,7 +360,7 @@ export class VideoPlayerNode extends Node { this.videoSourceControlNode.centerX = scaledW / 2; resizeHandle.center = this.videoContentWrapper.rightBottom; }; - this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded); + this.videoElement.addEventListener("loadedmetadata", onDimensionsLoaded, { signal }); // Sync model time from video during playback (event-driven, not polled) const onTimeUpdate = () => { @@ -362,7 +368,7 @@ export class VideoPlayerNode extends Node { model.playback.currentTimeProperty.value = this.videoElement.currentTime; } }; - this.videoElement.addEventListener("timeupdate", onTimeUpdate); + this.videoElement.addEventListener("timeupdate", onTimeUpdate, { signal }); // ── Apply video transform (translate + uniform scale) ──────────────── const videoTransformListener = (matrix: import("scenerystack/dot").Matrix3) => { @@ -372,11 +378,11 @@ export class VideoPlayerNode extends Node { // ── Keyboard shortcuts ───────────────────────────────────────────────── const onKeyDown = this.createKeyboardHandler(model); - document.addEventListener("keydown", onKeyDown); + document.addEventListener("keydown", onKeyDown, { signal }); // Store cleanup function this.disposeVideoPlayer = () => { - document.removeEventListener("keydown", onKeyDown); + listenerController.abort(); // removes keydown + all videoElement listeners at once model.overlayTools.videoContentVisibleProperty.unlink(videoContentVisibleListener); TrackLabColors.videoBackgroundColorProperty.unlink(videoBackgroundListener); TrackLabColors.panelHeaderColorProperty.unlink(panelHeaderColorListener); @@ -385,13 +391,6 @@ export class VideoPlayerNode extends Node { model.playback.playbackRateProperty.unlink(playbackRateListener); model.playback.videoTransformProperty.unlink(videoTransformListener); model.playback.panelSizeScaleProperty.unlink(panelSizeScaleListener); - this.videoElement.removeEventListener("loadedmetadata", onLoadedMetadata); - this.videoElement.removeEventListener("loadedmetadata", onDimensionsLoaded); - this.videoElement.removeEventListener("durationchange", updateDuration); - this.videoElement.removeEventListener("ended", onEnded); - this.videoElement.removeEventListener("timeupdate", onTimeUpdate); - this.videoElement.removeEventListener("error", onVideoError); - this.videoElement.removeEventListener("loadstart", onVideoLoadStart); if (this.currentBlobUrl) { URL.revokeObjectURL(this.currentBlobUrl); this.currentBlobUrl = null; diff --git a/src/tracking/OpenCVTracker.ts b/src/tracking/OpenCVTracker.ts index fbaea8f..2695efe 100644 --- a/src/tracking/OpenCVTracker.ts +++ b/src/tracking/OpenCVTracker.ts @@ -156,8 +156,9 @@ export class OpenCVTracker { try { return this.ctx.getImageData(x, y, w, h); } catch (e) { - const err = new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers."); - throw Object.assign(err, { cause: e }); + throw new Error("Cannot read video pixels: the video source may be cross-origin without CORS headers.", { + cause: e, + }); } } From b8c423e95e157c9c6063635e560f8820ba76d39e Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 15:27:33 +0000 Subject: [PATCH 3/3] Apply async/await, iterator destructuring, and Map spread improvements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - AutoTrackerNode.ts: replace .then().catch() promise chain with an async IIFE so the tracker-init error path reads as a flat try/catch block. The floating- promise behaviour is preserved (fire-and-forget from a sync DragListener callback); the IIFE is just cleaner than nested callback chains. - AxisGestureHandler.ts / ZoomGestureHandler.ts: replace Array.from(map.values()) + two indexed reads with a single destructuring assignment directly from the MapIterator: `const [p0, p1] = activePointers.values()` (ES2024 iterator destructuring — no intermediate array allocation). - PlaybackControlsNode.ts: replace Array.from(speedMap.entries()).map(...) with [...speedMap].map(...); Map is iterable and spreads as [key, value][] pairs, making the intermediate Array.from redundant. https://claude.ai/code/session_014gWKu1ZSG9hxhwpFd776KV --- src/screen-name/graph/AxisGestureHandler.ts | 4 +--- src/screen-name/graph/ZoomGestureHandler.ts | 4 +--- src/screen-name/view/AutoTrackerNode.ts | 12 ++++++------ src/screen-name/view/PlaybackControlsNode.ts | 2 +- 4 files changed, 9 insertions(+), 13 deletions(-) diff --git a/src/screen-name/graph/AxisGestureHandler.ts b/src/screen-name/graph/AxisGestureHandler.ts index 08bb094..a2868cb 100644 --- a/src/screen-name/graph/AxisGestureHandler.ts +++ b/src/screen-name/graph/AxisGestureHandler.ts @@ -124,9 +124,7 @@ export default class AxisGestureHandler { singleTouchStart = coord(pt); initialRange = getRange().copy(); } else if (activePointers.size === 2) { - const points = Array.from(activePointers.values()); - const p0 = points[0]; - const p1 = points[1]; + const [p0, p1] = activePointers.values(); if (p0 && p1) { initialPinchDistance = Math.abs(coord(p0) - coord(p1)); initialPinchMidpoint = (coord(p0) + coord(p1)) / 2; diff --git a/src/screen-name/graph/ZoomGestureHandler.ts b/src/screen-name/graph/ZoomGestureHandler.ts index 996452c..67f4f94 100644 --- a/src/screen-name/graph/ZoomGestureHandler.ts +++ b/src/screen-name/graph/ZoomGestureHandler.ts @@ -114,9 +114,7 @@ export default class ZoomGestureHandler { activePointers.set(event.pointer, localPoint); if (activePointers.size === 2) { - const points = Array.from(activePointers.values()); - const point0 = points[0]; - const point1 = points[1]; + const [point0, point1] = activePointers.values(); if (point0 && point1) { initialDistance = point0.distance(point1); initialMidpoint = point0.average(point1); diff --git a/src/screen-name/view/AutoTrackerNode.ts b/src/screen-name/view/AutoTrackerNode.ts index 4e9b129..0f025ac 100644 --- a/src/screen-name/view/AutoTrackerNode.ts +++ b/src/screen-name/view/AutoTrackerNode.ts @@ -228,9 +228,9 @@ export class AutoTrackerNode extends Node { // automatically once isTrackerReady becomes true. Staleness detection // (new drag starting before this one resolves) is handled inside the // model: initTracker() returns false when superseded. - this.tracking - .initTracker(videoElement, region) - .then((ready) => { + (async () => { + try { + const ready = await this.tracking.initTracker(videoElement, region); if (!ready) { // Superseded by a newer drag — silently discard. return; @@ -246,8 +246,7 @@ export class AutoTrackerNode extends Node { this.tracking.resetTracker(); this.hintText.visible = true; } - }) - .catch((err: unknown) => { + } catch (err: unknown) { // biome-ignore lint/suspicious/noConsole: error logging for tracker init failure console.error("AutoTracker: failed to initialise OpenCV tracker:", err); const message = @@ -255,7 +254,8 @@ export class AutoTrackerNode extends Node { this.errorText.string = message; this.errorText.visible = true; this.hintText.visible = true; - }); + } + })(); } else { this.hintText.visible = true; } diff --git a/src/screen-name/view/PlaybackControlsNode.ts b/src/screen-name/view/PlaybackControlsNode.ts index aadbf3e..65ef8b4 100644 --- a/src/screen-name/view/PlaybackControlsNode.ts +++ b/src/screen-name/view/PlaybackControlsNode.ts @@ -63,7 +63,7 @@ export class PlaybackControlsNode extends HBox { [TimeSpeed.NORMAL, SPEED_NORMAL], [TimeSpeed.SLOW, SPEED_SLOW], ]); - const rateToSpeed = new Map(Array.from(speedMap.entries()).map(([k, v]) => [v, k])); + const rateToSpeed = new Map([...speedMap].map(([k, v]) => [v, k])); const timeSpeedProperty = new EnumerationProperty(TimeSpeed.NORMAL); // view → model