@kgdiff/viewer 0.2.0 → 0.3.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.
package/src/framing.ts CHANGED
@@ -3,7 +3,8 @@
3
3
  * float over the canvas; a fit or flight must keep its target clear of them without wasting the
4
4
  * rest of the canvas. Each obstacle can be avoided through one side (an inset); all assignments
5
5
  * are tried (4ⁿ, n ≤ ~6 visible chips) and the one that shows `bounds` largest wins, ties going
6
- * to the free region closest to the canvas center. Pure; all sizes in canvas px.
6
+ * to the free region closest to the canvas center. `zoomFit` fits content whose world extent
7
+ * depends on the zoom (labels). Pure; all sizes in canvas px.
7
8
  */
8
9
  import type { Rect } from '@kgdiff/core';
9
10
 
@@ -139,6 +140,69 @@ export function frameTarget(
139
140
  return { x: cx - (sx - width / 2) / k, y: cy - (sy - height / 2) / k, k };
140
141
  }
141
142
 
143
+ /** Lowest zoom a fit considers (the renderer's default `minZoom`). */
144
+ const MIN_FIT_ZOOM = 0.05;
145
+ /** Bisection steps for the zoom of a label-aware fit (a fraction of a percent). */
146
+ const FIT_STEPS = 16;
147
+
148
+ /** A fit whose content depends on the zoom it reaches. */
149
+ export interface ZoomFit {
150
+ /** The zoom the fit lands on. */
151
+ readonly k: number;
152
+ /** What it shows (world units). */
153
+ readonly content: Rect;
154
+ /** Bounds for the camera: `content` centred in a rect that a fit lands on at exactly `k`. */
155
+ readonly bounds: Rect;
156
+ }
157
+
158
+ /**
159
+ * Fit content whose world extent depends on the zoom, `extent(k)`: labels keep their pixel size
160
+ * (and change sides as they make room for each other), so the further out the camera, the more
161
+ * world they cover. Finds the largest zoom up to `maxK` at which the content fits the canvas
162
+ * minus `insets` at that very zoom, by bisection (what fits only grows on screen as the camera
163
+ * zooms in), and lands exactly there, since the labels could move at another zoom. When not
164
+ * even the smallest zoom has room, `extent(Infinity)` (the discs alone) is fitted plainly.
165
+ */
166
+ export function zoomFit(
167
+ extent: (k: number) => Rect | null,
168
+ width: number,
169
+ height: number,
170
+ insets: Insets,
171
+ maxK: number,
172
+ ): ZoomFit | null {
173
+ const fits = (k: number): boolean => {
174
+ const b = extent(k);
175
+ return b !== null && fitZoom(b, width, height, insets) >= k;
176
+ };
177
+ let k = maxK;
178
+ if (!fits(k)) {
179
+ let lo = Math.min(MIN_FIT_ZOOM, maxK);
180
+ if (!fits(lo)) {
181
+ const discs = extent(Number.POSITIVE_INFINITY);
182
+ if (!discs) return null;
183
+ return { k: fitZoom(discs, width, height, insets), content: discs, bounds: discs };
184
+ }
185
+ let hi = k;
186
+ for (let i = 0; i < FIT_STEPS; i++) {
187
+ const mid = Math.sqrt(lo * hi);
188
+ if (fits(mid)) lo = mid;
189
+ else hi = mid;
190
+ }
191
+ k = lo;
192
+ }
193
+ const content = extent(k);
194
+ if (!content) return null;
195
+ const w = (width - insets.left - insets.right) / k;
196
+ const h = (height - insets.top - insets.bottom) / k;
197
+ const bounds = {
198
+ x: content.x + content.width / 2 - w / 2,
199
+ y: content.y + content.height / 2 - h / 2,
200
+ width: w,
201
+ height: h,
202
+ };
203
+ return { k, content, bounds };
204
+ }
205
+
142
206
  /** The part of `view` (world units) not covered by `insets` at zoom `k`. */
