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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
153 changes: 153 additions & 0 deletions src/TrackLabButton.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,153 @@
/**
* TrackLabButton.ts
*
* Factory function and icon helpers for all RectangularPushButton instances in
* TrackLab. A single factory ensures every button shares the same flat
* appearance, base colour, margins, and touch/mouse target sizes, while still
* allowing per-call overrides for special cases (e.g. record/stop/success
* colours, enabled properties).
*
* Usage:
* import { createTrackLabButton, makeDownloadIcon } from '../../TrackLabButton.js';
*
* const btn = createTrackLabButton(makeDownloadIcon(), {
* accessibleName: strings.downloadVideoStringProperty,
* listener: () => { ... },
* });
*
* // Override base colour for a special action:
* const recordBtn = createTrackLabButton(recordIcon, {
* baseColor: TrackLabColors.buttonRecordProperty,
* listener: () => startRecording(),
* });
*/

import { Shape } from "scenerystack/kite";
import { HStrut, Node, Path, VStrut } from "scenerystack/scenery";
import { ButtonNode, RectangularPushButton } from "scenerystack/sun";
import { Tandem } from "scenerystack/tandem";
import TrackLabColors from "./TrackLabColors.js";
import {
BUTTON_MIN_CONTENT_SIZE,
BUTTON_X_MARGIN,
BUTTON_Y_MARGIN,
MOUSE_AREA_DILATION,
TOUCH_AREA_DILATION,
} from "./TrackLabConstants.js";

// ── Types ─────────────────────────────────────────────────────────────────────

type PushButtonOptions = ConstructorParameters<typeof RectangularPushButton>[0];

/**
* Options accepted by createTrackLabButton.
* `content` is excluded because it is supplied as the first argument.
*/
export type TrackLabButtonOptions = Omit<PushButtonOptions, "content">;

// ── Content sizing ────────────────────────────────────────────────────────────

/**
* Wrap an icon node with HStrut/VStrut so the content area is at least
* BUTTON_MIN_CONTENT_SIZE × BUTTON_MIN_CONTENT_SIZE. The icon is centred
* within this minimum area so that small glyphs sit squarely in the middle
* of the button rather than in a corner.
*
* If `icon` is already larger than the minimum (e.g. a wide text label), the
* struts have no visible effect and the button sizes naturally to the content.
*/
function sizeContent(icon: Node): Node {
icon.centerX = BUTTON_MIN_CONTENT_SIZE / 2;
icon.centerY = BUTTON_MIN_CONTENT_SIZE / 2;
return new Node({
children: [new HStrut(BUTTON_MIN_CONTENT_SIZE), new VStrut(BUTTON_MIN_CONTENT_SIZE), icon],
});
}

// ── Factory ───────────────────────────────────────────────────────────────────

/**
* Create a standard TrackLab rectangular push button.
*
* Defaults applied (all overridable via `options`):
* - Flat appearance strategy
* - Dark base colour (`buttonBaseDarkProperty`)
* - Consistent x/y margins from TrackLabConstants
* - Enlarged touch area (TOUCH_AREA_DILATION on each side)
* - Enlarged mouse area (MOUSE_AREA_DILATION on each side)
* - Tandem opted out
*
* The content is always wrapped with HStrut/VStrut to guarantee a minimum
* icon area so that all icon buttons share identical dimensions.
*/
export function createTrackLabButton(content: Node, options?: TrackLabButtonOptions): RectangularPushButton {
return new RectangularPushButton({
// ── Defaults ────────────────────────────────────────────────────────────
baseColor: TrackLabColors.buttonBaseDarkProperty,
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
xMargin: BUTTON_X_MARGIN,
yMargin: BUTTON_Y_MARGIN,
touchAreaXDilation: TOUCH_AREA_DILATION,
touchAreaYDilation: TOUCH_AREA_DILATION,
mouseAreaXDilation: MOUSE_AREA_DILATION,
mouseAreaYDilation: MOUSE_AREA_DILATION,
tandem: Tandem.OPT_OUT,
// ── Caller overrides ────────────────────────────────────────────────────
...options,
// ── Content: always the min-sized wrapper ────────────────────────────────
content: sizeContent(content),
});
}

// ── Icon helpers ──────────────────────────────────────────────────────────────

/**
* Download icon: downward arrow with a tray bar.
*
* Replaces the plain ⬇ unicode glyph with a proper symbolic Path icon that
* scales cleanly at all sizes and renders crisply regardless of font hinting.
*
* | shaft |
* \ arrow /
* \_head__/
* [=tray bar=]
*/
export function makeDownloadIcon(): Node {
const totalW = 12; // total icon width
const shaftW = 4; // width of the vertical arrow shaft
const shaftH = 5; // height of the shaft above the arrowhead
const headH = 4; // height of the arrowhead triangle
const gap = 1; // gap between arrowhead tip and tray bar
const barH = 2; // height of the tray bar

const shape = new Shape();

// Vertical shaft (centered horizontally)
shape.rect((totalW - shaftW) / 2, 0, shaftW, shaftH);

// Arrowhead triangle (pointing down)
shape.moveTo(0, shaftH);
shape.lineTo(totalW / 2, shaftH + headH);
shape.lineTo(totalW, shaftH);
shape.close();

// Tray bar at bottom
shape.rect(0, shaftH + headH + gap, totalW, barH);

return new Path(shape, { fill: TrackLabColors.textOnDarkProperty });
}

