@kgdiff/viewer 0.2.0 → 0.4.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.
Files changed (78) hide show
  1. package/README.md +48 -6
  2. package/dist/controller.d.ts +63 -14
  3. package/dist/controller.d.ts.map +1 -1
  4. package/dist/controller.js +308 -54
  5. package/dist/controller.js.map +1 -1
  6. package/dist/element.d.ts +17 -4
  7. package/dist/element.d.ts.map +1 -1
  8. package/dist/element.js +199 -26
  9. package/dist/element.js.map +1 -1
  10. package/dist/framing.d.ts +20 -1
  11. package/dist/framing.d.ts.map +1 -1
  12. package/dist/framing.js +49 -0
  13. package/dist/framing.js.map +1 -1
  14. package/dist/hash-sync.d.ts +4 -2
  15. package/dist/hash-sync.d.ts.map +1 -1
  16. package/dist/hash-sync.js +21 -5
  17. package/dist/hash-sync.js.map +1 -1
  18. package/dist/i18n.d.ts +7 -7
  19. package/dist/i18n.d.ts.map +1 -1
  20. package/dist/i18n.js +14 -14
  21. package/dist/i18n.js.map +1 -1
  22. package/dist/index.d.ts +66 -6
  23. package/dist/index.d.ts.map +1 -1
  24. package/dist/index.js.map +1 -1
  25. package/dist/keyboard.d.ts +6 -4
  26. package/dist/keyboard.d.ts.map +1 -1
  27. package/dist/keyboard.js +6 -5
  28. package/dist/keyboard.js.map +1 -1
  29. package/dist/prefs.d.ts +13 -1
  30. package/dist/prefs.d.ts.map +1 -1
  31. package/dist/prefs.js +30 -3
  32. package/dist/prefs.js.map +1 -1
  33. package/dist/standalone/kgdiff-viewer.js +4 -4
  34. package/dist/styles.d.ts.map +1 -1
  35. package/dist/styles.js +84 -61
  36. package/dist/styles.js.map +1 -1
  37. package/dist/ui/context.d.ts +2 -0
  38. package/dist/ui/context.d.ts.map +1 -1
  39. package/dist/ui/context.js.map +1 -1
  40. package/dist/ui/dock.d.ts +17 -0
  41. package/dist/ui/dock.d.ts.map +1 -0
  42. package/dist/ui/dock.js +151 -0
  43. package/dist/ui/dock.js.map +1 -0
  44. package/dist/ui/header.d.ts +7 -2
  45. package/dist/ui/header.d.ts.map +1 -1
  46. package/dist/ui/header.js +24 -133
  47. package/dist/ui/header.js.map +1 -1
  48. package/dist/ui/popovers.d.ts.map +1 -1
  49. package/dist/ui/popovers.js +3 -2
  50. package/dist/ui/popovers.js.map +1 -1
  51. package/dist/ui/sidebar.d.ts.map +1 -1
  52. package/dist/ui/sidebar.js +2 -1
  53. package/dist/ui/sidebar.js.map +1 -1
  54. package/dist/ui/stage.d.ts +8 -0
  55. package/dist/ui/stage.d.ts.map +1 -1
  56. package/dist/ui/stage.js +66 -93
  57. package/dist/ui/stage.js.map +1 -1
  58. package/dist/ui/swatch.d.ts +7 -0
  59. package/dist/ui/swatch.d.ts.map +1 -0
  60. package/dist/ui/swatch.js +26 -0
  61. package/dist/ui/swatch.js.map +1 -0
  62. package/package.json +6 -6
  63. package/src/controller.ts +332 -56
  64. package/src/element.ts +220 -29
  65. package/src/framing.ts +65 -1
  66. package/src/hash-sync.ts +20 -6
  67. package/src/i18n.ts +15 -14
  68. package/src/index.ts +65 -7
  69. package/src/keyboard.ts +12 -6
  70. package/src/prefs.ts +45 -5
  71. package/src/styles.ts +84 -61
  72. package/src/ui/context.ts +2 -0
  73. package/src/ui/dock.ts +198 -0
  74. package/src/ui/header.ts +38 -168
  75. package/src/ui/popovers.ts +3 -2
  76. package/src/ui/sidebar.ts +2 -1
  77. package/src/ui/stage.ts +88 -112
  78. package/src/ui/swatch.ts +31 -0
