@motionstudies/web 0.1.0-alpha.22 → 0.1.0-alpha.23

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -265,3 +265,5 @@ The lab's **Fields** specimen renders a synthetic archipelago of seven instrumen
265
265
  `@motionstudies/core/timeline` supplies validated interval geometry, lookup, time mapping and service-time formatting. `@motionstudies/web/components/StudyTimeline` renders a controlled activity chart and scrubber; import `@motionstudies/web/study-timeline.css`. Pass `bins: {start, end, value}[]`, the time window, current `time` and `onSeek`. A `null` value means missing evidence; zero is a measured zero. `variant="bars"` (default) or `"line"` shares the same time axis. `maximum` optionally fixes the scale for comparisons. Playback and loading remain consumer-owned. See [the full contract](https://github.com/emmettl/motionstudies/blob/main/docs/STUDY-TIMELINE.md) and the Timeline lab specimen.
266
266
 
267
267
  Canvas editions can import `PausedVehicleFrame` from `@motionstudies/core/render-frame` without loading Three.js. The existing Three.js entry point re-exports the same class. Record only a frame actually submitted; change its visibility revision (or replace the tracker) when data, projection, size or selection changes. Time changes invalidate even while paused.
268
+
269
+ Plain playback bars can use `@motionstudies/web/components/TimelineScrubber` with `@motionstudies/web/timeline-scrubber.css`: the same visible rail and 44px interaction area as `StudyTimeline`, without activity bins. Pass the time window, time, seek callback and accessible time label.
@@ -1,23 +1,14 @@
1
1
  import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
- import { useId, useMemo, useRef, useState } from 'react';
2
+ import { useId, useMemo, useState } from 'react';
3
+ import { TimelineScrubber } from './TimelineScrubber.js';
3
4
  import { createTimelineModel, formatTimelineTime, timelineBinAt, timelinePosition, timelineTime } from '@motionstudies/core/timeline';
4
5
  const formatCount = (value) => value.toLocaleString();
5
6
  /** Controlled chart and scrubber. Playback, loading and date/timezone policy belong to the study. */
6
7
  export function StudyTimeline({ bins, windowStart, windowEnd, time, onSeek, label, ariaLabel = label, description, variant = 'bars', step = 1, maximum, disabled = false, formatTime = formatTimelineTime, formatValue = formatCount, missingLabel = 'No observation', emptyLabel = 'No observations in this window', scaleLabel = 'Scale', onScrubStart, onScrubEnd, className = '' }) {
7
- const id = useId(), [hover, setHover] = useState(), scrubbing = useRef(false);
8
+ const id = useId(), [hover, setHover] = useState();
8
9
  const model = useMemo(() => createTimelineModel(bins, { start: windowStart, end: windowEnd }, maximum), [bins, windowStart, windowEnd, maximum]);
9
- const position = timelinePosition(time, model.window), value = Number(timelineTime(position, model.window, step).toPrecision(12));
10
+ const value = Number(timelineTime(timelinePosition(time, model.window), model.window, step).toPrecision(12)), position = timelinePosition(value, model.window);
10
11
  const active = timelineBinAt(model, value), inspected = timelineBinAt(model, hover ?? value);
11
12
  const reading = (amount) => amount == null ? missingLabel : formatValue(amount);
12
- const begin = () => { if (!disabled && !scrubbing.current) {
13
- scrubbing.current = true;
14
- onScrubStart?.();
15
- } };
16
- const finish = () => { if (scrubbing.current) {
17
- scrubbing.current = false;
18
- onScrubEnd?.();
19
- } };
20
- return _jsxs("div", { className: `ms-study-timeline ${className}`, "data-variant": variant, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "ms-study-timeline__heading", children: [_jsx("span", { id: `${id}-label`, children: label }), _jsxs("span", { className: "ms-study-timeline__reading", children: [formatTime(hover ?? value), " \u00B7 ", reading(inspected?.value)] })] }), _jsxs("div", { className: "ms-study-timeline__plot", children: [_jsxs("div", { className: "ms-study-timeline__graph", "aria-hidden": "true", children: [_jsx("svg", { viewBox: "0 0 1000 100", preserveAspectRatio: "none", focusable: "false", children: variant === 'bars' ? model.bars.filter(bar => bar.value !== null && bar.value > 0).map(bar => _jsx("rect", { x: bar.x * 1000, y: (1 - bar.height) * 100, width: bar.width * 1000 * .8, height: bar.height * 100 }, bar.start)) : model.lines.map((line, i) => line.length === 1 ? _jsx("line", { x1: line[0].x * 1000 - 1, x2: line[0].x * 1000 + 1, y1: line[0].y * 100, y2: line[0].y * 100 }, i) : _jsx("polyline", { points: line.map(p => `${p.x * 1000},${p.y * 100}`).join(' ') }, i)) }), _jsx("span", { className: "ms-study-timeline__cursor", style: { left: `${position * 100}%` } }), !model.bars.some(bar => bar.value !== null) && _jsx("span", { className: "ms-study-timeline__empty", children: emptyLabel })] }), _jsx("input", { type: "range", "aria-label": ariaLabel, "aria-describedby": description ? `${id}-description` : undefined, "aria-valuetext": `${formatTime(value)} · ${reading(active?.value)}`, min: windowStart, max: windowEnd, step: step, value: value, disabled: disabled, onChange: event => onSeek(Number(event.target.value)), onPointerDown: begin, onPointerUp: finish, onPointerCancel: finish, onBlur: () => { finish(); setHover(undefined); }, onKeyDown: event => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key))
21
- begin(); }, onKeyUp: finish, onPointerMove: event => { if (disabled || event.pointerType === 'touch')
22
- return; const box = event.currentTarget.getBoundingClientRect(); setHover(timelineTime((event.clientX - box.left - 9) / Math.max(1, box.width - 18), model.window, step)); }, onPointerLeave: () => setHover(undefined) })] }), _jsx("div", { className: "ms-study-timeline__ticks", "aria-hidden": "true", children: [0, .25, .5, .75, 1].map(fraction => _jsx("span", { children: formatTime(windowStart + fraction * (windowEnd - windowStart)) }, fraction)) }), _jsxs("div", { className: "ms-study-timeline__footnote", children: [description && _jsx("span", { id: `${id}-description`, children: description }), _jsxs("span", { children: [scaleLabel, " 0\u2013", formatValue(model.maximum)] })] })] });
13
+ return _jsxs("div", { className: `ms-study-timeline ${className}`, "data-variant": variant, "data-disabled": disabled || undefined, children: [_jsxs("div", { className: "ms-study-timeline__heading", children: [_jsx("span", { id: `${id}-label`, children: label }), _jsxs("span", { className: "ms-study-timeline__reading", children: [formatTime(hover ?? value), " \u00B7 ", reading(inspected?.value)] })] }), _jsx(TimelineScrubber, { className: "ms-study-timeline__plot", windowStart: windowStart, windowEnd: windowEnd, time: value, onSeek: onSeek, step: step, disabled: disabled, ariaLabel: ariaLabel, describedBy: description ? `${id}-description` : undefined, ariaValueText: `${formatTime(value)} · ${reading(active?.value)}`, onScrubStart: onScrubStart, onScrubEnd: onScrubEnd, onInspect: setHover, children: _jsxs("div", { className: "ms-study-timeline__graph", "aria-hidden": "true", children: [_jsx("svg", { viewBox: "0 0 1000 100", preserveAspectRatio: "none", focusable: "false", children: variant === 'bars' ? model.bars.filter(bar => bar.value !== null && bar.value > 0).map(bar => _jsx("rect", { x: bar.x * 1000, y: (1 - bar.height) * 100, width: bar.width * 1000 * .8, height: bar.height * 100 }, bar.start)) : model.lines.map((line, i) => line.length === 1 ? _jsx("line", { x1: line[0].x * 1000 - 1, x2: line[0].x * 1000 + 1, y1: line[0].y * 100, y2: line[0].y * 100 }, i) : _jsx("polyline", { points: line.map(p => `${p.x * 1000},${p.y * 100}`).join(' ') }, i)) }), _jsx("span", { className: "ms-study-timeline__cursor", style: { left: `${position * 100}%` } }), !model.bars.some(bar => bar.value !== null) && _jsx("span", { className: "ms-study-timeline__empty", children: emptyLabel })] }) }), _jsx("div", { className: "ms-study-timeline__ticks", "aria-hidden": "true", children: [0, .25, .5, .75, 1].map(fraction => _jsx("span", { children: formatTime(windowStart + fraction * (windowEnd - windowStart)) }, fraction)) }), _jsxs("div", { className: "ms-study-timeline__footnote", children: [description && _jsx("span", { id: `${id}-description`, children: description }), _jsxs("span", { children: [scaleLabel, " 0\u2013", formatValue(model.maximum)] })] })] });
23
14
  }
