@joewinke/jatui 0.9.0 → 0.10.0

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.
@@ -0,0 +1,15 @@
1
+ export function memoSlot() {
2
+ return { deps: null, value: undefined };
3
+ }
4
+ export function memo(slot, deps, compute) {
5
+ const prev = slot.deps;
6
+ if (prev && prev.length === deps.length && prev.every((d, i) => Object.is(d, deps[i]))) {
7
+ return slot.value;
8
+ }
9
+ slot.deps = deps;
10
+ slot.value = compute();
11
+ return slot.value;
12
+ }
13
+ export function createBandLayerCache() {
14
+ return { ribbon: memoSlot(), heat: memoSlot(), clusters: memoSlot() };
15
+ }
@@ -0,0 +1,35 @@
1
+ /**
2
+ * The band's zoom window — ONE mapping every position computation goes
3
+ * through (mos-5lzns.30 moved it here from band.ts so the band/ paint layers
4
+ * — axis, luma ribbon, markers — can share it without an import cycle;
5
+ * band.ts re-exports everything, so existing import sites are unchanged).
6
+ */
7
+ export interface BandWindow {
8
+ start: number;
9
+ end: number;
10
+ }
11
+ /**
12
+ * The visible window in seconds. Falls back to the whole video until the
13
+ * duration effect has seeded `end`, so a first paint that races metadata
14
+ * still maps positions correctly instead of collapsing to zero.
15
+ */
16
+ export declare function spanSec(w: BandWindow, duration: number): number;
17
+ export declare function isZoomed(w: BandWindow, duration: number): boolean;
18
+ export declare function resetWindow(duration: number): BandWindow;
19
+ export declare function secToFrac(w: BandWindow, duration: number, sec: number): number;
20
+ export declare function fracToSec(w: BandWindow, duration: number, frac: number): number;
21
+ /**
22
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
23
+ * under the pointer stays fixed while the window resizes around it, so
24
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
25
+ * at. `deltaY < 0` zooms in.
26
+ */
27
+ export declare const ZOOM_FACTOR = 1.3;
28
+ export declare const MIN_WINDOW_SEC = 3;
29
+ export declare function zoomAt(w: BandWindow, duration: number, cursorSec: number, deltaY: number): BandWindow;
30
+ /**
31
+ * Zoomed in and playback walked past the edge of the window — follow it, or
32
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
33
+ * null when no follow is needed.
34
+ */
35
+ export declare function followPlayhead(w: BandWindow, duration: number, t: number): BandWindow | null;
@@ -0,0 +1,61 @@
1
+ /**
2
+ * The band's zoom window — ONE mapping every position computation goes
3
+ * through (mos-5lzns.30 moved it here from band.ts so the band/ paint layers
4
+ * — axis, luma ribbon, markers — can share it without an import cycle;
5
+ * band.ts re-exports everything, so existing import sites are unchanged).
6
+ */
7
+ /**
8
+ * The visible window in seconds. Falls back to the whole video until the
9
+ * duration effect has seeded `end`, so a first paint that races metadata
10
+ * still maps positions correctly instead of collapsing to zero.
11
+ */
12
+ export function spanSec(w, duration) {
13
+ return w.end > w.start ? w.end - w.start : duration;
14
+ }
15
+ export function isZoomed(w, duration) {
16
+ return w.end > 0 && spanSec(w, duration) < duration - 0.01;
17
+ }
18
+ export function resetWindow(duration) {
19
+ return { start: 0, end: duration };
20
+ }
21
+ export function secToFrac(w, duration, sec) {
22
+ const span = spanSec(w, duration);
23
+ return span > 0 ? (sec - w.start) / span : 0;
24
+ }
25
+ export function fracToSec(w, duration, frac) {
26
+ return w.start + frac * spanSec(w, duration);
27
+ }
28
+ /**
29
+ * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
30
+ * under the pointer stays fixed while the window resizes around it, so
31
+ * scrolling repeatedly walks you inward on exactly the spot you're pointing
32
+ * at. `deltaY < 0` zooms in.
33
+ */
34
+ export const ZOOM_FACTOR = 1.3;
35
+ export const MIN_WINDOW_SEC = 3;
36
+ export function zoomAt(w, duration, cursorSec, deltaY) {
37
+ if (!duration)
38
+ return w;
39
+ if (w.end <= 0)
40
+ w = resetWindow(duration);
41
+ const span = spanSec(w, duration);
42
+ let newSpan = deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
43
+ newSpan = Math.min(duration, Math.max(MIN_WINDOW_SEC, newSpan));
44
+ const frac = span > 0 ? (cursorSec - w.start) / span : 0.5;
45
+ const start = Math.max(0, Math.min(cursorSec - frac * newSpan, duration - newSpan));
46
+ return { start, end: start + newSpan };
47
+ }
48
+ /**
49
+ * Zoomed in and playback walked past the edge of the window — follow it, or
50
+ * you'd watch a frozen slice of waveform while the video plays on. Returns
51
+ * null when no follow is needed.
52
+ */
53
+ export function followPlayhead(w, duration, t) {
54
+ if (!isZoomed(w, duration))
55
+ return null;
56
+ if (t >= w.start && t <= w.end)
57
+ return null;
58
+ const span = spanSec(w, duration);
59
+ const start = Math.max(0, Math.min(t - span / 2, Math.max(0, duration - span)));
60
+ return { start, end: start + span };
61
+ }
@@ -21,37 +21,13 @@
21
21
  * theme colours and passes them in, so theme changes are a repaint, not a