143
207
  export function freeView(view: Rect, k: number, insets: Insets): Rect {
144
208
  return {
package/src/index.ts CHANGED
@@ -27,6 +27,10 @@ import { viewerTags } from './registry.js';
27
27
 
28
28
  export type ViewerMode = 'overlay' | 'swipe' | 'replay' | 'changes';
29
29
  export type ThemePreference = 'auto' | 'light' | 'dark';
30
+ /** `none`: the graph alone, for small embeds (see `KgdiffViewerElement.chrome`). */
31
+ export type ViewerChrome = 'full' | 'none';
32
+ /** The legend card, one line of dots, no legend, or `auto` (the card, one line without chrome). */
33
+ export type ViewerLegend = 'auto' | 'full' | 'mini' | 'none';
30
34
 
31
35
  /** A precomputed diff + layout: what the CLI embeds in HTML reports. Deterministic. */
32
36
  export interface ReportPayload {
@@ -83,6 +87,18 @@ export interface KgdiffViewerEventMap {
83
87
  readonly progress: ReviewState;
84
88
  }>;
85
89
  'kgdiff-error': CustomEvent<{ readonly error: Error }>;
90
+ /**
91
+ * Playback reached the end of the replay (not fired for seeking or stepping there). `loop`:
92
+ * it starts again after `loopDelay` ms; set `loop = false` or call `pause()` to stop it.
93
+ */
94
+ 'kgdiff-replay-end': CustomEvent<{ readonly loop: boolean }>;
95
+ }
96
+
97
+ export interface FitOptions {
98
+ /** Default true (false with reduced motion). */
99
+ readonly animate?: boolean;
100
+ /** Room kept around the graph and its labels, px. Default 24; later automatic fits keep it. */
101
+ readonly padding?: number;
86
102
  }
87
103
 
88
104
  /**
@@ -96,6 +112,9 @@ export interface KgdiffViewerEventMap {
96
112
  *
97
113
  * `header="compact"` leaves the brand and the compared names out of the header, for hosts that
98
114
  * show their own (such as the playground).
115
+ *
116
+ * A small looping replay for another page: `mode="replay" chrome="none" loop` (with a height of
117
+ * your choosing, such as 320px).
99
118
  */
100
119
  export interface KgdiffViewerElement extends HTMLElement {
101
120
  /** Reflected to the `mode` attribute. Default `overlay` (`changes` for large graphs). */
@@ -106,6 +125,32 @@ export interface KgdiffViewerElement extends HTMLElement {
106
125
  palette: PaletteName;
107
126
  /** Reflected to the `locale` attribute. Default from `navigator.language`. */
108
127
  locale: Locale;
128
+ /**
129
+ * Reflected to the `chrome` attribute. `none` shows the graph alone: no header, change list,
130
+ * step caption, playback bar, zoom buttons, minimap or legend card; the hover tooltip and a
131
+ * one-line legend stay. Clicks and keyboard shortcuts do nothing, the minimum height drops
132
+ * from 420px to 120px, and the view stays fitted: when the canvas is resized (unless the user
133
+ * panned or zoomed since) and at the start of every loop. Labels are never cut at the edge:
134
+ * one pushed from its spot by a neighbour moves inside or is left out. Default `full`.
135
+ */
136
+ chrome: ViewerChrome;
137
+ /**
138
+ * Reflected to the `legend` attribute: `full` (the card), `mini` (one line: the families this
139
+ * diff has, then unchanged), `none`. Default `auto`: `full`, or `mini` with `chrome="none"`.
140
+ */
141
+ legend: ViewerLegend;
142
+ /**
143
+ * Reflected to the boolean `loop` attribute. In `replay` mode the replay plays by itself and
144
+ * starts again `loopDelay` ms after it ends. `pause()` stops it until `play()`. Default false.
145
+ */
146
+ loop: boolean;
147
+ /** Reflected to `loop-delay`: ms between the end of a looping replay and its restart. Default 1200. */
148
+ loopDelay: number;
149
+ /**
150
+ * With `loop`, the replay pauses while the pointer is over the graph and continues when it
151
+ * leaves (a mouse or pen; not touch). Reflected as `pause-on-hover="false"` when off. Default true.
152
+ */
153
+ pauseOnHover: boolean;
109
154
  /** Set a precomputed report (no diff / layout work in the browser). */
110
155
  payload: ReportPayload | null;
111
156
  /** Parse (if needed), diff, lay out and show two graphs. Rejects with a KgdiffError on bad input. */
@@ -116,7 +161,8 @@ export interface KgdiffViewerElement extends HTMLElement {
116
161
  prev(): void;
117
162
  play(): void;
118
163
  pause(): void;
119
- fit(): Promise<void>;
164
+ /** Fit the whole graph, labels included, into view. */
165
+ fit(options?: FitOptions): Promise<void>;
120
166
  /** The full delta of the current diff. */
121
167
  exportDelta(): Delta;
122
168
  reviewState(): ReviewState;
package/src/prefs.ts CHANGED
@@ -6,11 +6,22 @@
6
6
  import type { Locale } from '@kgdiff/core';
7
7
  import type { PaletteName } from '@kgdiff/scene';
8
8
  import { normalizeLocale } from './i18n.js';
9
- import type { ReportPayload, ThemePreference, ViewerMode } from './index.js';
9
+ import type {
10
+ ReportPayload,
11
+ ThemePreference,
12
+ ViewerChrome,
13
+ ViewerLegend,
14
+ ViewerMode,
15
+ } from './index.js';
10
16
 
11
17
  const MODES: readonly ViewerMode[] = ['overlay', 'swipe', 'replay', 'changes'];
12
18
  const THEMES: readonly ThemePreference[] = ['auto', 'light', 'dark'];
13
19
  const PALETTES: readonly PaletteName[] = ['default', 'colorblind'];
20
+ const CHROMES: readonly ViewerChrome[] = ['full', 'none'];
21
+ const LEGENDS: readonly ViewerLegend[] = ['auto', 'full', 'mini', 'none'];
22
+
23
+ /** Pause between the end of a looping replay and its next start, ms. */
24
+ export const DEFAULT_LOOP_DELAY = 1200;
14
25
 
15
26
  export const THEME_PREFERENCES = THEMES;
16
27
 
@@ -30,6 +41,20 @@ export function toLocale(value: unknown): Locale | null {
30
41
  return typeof value === 'string' ? normalizeLocale(value) : null;
31
42
  }
32
43
 
44
+ export function toChrome(value: unknown): ViewerChrome | null {
45
+ return CHROMES.includes(value as ViewerChrome) ? (value as ViewerChrome) : null;
46
+ }
47
+
48
+ export function toLegend(value: unknown): ViewerLegend | null {
49
+ return LEGENDS.includes(value as ViewerLegend) ? (value as ViewerLegend) : null;
50
+ }
51
+
52
+ /** A delay in ms (number or numeric string), at least 0. */
53
+ export function toDelay(value: unknown): number | null {
54
+ const n = typeof value === 'string' && value.trim() !== '' ? Number(value) : value;
55
+ return typeof n === 'number' && Number.isFinite(n) && n >= 0 ? n : null;
56
+ }
57
+
33
58
  type View = NonNullable<ReportPayload['view']>;
34
59
 
35
60
  /** Keep only the valid fields of a report's `view` (warning about the rest). */
package/src/styles.ts CHANGED
@@ -90,6 +90,8 @@ export const STYLES = /* css */ `
90
90
  -webkit-font-smoothing: antialiased;
91
91
  }
92
92
  :host([hidden]) { display: none; }
93
+ /* chrome="none" shows the graph alone: small embeds (a 320 px strip) are fine. */
94
+ :host([chrome="none"]) { min-height: 120px; }
93
95
  *, *::before, *::after { box-sizing: border-box; }
94
96
  [hidden] { display: none !important; }
95
97
  button, input { font: inherit; color: inherit; letter-spacing: inherit; }
@@ -237,6 +239,15 @@ ${familyRules}
237
239
  .legend-row { display: grid; grid-template-columns: 22px auto; align-items: center; column-gap: 8px; height: 26px; padding: 0 10px 0 4px; border-radius: 6px; cursor: default; }
238
240
  .legend-row .name { font-size: 12px; font-weight: 550; color: var(--kg-text); }
239
241
  .swatch { width: 22px; height: 22px; display: block; }
242
+ .mini-legend { display: none; }
243
+ .stage-wrap[data-legend="mini"] .mini-legend {
244
+ display: flex; flex-wrap: wrap; align-items: center; gap: 2px 12px; bottom: 8px; left: 12px; max-width: calc(100% - 24px);
245
+ font-size: 11px; line-height: 16px; color: var(--kg-text-2);
246
+ }
247
+ .mini-item { display: inline-flex; align-items: center; gap: 5px; white-space: nowrap; }
248
+ .mini-item .dot { width: 8px; height: 8px; border-radius: 50%; flex: none; background: var(--fam-base); }
249
+ .mini-item[data-family="removed"] .dot { background: none; box-shadow: inset 0 0 0 1.5px var(--fam-base); }
250
+ .stage-wrap[data-legend="mini"] .legend, .stage-wrap[data-legend="none"] .legend { display: none; }
240
251
 
241
252
  .caption {
242
253
  position: absolute; top: 12px; left: 50%; transform: translateX(-50%); max-width: min(560px, calc(100% - 200px));
@@ -595,6 +606,12 @@ ins.hl { text-decoration: none; background: var(--kg-added-soft); color: var(--k
595
606
  .key-row { display: flex; align-items: center; justify-content: space-between; gap: 12px; min-height: 34px; border-bottom: 1px solid var(--kg-border); font-size: 12.5px; color: var(--kg-text-2); }
596
607
  .key-row .ks { display: inline-flex; align-items: center; gap: 3px; flex: none; color: var(--kg-text-3); font-size: 11px; }
597
608
 
609
+ /* ── chrome="none": the graph, its hover tooltip and the legend line only ──── */
610
+ .root[data-chrome="none"] .header, .root[data-chrome="none"] .sidebar, .root[data-chrome="none"] .caption,
611
+ .root[data-chrome="none"] .transport, .root[data-chrome="none"] .finished, .root[data-chrome="none"] .stepper,
612
+ .root[data-chrome="none"] .map-cluster, .root[data-chrome="none"] .side-chip { display: none !important; }
613
+ .stage-wrap[data-selectable="false"] .tooltip .t-hint { display: none; }
614
+
598
615
  /* ── Compact (< 760 px): bottom sheet ────────────────────────────────────── */
599
616
  .root[data-compact="true"] .body { flex-direction: column; }
600
617
  .root[data-compact="true"] .sidebar {
package/src/ui/stage.ts CHANGED
@@ -1,8 +1,9 @@
1
1
  /**
2
2
  * The canvas area and its floating chrome: replay caption and transport, swipe divider,
3
- * changes-only context stepper, legend, zoom controls + minimap, hover tooltip and the
4
- * loading / identical / error / empty states. Overlays never cover the current hunk: the
5
- * element reserves their space through the renderer's safe area.
3
+ * changes-only context stepper, legend (a card, or one line of dots for chrome-less embeds),
4
+ * zoom controls + minimap, hover tooltip and the loading / identical / error / empty states.
5
+ * Overlays never cover the current hunk: the element reserves their space through the
6
+ * renderer's safe area.
6
7
  */
7
8
  import { KgdiffError, type Rect } from '@kgdiff/core';
8
9
  import type { ChangeFamily, Family } from '@kgdiff/scene';
@@ -24,6 +25,9 @@ import { sceneHunkGlyph, type UiContext } from './context.js';
24
25
 
25
26
  const TICK_W = 1000;
26
27
 
28
+ /** How the legend shows: the card, one line of dots, or not at all. */
29
+ export type LegendStyle = 'full' | 'mini' | 'none';
30
+
27
31
  /** Legend swatch: the node cue of each family (docs/visual-language.md §Meaning families). */
28
32
  function swatch(family: Family): SVGSVGElement {
29
33
  const v = (name: string) => `var(--kg-${name})`;
@@ -84,6 +88,7 @@ export class Stage {
84
88
  readonly #legend: HTMLElement;
85
89
  readonly #legendHead: HTMLButtonElement;
86
90
  readonly #legendBody: HTMLElement;
91
+ readonly #miniLegend: HTMLElement;
87
92
  readonly #zoomIn: HTMLButtonElement;
88
93
  readonly #zoomOut: HTMLButtonElement;
89
94
  readonly #fitButton: HTMLButtonElement;
@@ -236,6 +241,7 @@ export class Stage {
236
241
  this.#legendHead,
237
242
  this.#legendBody,
238
243
  );
244
+ this.#miniLegend = h('div', { class: 'mini-legend float bottom-left', role: 'list' });
239
245
 
240
246
  // Zoom + minimap
241
247
  this.#zoomIn = iconButton('plus', '', () => c.zoom(1));
@@ -264,6 +270,7 @@ export class Stage {
264
270
  this.#caption,
265
271
  this.#stepper,
266
272
  this.#legend,
273
+ this.#miniLegend,
267
274
  this.#mapCluster,
268
275
  this.#finished,
269
276
  this.#transport,
@@ -297,6 +304,7 @@ export class Stage {
297
304
  const obstacles: Rect[] = [];
298
305
  const parts = [
299
306
  this.#legend,
307
+ this.#miniLegend,
300
308
  this.#mapCluster,
301
309
  this.#transport,
302
310
  this.#finished,
@@ -326,12 +334,19 @@ export class Stage {
326
334
  attr(this.#legendHead, 'aria-expanded', String(open));
327
335
  }
328
336
 
337
+ /** Legend style, and whether clicks select (the tooltip only hints at clicking then). */
338
+ setChrome(legend: LegendStyle, selectable: boolean): void {
339
+ this.el.dataset.legend = legend;
340
+ this.el.dataset.selectable = String(selectable);
341
+ }
342
+
329
343
  update(topics: ReadonlySet<Topic>): void {
330
344
  const c = this.#ctx.controller;
331
345
  const tr = this.#ctx.tr();
332
346
  const all = topics.has('status') || topics.has('data') || topics.has('locale');
333
347
  if (all) {
334
348
  this.#renderLabels(tr);
349
+ this.#renderMiniLegend(tr);
335
350
  this.#renderState(tr);
336
351
  this.#renderTicks();
337
352
  this.#renderCanvasLabel(tr);
@@ -381,6 +396,32 @@ export class Stage {
381
396
  );
382
397
  }
383
398
 
399
+ /** One line: the families this diff has, then the unchanged context. */
400
+ #renderMiniLegend(tr: Translator): void {
401
+ const scene = this.#ctx.controller.scene;
402
+ const present = new Set<Family>(scene?.hunks.map((x) => x.family));
403
+ if (scene?.nodes.some((n) => n.status === 'unchanged')) present.add('neutral');
404
+ attr(this.#miniLegend, 'aria-label', tr.t('legend.title'));
405
+ replace(
406
+ this.#miniLegend,
407
+ [...FAMILIES, 'neutral' as const]
408
+ .filter((f) => present.has(f))
409
+ .map((f) =>
410
+ h(
411
+ 'span',
412
+ {
413
+ class: 'mini-item',
414
+ role: 'listitem',
415
+ 'data-family': f,
416
+ title: tr.t(`familyHint.${f}`),
417
+ },
418
+ h('span', { class: 'dot', 'aria-hidden': 'true' }),
419
+ tr.t(`family.${f}`),
420
+ ),
421
+ ),
422
+ );
423
+ }
424
+
384
425
  #renderCanvasLabel(tr: Translator): void {
385
426
  const stats = this.#ctx.controller.diff?.stats;
386
427
  const canvas = this.canvasHost.querySelector('canvas');