@@ -0,0 +1,19 @@
1
+ import { type ReactNode } from 'react';
2
+ export interface TimelineScrubberProps {
3
+ windowStart: number;
4
+ windowEnd: number;
5
+ time: number;
6
+ onSeek: (time: number) => void;
7
+ ariaLabel: string;
8
+ ariaValueText?: string;
9
+ describedBy?: string;
10
+ step?: number;
11
+ disabled?: boolean;
12
+ onScrubStart?: () => void;
13
+ onScrubEnd?: () => void;
14
+ onInspect?: (time: number | undefined) => void;
15
+ children?: ReactNode;
16
+ className?: string;
17
+ }
18
+ /** Shared visible rail and native range interaction; the caller owns the clock. */
19
+ export declare function TimelineScrubber({ windowStart, windowEnd, time, onSeek, ariaLabel, ariaValueText, describedBy, step, disabled, onScrubStart, onScrubEnd, onInspect, children, className }: TimelineScrubberProps): import("react").JSX.Element;
@@ -0,0 +1,26 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useRef } from 'react';
3
+ import { timelinePosition, timelineTime } from '@motionstudies/core/timeline';
4
+ /** Shared visible rail and native range interaction; the caller owns the clock. */
5
+ export function TimelineScrubber({ windowStart, windowEnd, time, onSeek, ariaLabel, ariaValueText, describedBy, step = 1, disabled = false, onScrubStart, onScrubEnd, onInspect, children, className = '' }) {
6
+ const id = useId(), scrubbing = useRef(false);
7
+ const window = { start: windowStart, end: windowEnd };
8
+ const position = timelinePosition(time, window);
9
+ const value = Number.isFinite(time) ? Math.max(windowStart, Math.min(windowEnd, time)) : windowStart;
10
+ if (!Number.isFinite(step) || step <= 0)
11
+ throw new Error('Timeline step must be positive');
12
+ const begin = () => { if (!disabled && !scrubbing.current) {
13
+ scrubbing.current = true;
14
+ onScrubStart?.();
15
+ } };
16
+ const finish = () => { if (scrubbing.current) {
17
+ scrubbing.current = false;
18
+ onScrubEnd?.();
19
+ } };
20
+ return _jsxs("div", { className: `ms-timeline-scrubber ${className}`, "data-disabled": disabled || undefined, style: { '--ms-timeline-position': `${position * 100}%` }, children: [children, _jsxs("div", { className: "ms-timeline-scrubber__rail", "aria-hidden": "true", children: [_jsx("span", { className: "ms-timeline-scrubber__fill" }), _jsx("span", { className: "ms-timeline-scrubber__thumb" })] }), _jsx("input", { id: id, type: "range", "aria-label": ariaLabel, "aria-valuetext": ariaValueText, "aria-describedby": describedBy, min: windowStart, max: windowEnd, step: step, value: value, disabled: disabled, onChange: event => onSeek(Number(event.target.value)), onPointerDown: event => { if (!disabled) {
21
+ event.currentTarget.setPointerCapture(event.pointerId);
22
+ begin();
23
+ } }, onPointerUp: finish, onPointerCancel: finish, onLostPointerCapture: finish, onBlur: () => { finish(); onInspect?.(undefined); }, onKeyDown: event => { if (['ArrowLeft', 'ArrowRight', 'ArrowUp', 'ArrowDown', 'Home', 'End', 'PageUp', 'PageDown'].includes(event.key))
24
+ begin(); }, onKeyUp: finish, onPointerMove: event => { if (disabled || event.pointerType === 'touch' || !onInspect)
25
+ return; const box = event.currentTarget.getBoundingClientRect(); onInspect(timelineTime((event.clientX - box.left - 22) / Math.max(1, box.width - 44), window, step)); }, onPointerLeave: () => onInspect?.(undefined) })] });
26
+ }
@@ -1,8 +1,8 @@
1
+ @import "./timeline-scrubber.css";
1
2
  .ms-study-timeline{--ms-timeline-ink:#dce7eb;--ms-timeline-muted:#8ba3af;--ms-timeline-accent:#e2bc7c;--ms-timeline-chart:#668b9e;--ms-timeline-height:48px;min-width:0;color:var(--ms-timeline-ink);font:10px/1.5 ui-monospace,monospace}
2
3
  .ms-study-timeline__heading{display:flex;align-items:baseline;justify-content:space-between;gap:12px;min-height:20px}.ms-study-timeline__reading{white-space:nowrap;color:var(--ms-timeline-accent);font-variant-numeric:tabular-nums}
3
- .ms-study-timeline__plot{position:relative;height:calc(var(--ms-timeline-height) + 20px);margin-top:4px;isolation:isolate}.ms-study-timeline__graph{position:absolute;inset:4px 9px 12px;border-bottom:1px solid var(--ms-timeline-muted);pointer-events:none}.ms-study-timeline svg{display:block;width:100%;height:100%;overflow:visible}.ms-study-timeline rect{fill:var(--ms-timeline-chart)}.ms-study-timeline polyline,.ms-study-timeline line{fill:none;stroke:var(--ms-timeline-chart);stroke-width:1.5;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round}
4
- .ms-study-timeline__cursor{position:absolute;top:0;bottom:-1px;width:1px;background:var(--ms-timeline-accent);transform:translateX(-.5px)}.ms-study-timeline__cursor::after{content:'';position:absolute;bottom:-4px;left:-3px;width:7px;height:7px;background:var(--ms-timeline-accent);transform:rotate(45deg)}
5
- .ms-study-timeline__plot input[type=range]{position:absolute;inset:0;display:block;box-sizing:border-box;width:100%;height:100%;margin:0;padding:0;opacity:0;cursor:ew-resize;touch-action:pan-y;appearance:none;-webkit-appearance:none}.ms-study-timeline__plot input::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;width:18px;height:44px}.ms-study-timeline__plot input::-moz-range-thumb{width:18px;height:44px;border:0;border-radius:0}.ms-study-timeline__plot:focus-within{outline:2px solid var(--ms-timeline-accent);outline-offset:2px;border-radius:2px}
6
- .ms-study-timeline__ticks{display:flex;justify-content:space-between;gap:8px;padding:0 9px;font-size:9px;font-variant-numeric:tabular-nums;color:var(--ms-timeline-muted)}.ms-study-timeline__footnote{display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px 14px;margin-top:8px;font-size:9px;color:var(--ms-timeline-muted)}.ms-study-timeline__footnote>span:last-child{margin-left:auto}.ms-study-timeline__empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--ms-timeline-muted);font-size:10px}.ms-study-timeline[data-disabled]{opacity:.55}.ms-study-timeline[data-disabled] input{cursor:default}
4
+ .ms-study-timeline__plot{position:relative;height:calc(var(--ms-timeline-height) + 44px);margin-top:4px;isolation:isolate}.ms-study-timeline__graph{position:absolute;inset:4px 22px 22px;border-bottom:1px solid var(--ms-timeline-muted);pointer-events:none}.ms-study-timeline svg{display:block;width:100%;height:100%;overflow:visible}.ms-study-timeline rect{fill:var(--ms-timeline-chart)}.ms-study-timeline polyline,.ms-study-timeline line{fill:none;stroke:var(--ms-timeline-chart);stroke-width:1.5;vector-effect:non-scaling-stroke;stroke-linecap:round;stroke-linejoin:round}
5
+ .ms-study-timeline__cursor{position:absolute;top:0;bottom:-1px;width:1px;background:var(--ms-timeline-accent);transform:translateX(-.5px)}
6
+ .ms-study-timeline__ticks{display:flex;justify-content:space-between;gap:8px;padding:0 22px;font-size:9px;font-variant-numeric:tabular-nums;color:var(--ms-timeline-muted)}.ms-study-timeline__footnote{display:flex;justify-content:space-between;flex-wrap:wrap;gap:4px 14px;margin-top:8px;font-size:9px;color:var(--ms-timeline-muted)}.ms-study-timeline__footnote>span:last-child{margin-left:auto}.ms-study-timeline__empty{position:absolute;inset:0;display:grid;place-items:center;color:var(--ms-timeline-muted);font-size:10px}.ms-study-timeline[data-disabled] input{cursor:default}
7
7
  @media(max-width:480px){.ms-study-timeline__heading{gap:4px;flex-wrap:wrap;font-size:9px}.ms-study-timeline__ticks,.ms-study-timeline__footnote{font-size:8px}}
