@motionstudies/core 0.1.0-alpha.21 → 0.1.0-alpha.22

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,9 @@ 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.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@motionstudies/core",
3
- "version": "0.1.0-alpha.21",
3
+ "version": "0.1.0-alpha.22",
4
4
  "private": false,
5
5
  "type": "module",
6
6
  "description": "Transport contracts and motion primitives for Motion Studies.",
@@ -209,6 +209,16 @@
209
209
  "types": "./domain/power-day.d.ts",
210
210
  "import": "./domain/power-day.js",
211
211
  "default": "./domain/power-day.js"
212
+ },
213
+ "./timeline": {
214
+ "types": "./timeline.d.ts",
215
+ "import": "./timeline.js",
216
+ "default": "./timeline.js"
217
+ },
218
+ "./render-frame": {
219
+ "types": "./render-frame.d.ts",
220
+ "import": "./render-frame.js",
221
+ "default": "./render-frame.js"
212
222
  }
213
223
  },
214
224
  "files": [
@@ -0,0 +1,8 @@
1
+ /** Track the last submitted buffers, not the last animation callback. Create a
2
+ * fresh tracker when geometry, projection, data or selection inputs change. */
3
+ export declare class PausedVehicleFrame {
4
+ private time;
5
+ private visibility;
6
+ needsUpdate(isPlaying: boolean, time: number, visibility: number): boolean;
7
+ record(time: number, visibility: number): void;
8
+ }
@@ -0,0 +1,13 @@
1
+ /** Track the last submitted buffers, not the last animation callback. Create a
2
+ * fresh tracker when geometry, projection, data or selection inputs change. */
3
+ export class PausedVehicleFrame {
4
+ time = NaN;
5
+ visibility = NaN;
6
+ needsUpdate(isPlaying, time, visibility) {
7
+ return isPlaying || this.time !== time || this.visibility !== visibility;
8
+ }
9
+ record(time, visibility) {
10
+ this.time = time;
11
+ this.visibility = visibility;
12
+ }
13
+ }
package/timeline.d.ts ADDED
@@ -0,0 +1,31 @@
1
+ /** A measured interval. Null means missing evidence; zero is a measured zero. */
2
+ export interface TimelineBin {
3
+ start: number;
4
+ end: number;
5
+ value: number | null;
6
+ }
7
+ export interface TimelineWindow {
8
+ start: number;
9
+ end: number;
10
+ }
11
+ export interface TimelineBar extends TimelineBin {
12
+ x: number;
13
+ width: number;
14
+ height: number;
15
+ }
16
+ export interface TimelineModel {
17
+ window: TimelineWindow;
18
+ maximum: number;
19
+ bars: TimelineBar[];
20
+ lines: {
21
+ x: number;
22
+ y: number;
23
+ }[][];
24
+ }
25
+ export declare function timelinePosition(time: number, window: TimelineWindow): number;
26
+ export declare function timelineTime(position: number, window: TimelineWindow, step?: number): number;
27
+ /** Build chart geometry once per series, independently of playback and rendering. */
28
+ export declare function createTimelineModel(bins: readonly TimelineBin[], window: TimelineWindow, maximum?: number): TimelineModel;
29
+ export declare function timelineBinAt(model: TimelineModel, time: number): TimelineBar | undefined;
30
+ /** Service time, including 24:00 and times beyond midnight; no timezone inference. */
31
+ export declare function formatTimelineTime(seconds: number): string;
package/timeline.js ADDED
@@ -0,0 +1,68 @@
1
+ function checkWindow(window) {
2
+ if (!Number.isFinite(window.start) || !Number.isFinite(window.end) || window.end <= window.start)
3
+ throw new Error('Timeline needs a finite, increasing window');
4
+ }
5
+ export function timelinePosition(time, window) {
6
+ checkWindow(window);
7
+ return Number.isFinite(time) ? Math.max(0, Math.min(1, (time - window.start) / (window.end - window.start))) : 0;
8
+ }
9
+ export function timelineTime(position, window, step = 1) {
10
+ checkWindow(window);
11
+ if (!Number.isFinite(step) || step <= 0)
12
+ throw new Error('Timeline step must be positive');
13
+ const fraction = Number.isFinite(position) ? Math.max(0, Math.min(1, position)) : 0;
14
+ return Math.min(window.end, window.start + Math.round(fraction * (window.end - window.start) / step) * step);
15
+ }
16
+ /** Build chart geometry once per series, independently of playback and rendering. */
17
+ export function createTimelineModel(bins, window, maximum) {
18
+ checkWindow(window);
19
+ if (maximum !== undefined && (!Number.isFinite(maximum) || maximum < 0))
20
+ throw new Error('Timeline maximum must be non-negative');
21
+ const sorted = [...bins].sort((a, b) => a.start - b.start);
22
+ let previous = -Infinity;
23
+ for (const bin of sorted) {
24
+ if (!Number.isFinite(bin.start) || !Number.isFinite(bin.end) || bin.end <= bin.start || bin.start < previous)
25
+ throw new Error('Timeline bins must be finite, increasing and non-overlapping');
26
+ if (bin.value !== null && (!Number.isFinite(bin.value) || bin.value < 0))
27
+ throw new Error('Timeline values must be non-negative or null');
28
+ previous = bin.end;
29
+ }
30
+ const visible = sorted.filter(bin => bin.end > window.start && bin.start < window.end);
31
+ const peak = maximum ?? visible.reduce((value, bin) => Math.max(value, bin.value ?? 0), 0);
32
+ const bars = visible.map(bin => {
33
+ const start = Math.max(window.start, bin.start), end = Math.min(window.end, bin.end);
34
+ return { ...bin, start, end, x: timelinePosition(start, window), width: (end - start) / (window.end - window.start), height: peak > 0 && bin.value !== null ? Math.min(1, bin.value / peak) : 0 };
35
+ });
36
+ const lines = [];
37
+ let line = [], lastEnd = -Infinity;
38
+ for (const bar of bars) {
39
+ if (bar.value === null || bar.start !== lastEnd) {
40
+ if (line.length)
41
+ lines.push(line);
42
+ line = [];
43
+ }
44
+ if (bar.value !== null)
45
+ line.push({ x: bar.x + bar.width / 2, y: 1 - bar.height });
46
+ lastEnd = bar.end;
47
+ }
48
+ if (line.length)
49
+ lines.push(line);
50
+ return { window: { ...window }, maximum: peak, bars, lines };
51
+ }
52
+ export function timelineBinAt(model, time) {
53
+ let lo = 0, hi = model.bars.length;
54
+ while (lo < hi) {
55
+ const mid = (lo + hi) >>> 1;
56
+ if (model.bars[mid].start <= time)
57
+ lo = mid + 1;
58
+ else
59
+ hi = mid;
60
+ }
61
+ const bin = model.bars[lo - 1];
62
+ return bin && time < bin.end ? bin : undefined;
63
+ }
64
+ /** Service time, including 24:00 and times beyond midnight; no timezone inference. */
65
+ export function formatTimelineTime(seconds) {
66
+ const minutes = Math.floor(Math.max(0, Number.isFinite(seconds) ? seconds : 0) / 60);
67
+ return `${String(Math.floor(minutes / 60)).padStart(2, '0')}:${String(minutes % 60).padStart(2, '0')}`;
68
+ }