From 704026f3697fc81d6f920aa15d19f326e4e47a05 Mon Sep 17 00:00:00 2001 From: Claude Date: Sun, 1 Mar 2026 13:08:53 +0000 Subject: [PATCH] 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",