8
8
  @media(forced-colors:active){.ms-study-timeline{--ms-timeline-ink:CanvasText;--ms-timeline-muted:CanvasText;--ms-timeline-accent:Highlight;--ms-timeline-chart:CanvasText}}
@@ -0,0 +1,16 @@
1
+ .ms-timeline-scrubber{position:relative;min-width:44px;min-height:44px;direction:ltr;isolation:isolate}
2
+ .ms-timeline-scrubber__rail{position:absolute;left:22px;right:22px;bottom:18px;height:8px;border-radius:4px;background:var(--ms-timeline-track,#334650);box-shadow:inset 0 0 0 1px var(--ms-timeline-track-border,#627780);pointer-events:none}
3
+ .ms-timeline-scrubber__fill{position:absolute;inset:0 auto 0 0;width:var(--ms-timeline-position);border-radius:inherit;background:var(--ms-timeline-accent,#acc9b9)}
4
+ .ms-timeline-scrubber__thumb{position:absolute;left:var(--ms-timeline-position);top:50%;width:20px;height:20px;box-sizing:border-box;border:2px solid var(--ms-timeline-accent,#acc9b9);border-radius:50%;background:var(--ms-timeline-accent,#acc9b9);transform:translate(-50%,-50%);box-shadow:0 0 0 3px var(--ms-timeline-halo,#0c172080)}
5
+ .ms-timeline-scrubber input[type=range]{position:absolute;inset:0;display:block;box-sizing:border-box;width:100%;height:100%;min-height:44px;margin:0;padding:0;border:0;opacity:0;cursor:grab;touch-action:pan-y;appearance:none;-webkit-appearance:none;direction:ltr}
6
+ .ms-timeline-scrubber input[type=range]:active{cursor:grabbing}
7
+ .ms-timeline-scrubber input[type=range]::-webkit-slider-thumb{appearance:none;-webkit-appearance:none;box-sizing:border-box;width:44px;height:44px;margin:0;border:0;border-radius:0;background:transparent;box-shadow:none}
8
+ .ms-timeline-scrubber input[type=range]::-moz-range-thumb{width:44px;height:44px;box-sizing:border-box;border:0;border-radius:0}
9
+ .ms-timeline-scrubber:focus-within{outline:2px solid var(--ms-timeline-accent,#acc9b9);outline-offset:2px;border-radius:4px}
10
+ .ms-timeline-scrubber[data-disabled]{opacity:.55}.ms-timeline-scrubber[data-disabled] input{cursor:default}
11
+ @media(forced-colors:active){.ms-timeline-scrubber{--ms-timeline-accent:Highlight;--ms-timeline-track:Canvas;--ms-timeline-track-border:CanvasText;--ms-timeline-halo:Canvas}.ms-timeline-scrubber__rail{border:1px solid CanvasText;box-sizing:border-box}.ms-timeline-scrubber__fill,.ms-timeline-scrubber__thumb{forced-color-adjust:none}}
12
+
13
+ .ms-timeline-scrubber input[type=range]::-webkit-slider-runnable-track{height:44px;border:0;background:transparent}
14
+ .ms-timeline-scrubber input[type=range]::-moz-range-track{height:44px;border:0;background:transparent}
15
+
16
+ .ms-timeline-scrubber input[type=range]:disabled{opacity:0;cursor:default}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/web",
3
- "version": "0.1.0-alpha.22",
3
+ "version": "0.1.0-alpha.23",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Browser controls and loaders for Motion Studies.",
@@ -160,7 +160,13 @@
160
160
  "import": "./components/StudyTimeline.js",
161
161
  "default": "./components/StudyTimeline.js"
162
162
  },
163
- "./study-timeline.css": "./components/study-timeline.css"
163
+ "./study-timeline.css": "./components/study-timeline.css",
164
+ "./components/TimelineScrubber": {
165
+ "types": "./components/TimelineScrubber.d.ts",
166
+ "import": "./components/TimelineScrubber.js",
167
+ "default": "./components/TimelineScrubber.js"
168
+ },
169
+ "./timeline-scrubber.css": "./components/timeline-scrubber.css"
164
170
  },
165
171
  "files": [
166
172
  "**/*.js",
@@ -177,7 +183,7 @@
177
183
  "**/*.css"
178
184
  ],
179
185
  "dependencies": {
180
- "@motionstudies/core": "0.1.0-alpha.22"
186
+ "@motionstudies/core": "0.1.0-alpha.23"
181
187
  },
182
188
  "peerDependencies": {
183
189
  "react": "^19.2.8",