22
22
  * re-render of this module.
23
23
  */
24
- import { ALL_KEY } from './model.ts';
25
- import type { VideoBookmark, VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from './types.ts';
26
- export interface BandWindow {
27
- start: number;
28
- end: number;
29
- }
30
- /**
31
- * The visible window in seconds. Falls back to the whole video until the
32
- * duration effect has seeded `end`, so a first paint that races metadata
33
- * still maps positions correctly instead of collapsing to zero.
34
- */
35
- export declare function spanSec(w: BandWindow, duration: number): number;
36
- export declare function isZoomed(w: BandWindow, duration: number): boolean;
37
- export declare function resetWindow(duration: number): BandWindow;
38
- export declare function secToFrac(w: BandWindow, duration: number, sec: number): number;
39
- export declare function fracToSec(w: BandWindow, duration: number, frac: number): number;
40
- /**
41
- * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
42
- * under the pointer stays fixed while the window resizes around it, so
43
- * scrolling repeatedly walks you inward on exactly the spot you're pointing
44
- * at. `deltaY < 0` zooms in.
45
- */
46
- export declare const ZOOM_FACTOR = 1.3;
47
- export declare const MIN_WINDOW_SEC = 3;
48
- export declare function zoomAt(w: BandWindow, duration: number, cursorSec: number, deltaY: number): BandWindow;
49
- /**
50
- * Zoomed in and playback walked past the edge of the window — follow it, or
51
- * you'd watch a frozen slice of waveform while the video plays on. Returns
52
- * null when no follow is needed.
53
- */
54
- export declare function followPlayhead(w: BandWindow, duration: number, t: number): BandWindow | null;
24
+ import { ALL_KEY } from './model';
25
+ import type { VideoBookmark, VideoChapter, VideoSpeakerTurn, VideoTranscriptSegment } from './types';
26
+ import { type BandWindow } from './band/window';
27
+ import { type LumaSeries } from './band/luma';
28
+ import { type VideoMarker } from './band/markers';
29
+ import { type BandLayerCache } from './band/memo';
30
+ export { fracToSec, followPlayhead, isZoomed, MIN_WINDOW_SEC, resetWindow, secToFrac, spanSec, ZOOM_FACTOR, zoomAt, type BandWindow } from './band/window';
55
31
  /**
56
32
  * Click-to-seek biases toward the START of the spoken line under the click —
57
33
  * nobody wants to land mid-sentence. Only when the transcript is dense right
@@ -92,13 +68,37 @@ export interface BandPaintState {
92
68
  mutedKeys: Set<string>;
93
69
  window: BandWindow;
94
70
  hoverChapter: number;
71
+ /** Normalised light track (band/luma.ts); null ⇒ no ribbon. */
72
+ luma?: LumaSeries | null;
73
+ /** Social markers, ascending (band/markers.ts). */
74
+ comments?: VideoMarker[];
75
+ likes?: VideoMarker[];
76
+ /**
77
+ * Memo slots for the ribbon / heat / cluster arrays (band/memo.ts). With
78
+ * one, those layers recompute only when (window, width, data) change —
79
+ * not on every playhead repaint. Without one, computed fresh each paint.
80
+ */
81
+ cache?: BandLayerCache;
95
82
  /** Theme colours, resolved by the component via getDaisyUIColor. */
96
83
  colors: {
97
84
  accent: string;
98
85
  neutral: string;
99
86
  marker: string;
87
+ /** The light ribbon's wash (FEATURES.md: amber). Defaults to `marker`. */
88
+ ribbon?: string;
89
+ /** Comment dots. Defaults to `accent`. */
90
+ comment?: string;
91
+ /** Like heat strip. Defaults to `accent`. */
92
+ like?: string;
93
+ /** Outline behind comment dots so they read over a dense envelope. */
94
+ surface?: string;
100
95
  };
101
96
  }