package/src/element.ts CHANGED
@@ -9,16 +9,32 @@ import { h } from './dom.js';
9
9
  import { formatHash, parseHash } from './hash-sync.js';
10
10
  import { detectLocale, translate, translator } from './i18n.js';
11
11
  import type {
12
+ FitOptions,
12
13
  KgdiffViewerElement,
13
14
  LoadOptions,
14
15
  ReportPayload,
15
16
  ReviewState,
16
17
  ThemePreference,
18
+ ViewerChrome,
19
+ ViewerLegend,
17
20
  ViewerMode,
21
+ ViewerScope,
18
22
  } from './index.js';
19
23
  import { isTypingTarget, keyAction } from './keyboard.js';
20
24
  import { assertPayload } from './payload.js';
21
- import { THEME_PREFERENCES, toLocale, toMode, toPalette, toTheme } from './prefs.js';
25
+ import {
26
+ DEFAULT_LOOP_DELAY,
27
+ LEGACY_CHANGES_MODE,
28
+ THEME_PREFERENCES,
29
+ toChrome,
30
+ toDelay,
31
+ toLegend,
32
+ toLocale,
33
+ toMode,
34
+ toPalette,
35
+ toScope,
36
+ toTheme,
37
+ } from './prefs.js';
22
38
  import { defaultStorage } from './review-store.js';
23
39
  import { STYLES, themeVariables } from './styles.js';
24
40
  import type { UiContext } from './ui/context.js';
@@ -27,13 +43,38 @@ import { HelpDialog, Menu } from './ui/popovers.js';
27
43
  import { Sidebar } from './ui/sidebar.js';
28
44
  import { Stage } from './ui/stage.js';
29
45
 
30
- /** Below this width the sidebar becomes a bottom sheet and the mode control shows icons. */
46
+ /** Below this width the sidebar becomes a bottom sheet. */
31
47
  export const COMPACT_WIDTH = 760;
32
- /** Below this width the header drops the diffstat pills and version tags. */
48
+ /** Below this width the title drops the version tags. */
33
49
  const NARROW_WIDTH = 1100;
34
50
 
35
- const OBSERVED = ['mode', 'theme', 'palette', 'locale', 'sidebar', 'sync-hash'] as const;
36
- const UPGRADED = ['mode', 'theme', 'palette', 'locale', 'payload'] as const;
51
+ const OBSERVED = [
52
+ 'mode',
53
+ 'scope',
54
+ 'theme',
55
+ 'palette',
56
+ 'locale',
57
+ 'sidebar',
58
+ 'sync-hash',
59
+ 'chrome',
60
+ 'legend',
61
+ 'loop',
62
+ 'loop-delay',
63
+ 'pause-on-hover',
64
+ ] as const;
65
+ const UPGRADED = [
66
+ 'mode',
67
+ 'scope',
68
+ 'theme',
69
+ 'palette',
70
+ 'locale',
71
+ 'chrome',
72
+ 'legend',
73
+ 'loop',
74
+ 'loopDelay',
75
+ 'pauseOnHover',
76
+ 'payload',
77
+ ] as const;
37
78
 
38
79
  /** Keys the page itself scrolls with: never taken at document level. */
