@motionstudies/web 0.1.0-alpha.21 → 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
@@ -259,3 +259,11 @@ The lab's **Fields** specimen renders a synthetic archipelago of seven instrumen
259
259
  ## Recorded power days
260
260
 
261
261
  `@motionstudies/core/domain/power-day` exports `readPowerDay`, `powerPeriod`, `unitPower`, `plannedPower`, `sumPower` and `fuelMixAt`, plus their data types. A complete civil day declares its timezone, UTC bounds, half-hour intervals, MWh settlement quantities and MW plans/aggregates. Missing values remain null; signed quantities remain signed. Interval energy becomes average power only through the explicit interval duration. Declarations are interpolated only within one unambiguous segment; fuel samples expire after five minutes. Callers choose aggregate membership and receive a missing-contributor count. The reader validates source links, settlement run labels, identities, placement coordinates and safe evidence-artifact paths without fetching data. Source adapters, placement policy and the composition stay outside core. See [power ownership](../docs/POWER.md#ownership-and-handoff).
262
+
263
+ ## Activity timeline
264
+
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
+
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.
@@ -0,0 +1,25 @@
1
+ import { type TimelineBin } from '@motionstudies/core/timeline';
2
+ export interface StudyTimelineProps {
3
+ bins: readonly TimelineBin[];
4
+ windowStart: number;
5
+ windowEnd: number;
6
+ time: number;
7
+ onSeek: (time: number) => void;
8
+ label: string;
9
+ ariaLabel?: string;
10
+ description?: string;
11
+ variant?: 'bars' | 'line';
12
+ step?: number;
13
+ maximum?: number;
14
+ disabled?: boolean;
15
+ formatTime?: (time: number) => string;
16
+ formatValue?: (value: number) => string;
17
+ missingLabel?: string;
18
+ emptyLabel?: string;
19
+ scaleLabel?: string;
20
+ onScrubStart?: () => void;
21
+ onScrubEnd?: () => void;
22
+ className?: string;
23
+ }
24
+ /** Controlled chart and scrubber. Playback, loading and date/timezone policy belong to the study. */
25
+ export declare function StudyTimeline({ bins, windowStart, windowEnd, time, onSeek, label, ariaLabel, description, variant, step, maximum, disabled, formatTime, formatValue, missingLabel, emptyLabel, scaleLabel, onScrubStart, onScrubEnd, className }: StudyTimelineProps): import("react").JSX.Element;
@@ -0,0 +1,14 @@
1
+ import { jsx as _jsx, jsxs as _jsxs } from "react/jsx-runtime";
2
+ import { useId, useMemo, useState } from 'react';
3
+ import { TimelineScrubber } from './TimelineScrubber.js';
4
+ import { createTimelineModel, formatTimelineTime, timelineBinAt, timelinePosition, timelineTime } from '@motionstudies/core/timeline';
5
+ const formatCount = (value) => value.toLocaleString();
6
+ /** Controlled chart and scrubber. Playback, loading and date/timezone policy belong to the study. */
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 = '' }) {
8
+ const id = useId(), [hover, setHover] = useState();
9
+ const model = useMemo(() => createTimelineModel(bins, { start: windowStart, end: windowEnd }, maximum), [bins, windowStart, windowEnd, maximum]);
10
+ const value = Number(timelineTime(timelinePosition(time, model.window), model.window, step).toPrecision(12)), position = timelinePosition(value, model.window);
11
+ const active = timelineBinAt(model, value), inspected = timelineBinAt(model, hover ?? value);
12
+ const reading = (amount) => amount == null ? missingLabel : formatValue(amount);
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)] })] })] });
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
+ }
@@ -0,0 +1,8 @@
1
+ @import "./timeline-scrubber.css";
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}
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}
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
+ @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
+ @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.21",
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.",
@@ -154,7 +154,19 @@
154
154
  "default": "./components/NetworkVehicleHeroCard.js"
155
155
  },
156
156
  "./study-layout.css": "./study-layout.css",
157
- "./fonts.css": "./fonts.css"
157
+ "./fonts.css": "./fonts.css",
158
+ "./components/StudyTimeline": {
159
+ "types": "./components/StudyTimeline.d.ts",
160
+ "import": "./components/StudyTimeline.js",
161
+ "default": "./components/StudyTimeline.js"
162
+ },
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"
158
170
  },
159
171
  "files": [
160
172
  "**/*.js",
@@ -171,7 +183,7 @@
171
183
  "**/*.css"
172
184
  ],
173
185
  "dependencies": {
174
- "@motionstudies/core": "0.1.0-alpha.21"
186
+ "@motionstudies/core": "0.1.0-alpha.23"
175
187
  },
176
188
  "peerDependencies": {
177
189
  "react": "^19.2.8",