97
+ /** Like heat strip height, px, along the band's bottom edge (under the bookmark diamonds). */
98
+ export declare const HEAT_H = 3;
99
+ /** Comment dot radius and centre line (just under the chapter flags, which occupy y 0–4). */
100
+ export declare const COMMENT_R = 2.5;
101
+ export declare const COMMENT_Y = 7.5;
102
102
  /**
103
103
  * Size the canvas backing store for devicePixelRatio and return its 2d
104
104
  * context with the transform applied — or null when there is nothing honest
@@ -114,3 +114,9 @@ export declare function paintBand(ctx: CanvasRenderingContext2D, W: number, H: n
114
114
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
115
115
  export declare function toBandTurns(turns: VideoSpeakerTurn[]): BandTurn[];
116
116
  export { ALL_KEY };
117
+ /**
118
+ * Comment clusters for this window/width — memoised when a cache is given.
119
+ * The component's hit test calls this with the SAME cache as the paint, so a
120
+ * mousemove never re-clusters what the last repaint already did.
121
+ */
122
+ export declare function bandClusters(comments: VideoMarker[], w: BandWindow, duration: number, width: number, cache?: BandLayerCache): import("./band/markers").CommentCluster[];
@@ -21,62 +21,14 @@
21
21
  * theme colours and passes them in, so theme changes are a repaint, not a
22
22
  * re-render of this module.
23
23
  */