39
80
  const PAGE_KEYS: ReadonlySet<string> = new Set([
@@ -110,6 +151,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
110
151
  readonly #cleanup: (() => void)[] = [];
111
152
  #lastHash = '';
112
153
  #upgraded = false;
154
+ /** A mouse or pen rests on the graph (holds a looping replay with `pause-on-hover`). */
155
+ #pointerOver = false;
113
156
 
114
157
  constructor() {
115
158
  super();
@@ -120,14 +163,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
120
163
  this.#emit('kgdiff-select', { hunk, node });
121
164
  this.#writeHash();
122
165
  },
123
- mode: (mode) => {
124
- this.#emit('kgdiff-mode', { mode });
166
+ mode: (mode, scope) => {
167
+ this.#emit('kgdiff-mode', { mode, scope });
125
168
  this.#writeHash();
126
169
  },
127
170
  review: (hunk, reviewed, progress) =>
128
171
  this.#emit('kgdiff-review', { hunk, reviewed, progress }),
129
172
  error: (error) => this.#emit('kgdiff-error', { error }),
130
173
  announce: (text) => this.#announce(text),
174
+ replayEnd: (loop) => this.#emit('kgdiff-replay-end', { loop }),
131
175
  };
132
176
  this.controller = new ViewerController(host, {
133
177
  now: () => (typeof performance !== 'undefined' ? performance.now() : Date.now()),
@@ -165,8 +209,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
165
209
  // The graph takes keyboard focus (shortcuts apply where focus is), also on click.
166
210
  const canvas = ui.stage.canvasHost.querySelector('canvas');
167
211
  if (canvas) {
168
- canvas.tabIndex = 0;
169
- const focusCanvas = (): void => canvas.focus({ preventScroll: true });
212
+ canvas.tabIndex = this.chrome === 'none' ? -1 : 0;
213
+ const focusCanvas = (): void => {
214
+ if (this.chrome !== 'none') canvas.focus({ preventScroll: true });
215
+ };
170
216
  canvas.addEventListener('pointerdown', focusCanvas);
171
217
  this.#cleanup.push(() => canvas.removeEventListener('pointerdown', focusCanvas));
172
218
  }
@@ -182,7 +228,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
182
228
  window.addEventListener('hashchange', onHash);
183
229
  this.#cleanup.push(() => window.removeEventListener('hashchange', onHash));
184
230
  this.controller.setChrome(() => ui.stage.chromeLayout());
185
- document.fonts?.ready?.then(() => this.#ui?.header.layout()).catch(() => undefined);
231
+ document.fonts?.ready?.then(() => this.#ui?.stage.layout()).catch(() => undefined);
186
232
  this.#readHash();
187
233
  this.#render(
188
234
  new Set<Topic>([
@@ -197,6 +243,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
197
243
  'focus',
198
244
  'swipe',
199
245
  'replay',
246
+ 'overview',
200
247
  ]),
201
248
  );
202
249
  }
@@ -206,12 +253,16 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
206
253
  if (i >= 0) connected.splice(i, 1);
207
254
  for (const fn of this.#cleanup.splice(0)) fn();
208
255
  this.controller.detach();
256
+ // Moved while hovered: no pointerleave follows.
257
+ this.#pointerOver = false;
258
+ this.#applyHold();
209
259
  }
210
260
 
211
261
  attributeChangedCallback(name: string, _old: string | null, value: string | null): void {
212
262
  if (this.#reflecting) return;
213
263
  switch (name) {
214
264
  case 'mode':
265
+ case 'scope':
215
266
  case 'theme':
216
267
  case 'palette':
217
268
  case 'locale':
@@ -223,6 +274,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
223
274
  case 'sync-hash':
224
275
  if (value !== null) this.#readHash();
225
276
  break;
277
+ case 'chrome':
278
+ case 'legend':
279
+ this.#applyChrome();
280
+ break;
281
+ case 'loop':
282
+ case 'loop-delay':
283
+ this.controller.setLoop(this.loop ? this.loopDelay : null);
284
+ this.#applyHold();
285
+ break;
286
+ case 'pause-on-hover':
287
+ this.#applyHold();
288
+ break;
226
289
  default:
227
290
  break;
228
291
  }
@@ -230,23 +293,39 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
230
293
 
231
294
  /**
232
295
  * Apply a preference from an attribute or a property. Valid values are explicit (a report's
233
- * `view` does not override them); invalid ones fall back to the default (`overlay`, `auto`,
234
- * `default`, the browser's language). Properties reflect the normalized value.
296
+ * `view` does not override them); invalid ones fall back to the default (`overlay`, `all`,
297
+ * `auto`, `default`, the browser's language). Properties reflect the normalized value; the
298
+ * legacy mode `changes` reflects as `overlay` + scope `changes`.
235
299
  */
236
300
  #applyPreference(
237
- name: 'mode' | 'theme' | 'palette' | 'locale',
301
+ name: 'mode' | 'scope' | 'theme' | 'palette' | 'locale',
238
302
  raw: unknown,
239
303
  reflect: boolean,
240
304
  ): void {
241
305
  const c = this.controller;
306
+ // `mode="changes"` is rewritten to the attribute pair it stands for.
307
+ const legacy = name === 'mode' && raw === LEGACY_CHANGES_MODE;
242
308
  let value: string | null;
243
309
  switch (name) {
244
310
  case 'mode': {
311
+ if (legacy) {
312
+ c.explicit.add('scope');
313
+ c.setView('overlay', 'changes');
314
+ value = 'overlay';
315
+ break;
316
+ }
245
317
  const v = toMode(raw);
246
318
  c.setMode(v ?? 'overlay');
247
319
  value = v;
248
320
  break;
249
321
  }
322
+ case 'scope': {
323
+ const v = toScope(raw);
324
+ c.setScope(v ?? 'all');
325
+ // A locked scope stays `changes`: the attribute says what is shown.
326
+ value = v === null ? null : c.scope;
327
+ break;
328
+ }
250
329
  case 'theme': {
251
330
  const v = toTheme(raw);
252
331
  c.setThemePreference(v ?? 'auto');
@@ -270,7 +349,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
270
349
  }
271
350
  if (value === null) c.explicit.delete(name);
272
351
  else c.explicit.add(name);
273
- if (reflect) this.#reflect(name, value);
352
+ if (reflect || legacy) this.#reflect(name, value);
353
+ if (legacy) this.#reflect('scope', c.scope);
274
354
  }
275
355
 
276
356
  // ─── Properties ───────────────────────────────────────────────────────────
@@ -279,10 +359,18 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
279
359
  return this.controller.mode;
280
360
  }
281
361
 
282
- set mode(value: ViewerMode) {
362
+ set mode(value: ViewerMode | typeof LEGACY_CHANGES_MODE) {
283
363
  this.#applyPreference('mode', value, true);
284
364
  }
285
365
 
366
+ get scope(): ViewerScope {
367
+ return this.controller.scope;
368
+ }
369
+
370
+ set scope(value: ViewerScope) {
371
+ this.#applyPreference('scope', value, true);
372
+ }
373
+
286
374
  get theme(): ThemePreference {
287
375
  return this.controller.themePreference;
288
376
  }
@@ -307,6 +395,52 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
307
395
  this.#applyPreference('locale', value, true);
308
396
  }
309
397
 
398
+ get chrome(): ViewerChrome {
399
+ return toChrome(this.getAttribute('chrome')) ?? 'full';
400
+ }
401
+
402
+ set chrome(value: ViewerChrome) {
403
+ this.#reflect('chrome', toChrome(value) === 'none' ? 'none' : null);
404
+ this.#applyChrome();
405
+ }
406
+
407
+ get legend(): ViewerLegend {
408
+ return toLegend(this.getAttribute('legend')) ?? 'auto';
409
+ }
410
+
411
+ set legend(value: ViewerLegend) {
412
+ const v = toLegend(value);
413
+ this.#reflect('legend', v === 'auto' ? null : v);
414
+ this.#applyChrome();
415
+ }
416
+
417
+ get loop(): boolean {
418
+ return this.hasAttribute('loop');
419
+ }
420
+
421
+ set loop(value: boolean) {
422
+ this.toggleAttribute('loop', Boolean(value));
423
+ }
424
+
425
+ get loopDelay(): number {
426
+ return toDelay(this.getAttribute('loop-delay')) ?? DEFAULT_LOOP_DELAY;
427
+ }
428
+
429
+ set loopDelay(value: number) {
430
+ const v = toDelay(value);
431
+ if (v === null) this.removeAttribute('loop-delay');
432
+ else this.setAttribute('loop-delay', String(v));
433
+ }
434
+
435
+ get pauseOnHover(): boolean {
436
+ return this.getAttribute('pause-on-hover') !== 'false';
437
+ }
438
+
439
+ set pauseOnHover(value: boolean) {
440
+ if (value) this.removeAttribute('pause-on-hover');
441
+ else this.setAttribute('pause-on-hover', 'false');
442
+ }
443
+
310
444
  get payload(): ReportPayload | null {
311
445
  return this.#currentPayload;
312
446
  }
@@ -353,8 +487,8 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
353
487
  this.controller.pause();
354
488
  }
355
489
 
356
- fit(): Promise<void> {
357
- return this.controller.fit();
490
+ fit(options?: FitOptions): Promise<void> {
491
+ return this.controller.fit(options);
358
492
  }
359
493
 
360
494
  exportDelta(): Delta {
@@ -415,6 +549,34 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
415
549
  ui.header.setSidebarState(this.#sidebarOpen);
416
550
  }
417
551
 
552
+ /**
553
+ * `chrome` and `legend`: what the stage shows, whether clicks select, whether the view stays
554
+ * fitted. The CSS hides the rest (`.root[data-chrome="none"]`).
555
+ */
556
+ #applyChrome(): void {
557
+ const bare = this.chrome === 'none';
558
+ const c = this.controller;
559
+ c.autoFit = bare;
560
+ c.selectOnClick = !bare;
561
+ c.setLabelsInside(bare);
562
+ const ui = this.#ui;
563
+ if (!ui) return;
564
+ ui.root.dataset.chrome = this.chrome;
565
+ const legend = this.legend;
566
+ ui.stage.setChrome(legend === 'auto' ? (bare ? 'mini' : 'full') : legend, !bare);
567
+ const canvas = ui.stage.canvasHost.querySelector('canvas');
568
+ if (canvas) canvas.tabIndex = bare ? -1 : 0;
569
+ if (bare) {
570
+ ui.help.hide();
571
+ this.#toggleMenu(false);
572
+ }
573
+ }
574
+
575
+ /** Hold a looping replay while the pointer is over the graph (`pause-on-hover`). */
576
+ #applyHold(): void {
577
+ this.controller.hold(this.#pointerOver && this.loop && this.pauseOnHover);
578
+ }
579
+
418
580
  #build(): void {
419
581
  const style = h('style', { text: STYLES });
420
582
  const vars = h('style');
@@ -425,6 +587,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
425
587
  setMode: (mode) => {
426
588
  this.controller.setMode(mode);
427
589
  },
590
+ toggleScope: () => {
591
+ this.controller.toggleScope();
592
+ },
428
593
  setThemePreference: (pref) => {
429
594
  this.theme = pref;
430
595
  },
@@ -446,12 +611,13 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
446
611
  const menu = new Menu(ctx);
447
612
  const help = new HelpDialog(ctx);
448
613
  const live = h('div', { class: 'sr-only', 'aria-live': 'polite', 'aria-atomic': 'true' });
614
+ // The title and the actions float over the canvas; the menu drops from the actions.
615
+ header.attachMenu(menu.el);
616
+ stage.el.appendChild(header.el);
449
617
  const root = h(
450
618
  'div',
451
619
  { class: 'root' },
452
- header.el,
453
620
  h('div', { class: 'body' }, stage.el, sidebar.el),
454
- menu.el,
455
621
  help.el,
456
622
  live,
457
623
  );
@@ -463,7 +629,15 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
463
629
  const path = e.composedPath();
464
630
  if (!path.includes(menu.el) && !path.includes(header.menuButton)) this.#toggleMenu(false);
465
631
  });
632
+ const hover = (over: boolean) => (e: PointerEvent) => {
633
+ if (e.pointerType === 'touch') return;
634
+ this.#pointerOver = over;
635
+ this.#applyHold();
636
+ };
637
+ stage.el.addEventListener('pointerenter', hover(true));
638
+ stage.el.addEventListener('pointerleave', hover(false));
466
639
  this.#applySidebar();
640
+ this.#applyChrome();
467
641
  }
468
642
 
469
643
  /** Render, never throwing: a failure shows the error state with safe appearance defaults. */
@@ -486,7 +660,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
486
660
  #renderTopics(topics: ReadonlySet<Topic>): void {
487
661
  const ui = this.#ui;
488
662
  const c = this.controller;
489
- if (topics.has('mode')) this.#reflect('mode', c.mode);
663
+ if (topics.has('mode')) {
664
+ this.#reflect('mode', c.mode);
665
+ this.#reflect('scope', c.scope);
666
+ }
490
667
  if (topics.has('theme')) {
491
668
  this.#reflect(
492
669
  'theme',
@@ -507,7 +684,10 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
507
684
  ui.header.update(topics);
508
685
  ui.sidebar.update(topics);
509
686
  ui.stage.update(topics);
510
- if (topics.has('mode') || topics.has('status')) ui.root.dataset.mode = c.mode;
687
+ if (topics.has('mode') || topics.has('status')) {
688
+ ui.root.dataset.mode = c.mode;
689
+ ui.root.dataset.scope = c.scope;
690
+ }
511
691
  }
512
692
 
513
693
  #toggleSidebar(open?: boolean): void {
@@ -557,19 +737,21 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
557
737
  this.#compact = compact;
558
738
  ui.root.dataset.compact = String(compact);
559
739
  }
560
- ui.header.layout();
740
+ ui.stage.layout();
561
741
  ui.sidebar.renderWindow();
562
742
  if (compact) ui.sidebar.syncSheet();
563
743
  this.controller.layoutChanged();
564
744
  });