/**
* Upload icon: folder shape indicating "open a file".
*/
export function makeUploadIcon(): Node {
const folderShape = new Shape()
.moveTo(0, 3)
.lineTo(4, 3)
.lineTo(5.5, 0)
.lineTo(14, 0)
.lineTo(14, 10)
.lineTo(0, 10)
.close();
return new Path(folderShape, { fill: TrackLabColors.textOnDarkProperty });
}
12 changes: 12 additions & 0 deletions src/TrackLabConstants.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,18 @@ export const MIN_CALIB_DISTANCE = 1e-9; // minimum real-world calibration distan
export const BUTTON_X_MARGIN = 8;
export const BUTTON_Y_MARGIN = 6;

// Minimum icon content area (width × height) guaranteed by HStrut/VStrut inside
// the factory-created content wrapper. This ensures every icon-only button is
// the same size even when the icon glyph is smaller than this floor.
export const BUTTON_MIN_CONTENT_SIZE = 18;

// Extra touch-target padding beyond the rendered button bounds. Helps users on
// touch screens reliably tap small buttons without pixel-perfect precision.
export const TOUCH_AREA_DILATION = 5;

// Extra mouse-pointer hit area beyond the rendered button bounds.
export const MOUSE_AREA_DILATION = 2;

// ── Webcam panel ──────────────────────────────────────────────────────────────
export const WEBCAM_PREVIEW_WIDTH = 576; // width of the preview and review video elements (20% bigger)
export const WEBCAM_PREVIEW_HEIGHT = 324; // height of the preview and review video elements (20% bigger)
Expand Down
64 changes: 26 additions & 38 deletions src/screen-name/view/DataTableNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,12 +12,13 @@
*/