24
- import { ALL_KEY, filterDims, speakerKeyOf } from './model.ts';
25
- /**
26
- * The visible window in seconds. Falls back to the whole video until the
27
- * duration effect has seeded `end`, so a first paint that races metadata
28
- * still maps positions correctly instead of collapsing to zero.
29
- */
30
- export function spanSec(w, duration) {
31
- return w.end > w.start ? w.end - w.start : duration;
32
- }
33
- export function isZoomed(w, duration) {
34
- return w.end > 0 && spanSec(w, duration) < duration - 0.01;
35
- }
36
- export function resetWindow(duration) {
37
- return { start: 0, end: duration };
38
- }
39
- export function secToFrac(w, duration, sec) {
40
- const span = spanSec(w, duration);
41
- return span > 0 ? (sec - w.start) / span : 0;
42
- }
43
- export function fracToSec(w, duration, frac) {
44
- return w.start + frac * spanSec(w, duration);
45
- }
46
- /**
47
- * Scroll to zoom, centred on the cursor — the DAW/mpv convention: the point
48
- * under the pointer stays fixed while the window resizes around it, so
49
- * scrolling repeatedly walks you inward on exactly the spot you're pointing
50
- * at. `deltaY < 0` zooms in.
51
- */
52
- export const ZOOM_FACTOR = 1.3;
53
- export const MIN_WINDOW_SEC = 3;
54
- export function zoomAt(w, duration, cursorSec, deltaY) {
55
- if (!duration)
56
- return w;
57
- if (w.end <= 0)
58
- w = resetWindow(duration);
59
- const span = spanSec(w, duration);
60
- let newSpan = deltaY < 0 ? span / ZOOM_FACTOR : span * ZOOM_FACTOR;
61
- newSpan = Math.min(duration, Math.max(MIN_WINDOW_SEC, newSpan));
62
- const frac = span > 0 ? (cursorSec - w.start) / span : 0.5;
63
- const start = Math.max(0, Math.min(cursorSec - frac * newSpan, duration - newSpan));
64
- return { start, end: start + newSpan };
65
- }
66
- /**
67
- * Zoomed in and playback walked past the edge of the window — follow it, or
68
- * you'd watch a frozen slice of waveform while the video plays on. Returns
69
- * null when no follow is needed.
70
- */
71
- export function followPlayhead(w, duration, t) {
72
- if (!isZoomed(w, duration))
73
- return null;
74
- if (t >= w.start && t <= w.end)
75
- return null;
76
- const span = spanSec(w, duration);
77
- const start = Math.max(0, Math.min(t - span / 2, Math.max(0, duration - span)));
78
- return { start, end: start + span };
79
- }
24
+ import { ALL_KEY, filterDims, speakerKeyOf } from './model';
25
+ // ── Zoom window: lives in band/window.ts (shared with the band/ layers) ─────
26
+ import { fracToSec, secToFrac } from './band/window';
27
+ import { PLAYHEAD_W, playheadX } from './band/axis';
28
+ import { ribbonColumns } from './band/luma';
29
+ import { commentClusters, HEAT_BIN_PX, likeHeat } from './band/markers';
30
+ import { memo } from './band/memo';
31
+ export { fracToSec, followPlayhead, isZoomed, MIN_WINDOW_SEC, resetWindow, secToFrac, spanSec, ZOOM_FACTOR, zoomAt } from './band/window';
80
32
  // ── Hit tests / position helpers ────────────────────────────────────────────