565
745
  ro.observe(this);
746
+ // The canvas also changes size without the element (chrome shown or hidden).
747
+ if (this.#ui) ro.observe(this.#ui.stage.el);
566
748
  this.#cleanup.push(() => ro.disconnect());
567
749
  }
568
750
 
569
751
  #onKeyDown(e: KeyboardEvent): void {
570
752
  if (e.defaultPrevented || e.isComposing) return;
571
753
  const ui = this.#ui;
572
- if (!ui) return;
754
+ if (!ui || this.chrome === 'none') return;
573
755
  const path = e.composedPath();
574
756
  if (!path.includes(this)) {
575
757
  // Outside the component only with `global-shortcuts`, when nothing else has focus, for
@@ -590,7 +772,7 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
590
772
  const target = path[0] ?? null;
591
773
  if (isTypingTarget(target)) return;
592
774
  const c = this.controller;
593
- const action = keyAction(e, c.mode);
775
+ const action = keyAction(e, c.mode, c.scope);
594
776
  if (!action) return;
595
777
  // Enter / Space on a focused control activate that control.
596
778
  const el = target as Element | null;
@@ -607,6 +789,9 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
607
789
  case 'mode':
608
790
  c.setMode(action.mode);
609
791
  break;
792
+ case 'scope':
793
+ c.toggleScope();
794
+ break;
610
795
  case 'next':
611
796
  c.next();
612
797
  break;
@@ -673,10 +858,12 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
673
858
  const state = parseHash(location.hash);
674
859
  this.#lastHash = location.hash;
675
860
  if (!state) return;
676
- if (state.mode) {
677
- // A deep link outranks the large-graph default and a report's preferred mode.
678
- this.controller.explicit.add('mode');
679
- this.controller.setMode(state.mode);
861
+ if (state.mode || state.scope) {
862
+ // A deep link outranks the large-graph default and a report's preferred view.
863
+ const c = this.controller;
864
+ if (state.mode) c.explicit.add('mode');
865
+ if (state.scope) c.explicit.add('scope');
866
+ c.setView(state.mode ?? c.mode, state.scope ?? c.scope);
680
867
  }
681
868
  if (state.hunk) this.controller.selectHunkById(state.hunk, { fly: true });
682
869
  }
@@ -685,7 +872,11 @@ export class KgdiffViewer extends HTMLElement implements KgdiffViewerElement {
685
872
  if (!this.hasAttribute('sync-hash') || typeof location === 'undefined') return;
686
873
  const c = this.controller;
687
874
  const hunk = c.selectedHunk >= 0 ? c.scene?.hunks[c.selectedHunk]?.id : undefined;
688
- const hash = formatHash({ ...(hunk !== undefined ? { hunk } : {}), mode: c.mode });
875
+ const hash = formatHash({
876
+ ...(hunk !== undefined ? { hunk } : {}),
877
+ mode: c.mode,
878
+ scope: c.scope,
879
+ });
689
880
  if (hash === location.hash) return;
690
881
  this.#lastHash = hash;
691
882
  try {
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/hash-sync.ts CHANGED
@@ -1,22 +1,24 @@
1
1
  /**
2
- * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>`.
2
+ * Deep links for `sync-hash`: `#kgdiff=hunk:<id>;mode:<mode>;scope:<scope>`. The legacy
3
+ * `mode:changes` reads as overlay + the `changes` scope.
3
4
  *
4
5
  * Values are percent-encoded (hunk ids embed union ids, which may be IRIs containing `;`, `#`
5
6
  * or `:`), so parsing splits on the first `:` of each `;`-separated field. Unknown fields are
6
7
  * ignored; a malformed value drops just that field.
7
8
  */
8
- import type { ViewerMode } from './index.js';
9
+ import type { ViewerMode, ViewerScope } from './index.js';
10
+ import { LEGACY_CHANGES_MODE, toMode, toScope } from './prefs.js';
9
11
 
10
12
  export interface HashState {
11
13
  readonly hunk?: string;
12
14
  readonly mode?: ViewerMode;
15
+ readonly scope?: ViewerScope;
13
16
  }
14
17
 
15
18
  const PREFIX = 'kgdiff=';
16
- const MODES: ReadonlySet<string> = new Set<ViewerMode>(['overlay', 'swipe', 'replay', 'changes']);
17
19
 
18
20
  export function isViewerMode(value: unknown): value is ViewerMode {
19
- return typeof value === 'string' && MODES.has(value);
21
+ return toMode(value) !== null;
20
22
  }
21
23
 
22
24
  function decode(value: string): string | undefined {
@@ -33,6 +35,8 @@ export function parseHash(hash: string): HashState | null {
33
35
  if (!raw.startsWith(PREFIX)) return null;
34
36
  let hunk: string | undefined;
35
37
  let mode: ViewerMode | undefined;
38
+ let scope: ViewerScope | undefined;
39
+ let legacy = false;
36
40
  for (const field of raw.slice(PREFIX.length).split(';')) {
37
41
  const colon = field.indexOf(':');
38
42
  if (colon <= 0) continue;
@@ -40,9 +44,18 @@ export function parseHash(hash: string): HashState | null {
40
44
  const value = decode(field.slice(colon + 1));
41
45
  if (value === undefined || value === '') continue;
42
46
  if (key === 'hunk') hunk = value;
43
- else if (key === 'mode' && isViewerMode(value)) mode = value;
47
+ else if (key === 'mode' && value === LEGACY_CHANGES_MODE) {
48
+ mode = 'overlay';
49
+ legacy = true;
50
+ } else if (key === 'mode') mode = toMode(value) ?? mode;
51
+ else if (key === 'scope') scope = toScope(value) ?? scope;
44
52
  }
45
- return { ...(hunk !== undefined ? { hunk } : {}), ...(mode !== undefined ? { mode } : {}) };
53
+ if (legacy) scope ??= 'changes';
54
+ return {
55
+ ...(hunk !== undefined ? { hunk } : {}),
56
+ ...(mode !== undefined ? { mode } : {}),
57
+ ...(scope !== undefined ? { scope } : {}),
58
+ };
46
59
  }
47
60
 
48
61
  /** Encode `state` as a hash (`#kgdiff=…`); empty state gives `#kgdiff=`. */
@@ -50,5 +63,6 @@ export function formatHash(state: HashState): string {
50
63
  const fields: string[] = [];
51
64
  if (state.hunk !== undefined) fields.push(`hunk:${encodeURIComponent(state.hunk)}`);
52
65
  if (state.mode !== undefined) fields.push(`mode:${state.mode}`);
66
+ if (state.scope !== undefined) fields.push(`scope:${state.scope}`);
53
67
  return `#${PREFIX}${fields.join(';')}`;
54
68
  }
package/src/i18n.ts CHANGED
@@ -10,11 +10,18 @@ import type { Locale } from '@kgdiff/core';
10
10
  const en = {
11
11
  'brand.tagline': 'git diff for knowledge graphs',
12
12
  'header.compare': 'Compared versions',
13
- 'mode.label': 'View mode',
13
+ 'header.actions': 'Viewer actions',
14
+ 'dock.label': 'View',
15
+ 'mode.label': 'Compare by',
14
16
  'mode.overlay': 'Overlay',
15
17
  'mode.swipe': 'Swipe',
16
18
  'mode.replay': 'Replay',
17
19
  'mode.changes': 'Changes only',
20
+ 'scope.hint': 'Show only the changes, with nearby context',
21
+ 'scope.on': 'Showing only the changes',
22
+ 'scope.off': 'Showing the whole graph',
23
+ 'scope.locked':
24
+ 'This graph was laid out around its changes only: the whole graph cannot be shown.',
18
25
  'family.added': 'Added',
19
26
  'family.removed': 'Removed',
20
27
  'family.content': 'Content',
@@ -41,12 +48,6 @@ const en = {
41
48
  'status.added': 'Added',
42
49
  'status.removed': 'Removed',
43
50
  'status.modified': 'Modified',
44
- 'stat.added': '{n} added',
45
- 'stat.removed': '{n} removed',
46
- 'stat.modified': '{n} modified',
47
- 'stat.restructured': '{n} restructured',
48
- 'stat.filter': 'Show only these changes',
49
- 'stat.label': 'Change summary',
50
51
  'action.fit': 'Fit to view',
51
52
  'action.help': 'Keyboard shortcuts',
52
53
  'action.sidebar': 'Toggle sidebar',
@@ -199,11 +200,17 @@ export type Messages = Readonly<Record<MessageKey, string>>;
199
200
  const zhCN: Messages = {
200
201
  'brand.tagline': '知识图谱的 git diff',
201
202
  'header.compare': '对比的版本',
202
- 'mode.label': '视图模式',
203
+ 'header.actions': '查看器操作',
204
+ 'dock.label': '视图',
205
+ 'mode.label': '对比方式',
203
206
  'mode.overlay': '叠加',
204
207
  'mode.swipe': '滑动对比',
205
208
  'mode.replay': '回放',
206
209
  'mode.changes': '仅看变更',
210
+ 'scope.hint': '只显示变更,以及附近的上下文',
211
+ 'scope.on': '只显示变更',
212
+ 'scope.off': '显示全图',
213
+ 'scope.locked': '这张图只对变更部分做了布局,无法显示全图。',
207
214
  'family.added': '新增',
208
215
  'family.removed': '删除',
209
216
  'family.content': '内容',
@@ -230,12 +237,6 @@ const zhCN: Messages = {
230
237
  'status.added': '新增',
231
238
  'status.removed': '删除',
232
239
  'status.modified': '修改',
233
- 'stat.added': '新增 {n} 处',
234
- 'stat.removed': '删除 {n} 处',
235
- 'stat.modified': '修改 {n} 处',
236
- 'stat.restructured': '结构调整 {n} 处',
237
- 'stat.filter': '只显示这类变更',
238
- 'stat.label': '变更概览',
239
240
  'action.fit': '适应视图',
240
241
  'action.help': '键盘快捷键',
241
242
  'action.sidebar': '显示或隐藏侧栏',