import type { TReadOnlyProperty } from "scenerystack/axon";
import { DOM, HBox, type Node, Text, VBox } from "scenerystack/scenery";
import { DOM, HBox, Text, VBox } from "scenerystack/scenery";
import { PhetFont } from "scenerystack/scenery-phet";
import { ButtonNode, Panel, RectangularPushButton } from "scenerystack/sun";
import { Panel } from "scenerystack/sun";
import { StringManager } from "../../i18n/StringManager.js";
import { createTrackLabButton, makeDownloadIcon } from "../../TrackLabButton.js";
import TrackLabColors from "../../TrackLabColors.js";
import { BUTTON_X_MARGIN, BUTTON_Y_MARGIN, PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js";
import { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js";
import type { SimModel } from "../model/SimModel.js";
import type { Track } from "../model/Track.js";

Expand All @@ -37,7 +38,6 @@ 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.
Expand Down Expand Up @@ -352,17 +352,6 @@ function buildSingleDataRow(
return tr;
}

/**
* Download icon (simple arrow pointing down).
*/
function makeDownloadIcon(): Node {
// Simple text-based icon
return new Text("⬇", {
font: new PhetFont({ size: DOWNLOAD_ICON_FONT_SIZE }),
fill: TrackLabColors.textOnDarkProperty,
});
}

// ── Component ────────────────────────────────────────────────────────────────

export class DataTableNode extends Panel {
Expand Down Expand Up @@ -413,8 +402,8 @@ export class DataTableNode extends Panel {
const tableDomNode = new DOM(tableWrapper, { allowInput: true });

// ── Export button ────────────────────────────────────────────────────────
const exportButton = new RectangularPushButton({
content: new HBox({
const exportButton = createTrackLabButton(
new HBox({
children: [
makeDownloadIcon(),
new Text(dataTableStrings.csvStringProperty, {
Expand All @@ -427,28 +416,27 @@ export class DataTableNode extends Panel {
],
spacing: EXPORT_BUTTON_ICON_SPACING,
}),
accessibleName: a11yStrings.exportCSVStringProperty,
baseColor: TrackLabColors.exportButtonProperty,
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
xMargin: BUTTON_X_MARGIN,
yMargin: BUTTON_Y_MARGIN,
listener: () => {
const tracks = model.tracksProperty.value;
const unit = unitProperty.value;
const csv = generateCsv(tracks, unit, getLabels());

// Create download — no DOM insertion needed in modern browsers.
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `export${this.exportCounter}.csv`;
link.click();
URL.revokeObjectURL(url);

this.exportCounter++;
{
accessibleName: a11yStrings.exportCSVStringProperty,
baseColor: TrackLabColors.exportButtonProperty,
listener: () => {
const tracks = model.tracksProperty.value;
const unit = unitProperty.value;
const csv = generateCsv(tracks, unit, getLabels());

// Create download — no DOM insertion needed in modern browsers.
const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" });
const url = URL.createObjectURL(blob);
const link = document.createElement("a");
link.href = url;
link.download = `export${this.exportCounter}.csv`;
link.click();
URL.revokeObjectURL(url);

this.exportCounter++;
},
},
});
);

// ── Title row ────────────────────────────────────────────────────────────
const titleLabel = new Text(dataTableStrings.titleStringProperty, {
Expand Down
31 changes: 14 additions & 17 deletions src/screen-name/view/PlaybackControlsNode.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,14 +9,14 @@ import { DerivedProperty, EnumerationProperty } from "scenerystack/axon";
import { Dimension2, Range } from "scenerystack/dot";
import { HBox, Text, VBox } from "scenerystack/scenery";
import { PhetFont, TimeControlNode, TimeSpeed } from "scenerystack/scenery-phet";
import { ButtonNode, HSlider, RectangularPushButton } from "scenerystack/sun";
import { HSlider } from "scenerystack/sun";

import { Tandem } from "scenerystack/tandem";
import { StringManager } from "../../i18n/StringManager.js";
import { createTrackLabButton } from "../../TrackLabButton.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";

const LABEL_FONT = new PhetFont(14);
Expand Down Expand Up @@ -195,24 +195,21 @@ export class PlaybackControlsNode extends HBox {
});

// ── Rewind-to-zero button ──────────────────────────────────────────────
const rewindButton = new RectangularPushButton({
content: new Text("\u23EE", {
const rewindButton = createTrackLabButton(
new Text("\u23EE", {
font: new PhetFont(REWIND_BUTTON_ICON_SIZE),
fill: TrackLabColors.textOnDarkProperty,
}),
baseColor: TrackLabColors.buttonBaseDarkProperty,
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
xMargin: BUTTON_X_MARGIN,
yMargin: BUTTON_Y_MARGIN,
listener: () => {
model.isPlayingProperty.value = false;
model.currentTimeProperty.value = 0;
videoElement.currentTime = 0;
{
enabledProperty: model.videoLoadedProperty,
accessibleName: a11yStrings.rewindToStartStringProperty,
listener: () => {
model.isPlayingProperty.value = false;
model.currentTimeProperty.value = 0;
videoElement.currentTime = 0;
},
},
enabledProperty: model.videoLoadedProperty,
tandem: Tandem.OPT_OUT,
accessibleName: a11yStrings.rewindToStartStringProperty,
});
);

this.children = [timeControlNode, scrubber, rewindButton, infoDisplay];

Expand Down
30 changes: 11 additions & 19 deletions src/screen-name/view/TrackListPanel.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,14 +17,14 @@ import { Color } from "scenerystack";
import { BooleanProperty, DerivedProperty, type TReadOnlyProperty } from "scenerystack/axon";
import { Circle, Line, Node, Rectangle, Text, VBox } from "scenerystack/scenery";
import { PhetFont } from "scenerystack/scenery-phet";
import { ButtonNode, Checkbox, Panel, RectangularPushButton } from "scenerystack/sun";
import { Checkbox, Panel } from "scenerystack/sun";
import { Tandem } from "scenerystack/tandem";
import { StringManager } from "../../i18n/StringManager.js";
import { createTrackLabButton } from "../../TrackLabButton.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 { PANEL_CORNER_RADIUS } from "../../TrackLabConstants.js";
import type { SimModel } from "../model/SimModel.js";
import type { Track } from "../model/Track.js";

Expand Down Expand Up @@ -149,14 +149,9 @@ class TrackRowNode extends Node {
checkbox.centerY = ROW_CY;

// ── Trash button (right side) ─────────────────────────────────────────
const trashButton = new RectangularPushButton({
content: makeTrashIcon(),
const trashButton = createTrackLabButton(makeTrashIcon(), {
baseColor: TrackLabColors.trashButtonBaseProperty,
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
xMargin: BUTTON_X_MARGIN,
yMargin: BUTTON_Y_MARGIN,
listener: () => model.removeTrack(track.id),
tandem: Tandem.OPT_OUT,
accessibleName: a11yStrings.removeTrackStringProperty.value.replace("{{symbol}}", track.symbol),
});
trashButton.centerY = ROW_CY;
Expand Down Expand Up @@ -206,19 +201,16 @@ export class TrackListPanel extends Panel {
(loaded, canAdd) => loaded && canAdd,
);

const addButton = new RectangularPushButton({
content: new Text(trackListStrings.addTrackStringProperty, {
const addButton = createTrackLabButton(
new Text(trackListStrings.addTrackStringProperty, {
font: LABEL_FONT,
fill: TrackLabColors.textOnDarkProperty,
}),
baseColor: TrackLabColors.buttonBaseDarkProperty,
buttonAppearanceStrategy: ButtonNode.FlatAppearanceStrategy,
xMargin: BUTTON_X_MARGIN,
yMargin: BUTTON_Y_MARGIN,
enabledProperty: addButtonEnabledProperty,
listener: () => model.addTrack(),
tandem: Tandem.OPT_OUT,
});
{
enabledProperty: addButtonEnabledProperty,
listener: () => model.addTrack(),
},
);

// ── Track list (rebuilt whenever tracks change) ───────────────────────
const trackListVBox = new VBox({
Expand Down
Loading