81
33
  /**
82
34
  * Click-to-seek biases toward the START of the spoken line under the click —
@@ -141,6 +93,11 @@ export function clampCardX(x, width) {
141
93
  }
142
94
  // ── Canvas paint ────────────────────────────────────────────────────────────
143
95
  export const RAIL_H = 10; // no-waveform fallback: a flat rail instead of an envelope
96
+ /** Like heat strip height, px, along the band's bottom edge (under the bookmark diamonds). */
97
+ export const HEAT_H = 3;
98
+ /** Comment dot radius and centre line (just under the chapter flags, which occupy y 0–4). */
99
+ export const COMMENT_R = 2.5;
100
+ export const COMMENT_Y = 7.5;
144
101
  /**
145
102
  * Size the canvas backing store for devicePixelRatio and return its 2d
146
103
  * context with the transform applied — or null when there is nothing honest
@@ -183,6 +140,27 @@ export function paintBand(ctx, W, H, s) {
183
140
  ctx.globalAlpha = 1;
184
141
  return;
185
142
  }
143
+ // The light ribbon: a wash under the envelope, through the same window as
144
+ // the peaks so it zooms with everything else. Painted FIRST so every
145
+ // other layer sits on top of it — it is a property of the timeline, not
146
+ // a fourth bar (FEATURES.md §The light track).
147
+ if (s.luma) {
148
+ const RIBBON_COL_PX = 2;
149
+ const cols = Math.max(1, Math.ceil(W / RIBBON_COL_PX));
150
+ const luma = s.luma;
151
+ const compute = () => ribbonColumns(luma, s.window, dur, cols);
152
+ const alphas = s.cache
153
+ ? memo(s.cache.ribbon, [luma, s.window.start, s.window.end, dur, cols], compute)
154
+ : compute();
155
+ ctx.fillStyle = s.colors.ribbon ?? marker;
156
+ for (let i = 0; i < alphas.length; i++) {
157
+ if (alphas[i] <= 0.004)
158
+ continue;
159
+ ctx.globalAlpha = alphas[i];
160
+ ctx.fillRect((i * W) / cols, 0, W / cols + 0.5, H);
161
+ }
162
+ ctx.globalAlpha = 1;
163
+ }
186
164
  const p = s.peaks;
187
165
  const withWave = !!p;
188
166
  const n = p?.length ?? 0;
@@ -277,6 +255,38 @@ export function paintBand(ctx, W, H, s) {
277
255
  ctx.closePath();
278
256
  ctx.fill();
279
257
  }
258
+ // Likes: one heat strip along the bottom edge, never individual glyphs.
259
+ if (s.likes && s.likes.length) {
260
+ const bins = Math.max(1, Math.ceil(W / HEAT_BIN_PX));
261
+ const likes = s.likes;
262
+ const compute = () => likeHeat(likes, w, dur, bins);
263
+ const heat = s.cache ? memo(s.cache.heat, [likes, w.start, w.end, dur, bins], compute) : compute();
264
+ ctx.fillStyle = s.colors.like ?? accent;
265
+ for (let i = 0; i < heat.length; i++) {
266
+ if (heat[i] <= 0)
267
+ continue;
268
+ ctx.globalAlpha = 0.15 + 0.8 * heat[i];
269
+ ctx.fillRect((i * W) / bins, H - HEAT_H, W / bins + 0.5, HEAT_H);
270
+ }
271
+ ctx.globalAlpha = 1;
272
+ }
273
+ // Comments: round dots just under the chapter flags, merged when close.
274
+ if (s.comments && s.comments.length) {
275
+ const surface = s.colors.surface;
276
+ for (const c of bandClusters(s.comments, w, dur, W, s.cache)) {
277
+ ctx.beginPath();
278
+ ctx.arc(c.x, COMMENT_Y, COMMENT_R + (c.markers.length > 1 ? 0.75 : 0), 0, Math.PI * 2);
279
+ if (surface) {
280
+ ctx.globalAlpha = 0.9;
281
+ ctx.lineWidth = 1.5;
282
+ ctx.strokeStyle = surface;
283
+ ctx.stroke();
284
+ }
285
+ ctx.globalAlpha = 1;
286
+ ctx.fillStyle = s.colors.comment ?? accent;
287
+ ctx.fill();
288
+ }
289
+ }
280
290
  // Bookmarks: diamonds pointing up from the BOTTOM edge — the mirror image
281
291
  // of the chapter flags on top, so yours and the model's never blur together.
282
292
  ctx.fillStyle = marker;
@@ -295,7 +305,9 @@ export function paintBand(ctx, W, H, s) {
295
305
  // Playhead
296
306
  ctx.globalAlpha = 0.9;
297
307
  ctx.fillStyle = neutral;
298
- ctx.fillRect(Math.max(0, Math.min(1, secToFrac(w, dur, s.currentTime))) * W - 0.5, 0, 1.5, H);
308
+ // Centred on playheadX — the one function every "where you are" widget
309
+ // uses (band/axis.ts), so the overview rail and the band agree to ≤0.2px.
310
+ ctx.fillRect(playheadX(s.currentTime, w, dur, W) - PLAYHEAD_W / 2, 0, PLAYHEAD_W, H);
299
311
  ctx.globalAlpha = 1;
300
312
  }
301
313
  /** Turn → BandTurn for the paint state (resolved key, ad flag). */
@@ -309,3 +321,12 @@ export function toBandTurns(turns) {
309
321
  }
310
322
  // Re-exports so the component (and sibling modules) have one band import site.
311
323
  export { ALL_KEY };
324
+ /**
325
+ * Comment clusters for this window/width — memoised when a cache is given.
326
+ * The component's hit test calls this with the SAME cache as the paint, so a
327
+ * mousemove never re-clusters what the last repaint already did.
328
+ */
329
+ export function bandClusters(comments, w, duration, width, cache) {
330
+ const compute = () => commentClusters(comments, w, duration, width);
331
+ return cache ? memo(cache.clusters, [comments, w.start, w.end, duration, width], compute) : compute();
332
+ }
@@ -20,7 +20,7 @@ export interface PlayerKeyRow {
20
20
  * Availability gate: the row is only advertised (and the key only bound)
21
21
  * when the feature's data exists. Undefined ⇒ always active.
22
22
  */
23
- when?: 'chapters' | 'bookmarks' | 'transcript';
23
+ when?: 'chapters' | 'bookmarks' | 'transcript' | 'comments';
24
24
  }
25
25
  /** Order follows the help overlay: transport, seek, volume, speed, structure. */
26
26
  export declare const PLAYER_KEYS: PlayerKeyRow[];
@@ -23,6 +23,9 @@ export const PLAYER_KEYS = [
23
23
  { keys: 'Backspace', description: 'Reset speed to 1×' },
24
24
  { keys: '< / >', description: 'Frame step (pauses; ~1/30s per press)' },
25
25
  { keys: '[ / ]', description: 'Previous / next chapter', when: 'chapters' },
26
+ // Shift+[ / Shift+] — the chapter keys' shifted twins, so "structure"
27
+ // and "people's reactions to it" sit under one hand (mos-5lzns.30).
28
+ { keys: '{ / }', description: 'Previous / next comment', when: 'comments' },
26
29
  { keys: 'B', description: 'Bookmark at the current time', when: 'bookmarks' },
27
30
  { keys: 'T', description: 'Show / hide the transcript', when: 'transcript' },
28
31
  { keys: 'Home / End', description: 'Jump to start / end' },
@@ -6,7 +6,7 @@
6
6
  * the identity→colour map, and talk-time totals. The component binds these
7
7
  * with `$derived`; the band paint reads the results.
8
8
  */
9
- import type { VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment } from './types.ts';
9
+ import type { VideoSpeakerTalkTime, VideoSpeakerTurn, VideoTranscriptSegment } from './types';
10
10
  export declare const ALL_KEY = "__all__";
11
11
  export declare const UNKNOWN_KEY = "__unknown__";
12
12
  /**
@@ -30,11 +30,12 @@
30
30
  * the honest state while every behaviour is prop-driven.
31
31
  */
32
32
  export const VIDEO_SETTINGS = [
33
- // { id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30' },
34
- // { id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31' },
35
- // { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
36
- // { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto','on','off'], owner: 'mos-5lzns.33' },
37
- // { id: 'sleep.enabled', type: 'bool', default: true, owner: 'mos-5lzns.34' },
33
+ // Read by VideoPlayer's `oneAxis` prop → video/band/axis.ts resolveAxis().
34
+ { id: 'player.oneAxis', type: 'bool', default: true, owner: 'mos-5lzns.30', wired: true },
35
+ // { id: 'audio.level', type: 'bool', default: true, owner: 'mos-5lzns.31' },
36
+ // { id: 'player.speakerRate', type: 'bool', default: false, owner: 'mos-5lzns.32' },
37
+ // { id: 'night.mode', type: 'enum', default: 'auto', options: ['auto','on','off'], owner: 'mos-5lzns.33' },
38
+ // { id: 'sleep.enabled', type: 'bool', default: true, owner: 'mos-5lzns.34' },
38
39
  ];
39
40
  /** Coerce a raw stored value against a spec — never throws, never returns junk. */
40
41
  export function coerce(spec, raw) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@joewinke/jatui",
3
- "version": "0.9.0",
3
+ "version": "0.10.0",
4
4
  "private": false,
5
5
  "description": "Shared Svelte 5 component library for JAT projects",
6
6
  "type": "module",
@@ -76,7 +76,7 @@
76
76
  "check:demos": "node scripts/check-demo-coverage.mjs",
77
77
  "guard": "node scripts/check-demo-coverage.mjs",
78
78
  "test:dist": "svelte-kit sync && vitest run --config vitest.dist.config.ts",
79
- "test:unit": "node --test tests/unit/",
79
+ "test:unit": "node --import ./tests/unit/register.mjs --test tests/unit/",
80
80
  "prepublishOnly": "node scripts/guard-ci-publish.mjs && npm run package"
81
81
  },
82
82
  "peerDependencies": {