jekyll-theme-zer0 1.27.0 → 1.28.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. checksums.yaml +4 -4
  2. data/CHANGELOG.md +386 -4
  3. data/_data/consumers.yml +147 -0
  4. data/_data/features.yml +77 -5
  5. data/_data/i18n/fr.yml +98 -0
  6. data/_data/i18n/manifest.yml +1502 -0
  7. data/_data/navigation/docs.yml +2 -0
  8. data/_data/navigation/main.yml +6 -0
  9. data/_data/series.yml +19 -0
  10. data/_data/theme-manifest.yml +348 -266
  11. data/_includes/README.md +21 -2
  12. data/_includes/analytics/google-tag-manager-body.html +11 -2
  13. data/_includes/analytics/google-tag-manager-head.html +16 -6
  14. data/_includes/analytics/posthog.html +33 -4
  15. data/_includes/components/abc-letter.html +43 -0
  16. data/_includes/components/book-card.html +42 -0
  17. data/_includes/components/book-nav.html +80 -0
  18. data/_includes/components/book-plate.html +31 -0
  19. data/_includes/components/book-toc.html +47 -0
  20. data/_includes/components/bookshelf.html +68 -0
  21. data/_includes/components/card-grid.html +60 -0
  22. data/_includes/components/data-card.html +95 -0
  23. data/_includes/components/halfmoon.html +5 -1
  24. data/_includes/components/page-feedback.html +65 -2
  25. data/_includes/components/theme-controls-bar.html +10 -2
  26. data/_includes/components/theme-customizer.html +8 -2
  27. data/_includes/content/seo.html +9 -3
  28. data/_includes/core/color-mode-init.html +13 -4
  29. data/_includes/core/favicon.html +46 -0
  30. data/_includes/core/head.html +9 -0
  31. data/_includes/custom/body-end.html +18 -0
  32. data/_includes/custom/body-start.html +17 -0
  33. data/_includes/custom/footer.html +18 -0
  34. data/_includes/custom/head.html +18 -0
  35. data/_includes/navigation/local-graph.html +28 -2
  36. data/_includes/navigation/nav-tree.html +3 -3
  37. data/_includes/navigation/sidebar-config.html +21 -0
  38. data/_includes/navigation/sidebar-nav.html +4 -0
  39. data/_includes/navigation/sidebar-pagetree.html +150 -0
  40. data/_includes/navigation/unified-drawer.html +8 -2
  41. data/_includes/obsidian/full-graph.html +165 -136
  42. data/_layouts/404.html +260 -0
  43. data/_layouts/README.md +2 -0
  44. data/_layouts/book-abc.html +106 -0
  45. data/_layouts/book-story.html +91 -0
  46. data/_layouts/book.html +113 -0
  47. data/_layouts/collection.html +17 -5
  48. data/_layouts/home.html +5 -2
  49. data/_layouts/landing.html +9 -0
  50. data/_layouts/news.html +155 -31
  51. data/_layouts/root.html +16 -2
  52. data/_layouts/section.html +57 -15
  53. data/_sass/components/_book.scss +423 -0
  54. data/_sass/core/_navbar.scss +13 -0
  55. data/_sass/core/_obsidian.scss +286 -6
  56. data/_sass/theme/_backgrounds.scss +21 -8
  57. data/assets/css/main.scss +1 -0
  58. data/assets/js/auto-hide-nav.js +5 -1
  59. data/assets/js/halfmoon.js +26 -0
  60. data/assets/js/obsidian-graph.js +702 -264
  61. data/assets/js/obsidian-local-graph.js +161 -54
  62. data/assets/js/search-modal.js +4 -1
  63. data/scripts/bin/manifest +31 -4
  64. data/scripts/bin/validate +5 -1
  65. data/scripts/install/README.md +47 -6
  66. data/scripts/install/ai/client.sh +302 -93
  67. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  68. data/scripts/install/ai/wizard.sh +10 -5
  69. data/scripts/install/apply.sh +7 -3
  70. data/scripts/install/cli.sh +54 -4
  71. data/scripts/install/config.sh +167 -0
  72. data/scripts/install/doctor.sh +38 -0
  73. data/scripts/install/plan.sh +10 -0
  74. data/scripts/install/spec.sh +15 -7
  75. data/scripts/install/template.sh +4 -0
  76. data/scripts/propagate.rb +277 -0
  77. data/scripts/translate.rb +90 -0
  78. metadata +27 -2
@@ -19,15 +19,24 @@
19
19
  * targets become floating "broken" nodes drawn in red so the graph also
20
20
  * surfaces dangling links.
21
21
  *
22
- * Public hooks (read-only):
22
+ * Mirrors the Obsidian desktop graph controls: Filters (search with tag:/
23
+ * path: operators, per-collection toggles, orphans, unresolved), Display
24
+ * (arrows, node size, link thickness, label fade threshold) and Forces
25
+ * (repulsion, link distance, animated re-layout). Settings persist to
26
+ * localStorage. The stylesheet re-derives itself when the Bootstrap
27
+ * color mode (data-bs-theme) changes — no reload needed.
28
+ *
29
+ * Public hooks (read-only, used by test/visual/features/obsidian-graph.spec.js):
23
30
  * window.ObsidianGraph.cy — cytoscape instance once initialized
24
31
  * window.ObsidianGraph.byKey — normalized lookup table
25
32
  * window.ObsidianGraph.entries — raw entries array
33
+ * window.ObsidianGraph.settings — live settings object
26
34
  */
27
35
  (function () {
28
36
  'use strict';
29
37
 
30
38
  var CONTAINER_ID = 'obsidian-graph';
39
+ var STORAGE_KEY = 'zer0.obsidianGraph.v1';
31
40
  // Index URL resolution mirrors obsidian-wiki-links.js: the baseurl-aware
32
41
  // value js-cdn.html emits (OBSIDIAN_CONFIG.wikiIndexUrl), then the legacy
33
42
  // per-page override global, then a <base>-relative fallback.
@@ -35,6 +44,61 @@
35
44
  window.OBSIDIAN_WIKI_INDEX_URL ||
36
45
  ((document.querySelector('base') || {}).href || '/') + 'assets/data/wiki-index.json');
37
46
 
47
+ var HUB_DEGREE_THRESHOLD = 12;
48
+
49
+ // User-tunable view state. Multipliers are relative to the base style so
50
+ // "1" always means the stock look. Persisted per-browser via localStorage.
51
+ var DEFAULTS = {
52
+ collections: {}, // name -> false when hidden; missing = visible
53
+ showOrphans: false, // matches Obsidian's "Show orphans" off state
54
+ showBroken: true, // unresolved [[targets]] as dashed red nodes
55
+ showArrows: true,
56
+ nodeScale: 1, // 0.5 – 2
57
+ linkScale: 1, // 0.5 – 3
58
+ labelZoom: 1.25, // zoom level past which labels appear (0.4 – 2.5)
59
+ repulsion: 1, // 0.3 – 3 × base node repulsion
60
+ linkDistance: 1, // 0.4 – 2.5 × base ideal edge length
61
+ animateLayout: true // ignored when prefers-reduced-motion
62
+ };
63
+
64
+ function clamp(value, min, max, fallback) {
65
+ var n = parseFloat(value);
66
+ if (!isFinite(n)) return fallback;
67
+ return Math.min(max, Math.max(min, n));
68
+ }
69
+
70
+ function loadSettings() {
71
+ var merged = JSON.parse(JSON.stringify(DEFAULTS));
72
+ try {
73
+ var raw = window.localStorage.getItem(STORAGE_KEY);
74
+ if (!raw) return merged;
75
+ var saved = JSON.parse(raw);
76
+ if (!saved || typeof saved !== 'object') return merged;
77
+ if (saved.collections && typeof saved.collections === 'object') {
78
+ merged.collections = saved.collections;
79
+ }
80
+ ['showOrphans', 'showBroken', 'showArrows', 'animateLayout'].forEach(function (k) {
81
+ if (typeof saved[k] === 'boolean') merged[k] = saved[k];
82
+ });
83
+ merged.nodeScale = clamp(saved.nodeScale, 0.5, 2, merged.nodeScale);
84
+ merged.linkScale = clamp(saved.linkScale, 0.5, 3, merged.linkScale);
85
+ merged.labelZoom = clamp(saved.labelZoom, 0.4, 2.5, merged.labelZoom);
86
+ merged.repulsion = clamp(saved.repulsion, 0.3, 3, merged.repulsion);
87
+ merged.linkDistance = clamp(saved.linkDistance, 0.4, 2.5, merged.linkDistance);
88
+ } catch (e) { /* private mode / corrupt payload — fall back to defaults */ }
89
+ return merged;
90
+ }
91
+
92
+ var saveTimer = null;
93
+ function saveSettings(settings) {
94
+ clearTimeout(saveTimer);
95
+ saveTimer = setTimeout(function () {
96
+ try {
97
+ window.localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
98
+ } catch (e) { /* storage unavailable — view state is session-only */ }
99
+ }, 150);
100
+ }
101
+
38
102
  function normalize(value) {
39
103
  return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
40
104
  }
@@ -43,6 +107,22 @@
43
107
  return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
44
108
  }
45
109
 
110
+ function debounce(fn, wait) {
111
+ var t;
112
+ return function () {
113
+ var ctx = this;
114
+ var args = arguments;
115
+ clearTimeout(t);
116
+ t = setTimeout(function () { fn.apply(ctx, args); }, wait);
117
+ };
118
+ }
119
+
120
+ function escapeHtml(value) {
121
+ return String(value == null ? '' : value)
122
+ .replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;')
123
+ .replace(/"/g, '&quot;').replace(/'/g, '&#39;');
124
+ }
125
+
46
126
  // Cytoscape is vendored under assets/vendor/ (no runtime CDN). Path comes from
47
127
  // window.OBSIDIAN_CONFIG.cytoscapeUrl (set by Liquid), with a base fallback.
48
128
  function cytoscapeSrc() {
@@ -99,27 +179,7 @@
99
179
  }
100
180
 
101
181
  // Cytoscape needs concrete color values (not CSS vars). readTheme()
102
- // resolves light/dark at init time.
103
- // Shared cose layout — looser packing, more edge length, wider padding.
104
- var COSE_LAYOUT = {
105
- name: 'cose',
106
- animate: false,
107
- randomize: true,
108
- nodeRepulsion: function () { return 32000; },
109
- idealEdgeLength: function () { return 190; },
110
- edgeElasticity: function () { return 60; },
111
- nodeOverlap: 40,
112
- gravity: 0.1,
113
- nestingFactor: 1.5,
114
- numIter: 3000,
115
- padding: 80,
116
- componentSpacing: 140
117
- };
118
-
119
- // Labels appear when zoomed past this level or on hover/highlight.
120
- var LABEL_ZOOM_THRESHOLD = 1.25;
121
- var HUB_DEGREE_THRESHOLD = 12;
122
-
182
+ // resolves light/dark from the live Bootstrap color mode.
123
183
  function readTheme() {
124
184
  var attr = (document.documentElement.getAttribute('data-bs-theme') ||
125
185
  document.body.getAttribute('data-bs-theme') || '').toLowerCase();
@@ -130,56 +190,63 @@
130
190
  labelOutline: 'rgba(13,17,23,0.88)',
131
191
  edge: 'rgba(173,181,189,0.28)',
132
192
  edgeArrow: 'rgba(173,181,189,0.42)',
133
- canvasBg: 'transparent',
134
193
  nodeBorder: 'rgba(255,255,255,0.18)'
135
194
  } : {
136
195
  label: 'rgba(27,31,35,0.92)',
137
196
  labelOutline: 'rgba(248,249,250,0.90)',
138
197
  edge: 'rgba(73,80,87,0.22)',
139
198
  edgeArrow: 'rgba(73,80,87,0.38)',
140
- canvasBg: 'transparent',
141
199
  nodeBorder: 'rgba(0,0,0,0.14)'
142
200
  };
143
201
  }
144
202
 
203
+ var COLLECTION_PALETTE = {
204
+ posts: [13, 110, 253],
205
+ docs: [25, 135, 84],
206
+ notes: [111, 66, 193],
207
+ notebooks: [214, 51, 132],
208
+ quickstart: [253, 126, 20],
209
+ about: [32, 201, 151],
210
+ hobbies: [255, 193, 7],
211
+ news: [102, 16, 242],
212
+ services: [13, 202, 240],
213
+ books: [220, 53, 133]
214
+ };
215
+
145
216
  function collectionColor(name, alpha) {
146
217
  alpha = alpha == null ? 0.72 : alpha;
147
- // Stable, distinguishable palette per collection. Falls back for pages.
148
- var palette = {
149
- posts: [13, 110, 253],
150
- docs: [25, 135, 84],
151
- notes: [111, 66, 193],
152
- notebooks: [214, 51, 132],
153
- quickstart: [253, 126, 20],
154
- about: [32, 201, 151],
155
- hobbies: [255, 193, 7],
156
- news: [102, 16, 242],
157
- services: [13, 202, 240]
158
- };
159
- var rgb = palette[name] || [108, 117, 125];
218
+ var rgb = COLLECTION_PALETTE[name] || [108, 117, 125];
160
219
  return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alpha + ')';
161
220
  }
162
221
 
222
+ function baseNodeSize(degree) {
223
+ // Same mapping the old mapData(degree, 0, 25, 8, 34) produced, computed in
224
+ // data so the Display "node size" multiplier can rescale it live.
225
+ var d = Math.min(Math.max(degree || 0, 0), 25);
226
+ return 8 + (d / 25) * 26;
227
+ }
228
+
163
229
  function buildElements(entries, byKey) {
164
230
  var elements = [];
165
- var seenTargets = Object.create(null);
166
231
  var brokenIds = Object.create(null);
167
232
 
168
233
  entries.forEach(function (entry) {
169
- var nid = entry.url;
170
234
  elements.push({
171
235
  group: 'nodes',
172
236
  data: {
173
- id: nid,
174
- label: entry.title || entry.basename || nid,
237
+ id: entry.url,
238
+ label: entry.title || entry.basename || entry.url,
175
239
  url: entry.url,
176
240
  collection: entry.collection || 'page',
177
241
  color: collectionColor(entry.collection),
178
242
  excerpt: entry.excerpt || '',
243
+ // Search-operator fodder (tag:/path: + alias matches). Client-side
244
+ // only — the wiki-index.json contract is unchanged.
245
+ tags: (entry.tags || []).map(normalize),
246
+ aliasText: (entry.aliases || []).map(normalize).join(' | '),
179
247
  broken: false
180
248
  }
181
249
  });
182
- seenTargets[nid] = true;
183
250
  });
184
251
 
185
252
  entries.forEach(function (entry) {
@@ -203,6 +270,8 @@
203
270
  collection: 'broken',
204
271
  color: 'rgba(220,53,69,0.78)',
205
272
  excerpt: 'Unresolved wiki-link',
273
+ tags: [],
274
+ aliasText: '',
206
275
  broken: true
207
276
  }
208
277
  });
@@ -233,261 +302,620 @@
233
302
  elements.forEach(function (el) {
234
303
  if (el.group !== 'nodes') return;
235
304
  el.data.degree = degree[el.data.id] || 0;
305
+ el.data.size = baseNodeSize(el.data.degree);
236
306
  });
237
307
  }
238
308
 
239
- function updateLabelVisibility(cy) {
240
- if (!cy) return;
241
- var show = cy.zoom() >= LABEL_ZOOM_THRESHOLD;
242
- cy.nodes().forEach(function (node) {
243
- if (node.hasClass('highlighted')) return;
244
- if ((node.data('degree') || 0) >= HUB_DEGREE_THRESHOLD) return;
245
- node.style('text-opacity', show ? 0.9 : 0);
246
- });
309
+ // ---------------------------------------------------------------------------
310
+ // Graph application — wraps one cytoscape instance plus the settings-driven
311
+ // style/filter/layout plumbing shared by every control.
312
+ // ---------------------------------------------------------------------------
313
+ function GraphApp(container, entries, byKey) {
314
+ this.container = container;
315
+ this.entries = entries;
316
+ this.byKey = byKey;
317
+ this.settings = loadSettings();
318
+ this.cy = null;
319
+ this.searchQuery = '';
247
320
  }
248
321
 
249
- function renderGraph(container, elements) {
250
- if (typeof window.cytoscape !== 'function') {
251
- container.innerHTML =
252
- '<div class="alert alert-danger" role="alert">' +
253
- 'Graph view failed to load: <code>cytoscape</code> library is not ' +
254
- 'available. Check your network connection or content security policy.' +
255
- '</div>';
256
- return null;
257
- }
258
-
322
+ GraphApp.prototype.buildStyle = function () {
259
323
  var theme = readTheme();
260
- container.style.backgroundColor = theme.canvasBg;
261
- var nodeDur = prefersReducedMotion() ? '0ms' : '160ms';
262
- var edgeDur = prefersReducedMotion() ? '0ms' : '150ms';
324
+ var s = this.settings;
325
+ var motion = prefersReducedMotion() ? '0ms' : '160ms';
326
+ return [
327
+ {
328
+ selector: 'node',
329
+ style: {
330
+ 'background-color': 'data(color)',
331
+ 'background-opacity': 0.82,
332
+ 'label': 'data(label)',
333
+ 'font-size': '10px',
334
+ 'font-weight': 500,
335
+ 'color': theme.label,
336
+ 'text-outline-color': theme.labelOutline,
337
+ 'text-outline-width': 3,
338
+ 'text-outline-opacity': 1,
339
+ 'text-background-opacity': 0,
340
+ 'text-border-opacity': 0,
341
+ 'text-valign': 'bottom',
342
+ 'text-margin-y': 6,
343
+ 'text-wrap': 'ellipsis',
344
+ 'text-max-width': '120px',
345
+ // Hidden until zoomed-in, hovered, or a high-degree hub.
346
+ 'min-zoomed-font-size': 11,
347
+ 'text-opacity': 0,
348
+ 'width': 'data(size)',
349
+ 'height': 'data(size)',
350
+ 'border-width': 1,
351
+ 'border-color': theme.nodeBorder,
352
+ 'opacity': 0.88,
353
+ 'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
354
+ 'transition-duration': motion
355
+ }
356
+ },
357
+ {
358
+ // Hub landmarks stay labeled when zoomed out.
359
+ selector: 'node[degree >= ' + HUB_DEGREE_THRESHOLD + ']',
360
+ style: {
361
+ 'text-opacity': 0.88,
362
+ 'font-size': '11px',
363
+ 'font-weight': 600,
364
+ 'min-zoomed-font-size': 0
365
+ }
366
+ },
367
+ {
368
+ selector: 'node[?broken]',
369
+ style: {
370
+ 'border-style': 'dashed',
371
+ 'border-color': 'rgba(220,53,69,0.75)',
372
+ 'border-width': 1.5,
373
+ 'background-opacity': 0.55,
374
+ 'opacity': 0.78
375
+ }
376
+ },
377
+ {
378
+ selector: 'edge',
379
+ style: {
380
+ 'curve-style': 'bezier',
381
+ 'width': s.linkScale,
382
+ 'line-color': theme.edge,
383
+ 'target-arrow-color': theme.edgeArrow,
384
+ 'target-arrow-shape': s.showArrows ? 'triangle' : 'none',
385
+ 'arrow-scale': 0.75,
386
+ 'opacity': 0.55,
387
+ 'transition-property': 'line-color, width, opacity',
388
+ 'transition-duration': motion
389
+ }
390
+ },
391
+ {
392
+ selector: 'edge[?broken]',
393
+ style: {
394
+ 'line-color': 'rgba(220,53,69,0.32)',
395
+ 'target-arrow-color': 'rgba(220,53,69,0.42)',
396
+ 'line-style': 'dashed',
397
+ 'opacity': 0.45
398
+ }
399
+ },
400
+ {
401
+ selector: '.faded',
402
+ style: {
403
+ 'opacity': 0.08,
404
+ 'text-opacity': 0
405
+ }
406
+ },
407
+ {
408
+ selector: 'node.highlighted',
409
+ style: {
410
+ 'border-color': 'rgba(253,126,20,0.95)',
411
+ 'border-width': 2.5,
412
+ 'opacity': 1,
413
+ 'background-opacity': 0.95,
414
+ 'text-opacity': 1,
415
+ 'font-size': '11px',
416
+ 'font-weight': 700,
417
+ 'min-zoomed-font-size': 0,
418
+ 'z-index': 9999
419
+ }
420
+ },
421
+ {
422
+ selector: 'edge.highlighted',
423
+ style: {
424
+ 'line-color': 'rgba(253,126,20,0.85)',
425
+ 'target-arrow-color': 'rgba(253,126,20,0.85)',
426
+ 'width': Math.max(2, 2 * this.settings.linkScale),
427
+ 'opacity': 0.95,
428
+ 'z-index': 9999
429
+ }
430
+ },
431
+ {
432
+ // Filtered out by the Filters panel. display:none also hides the
433
+ // connected edges, so the visible subgraph stays consistent.
434
+ selector: '.obsidian-hidden',
435
+ style: { 'display': 'none' }
436
+ }
437
+ ];
438
+ };
263
439
 
264
- var cy = window.cytoscape({
265
- container: container,
266
- elements: elements,
440
+ GraphApp.prototype.render = function () {
441
+ var app = this;
442
+ this.cy = window.cytoscape({
443
+ container: this.container,
444
+ elements: this.elements,
267
445
  minZoom: 0.1,
268
446
  maxZoom: 4,
269
- style: [
270
- {
271
- selector: 'node',
272
- style: {
273
- 'background-color': 'data(color)',
274
- 'background-opacity': 0.82,
275
- 'label': 'data(label)',
276
- 'font-size': '10px',
277
- 'font-weight': 500,
278
- 'color': theme.label,
279
- 'text-outline-color': theme.labelOutline,
280
- 'text-outline-width': 3,
281
- 'text-outline-opacity': 1,
282
- 'text-background-opacity': 0,
283
- 'text-border-opacity': 0,
284
- 'text-valign': 'bottom',
285
- 'text-margin-y': 6,
286
- 'text-wrap': 'ellipsis',
287
- 'text-max-width': '120px',
288
- // Hidden until zoomed-in, hovered, or a high-degree hub.
289
- 'min-zoomed-font-size': 11,
290
- 'text-opacity': 0,
291
- 'width': 'mapData(degree, 0, 25, 8, 34)',
292
- 'height': 'mapData(degree, 0, 25, 8, 34)',
293
- 'border-width': 1,
294
- 'border-color': theme.nodeBorder,
295
- 'opacity': 0.88,
296
- 'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
297
- 'transition-duration': nodeDur
298
- }
299
- },
300
- {
301
- // Hub landmarks stay labeled when zoomed out.
302
- selector: 'node[degree >= ' + HUB_DEGREE_THRESHOLD + ']',
303
- style: {
304
- 'text-opacity': 0.88,
305
- 'font-size': '11px',
306
- 'font-weight': 600,
307
- 'min-zoomed-font-size': 0
308
- }
309
- },
310
- {
311
- selector: 'node[?broken]',
312
- style: {
313
- 'border-style': 'dashed',
314
- 'border-color': 'rgba(220,53,69,0.75)',
315
- 'border-width': 1.5,
316
- 'background-opacity': 0.55,
317
- 'opacity': 0.78
318
- }
319
- },
320
- {
321
- selector: 'edge',
322
- style: {
323
- 'curve-style': 'bezier',
324
- 'width': 1,
325
- 'line-color': theme.edge,
326
- 'target-arrow-color': theme.edgeArrow,
327
- 'target-arrow-shape': 'triangle',
328
- 'arrow-scale': 0.75,
329
- 'opacity': 0.55,
330
- 'transition-property': 'line-color, width, opacity',
331
- 'transition-duration': edgeDur
332
- }
333
- },
334
- {
335
- selector: 'edge[?broken]',
336
- style: {
337
- 'line-color': 'rgba(220,53,69,0.32)',
338
- 'target-arrow-color': 'rgba(220,53,69,0.42)',
339
- 'line-style': 'dashed',
340
- 'opacity': 0.45
341
- }
342
- },
343
- {
344
- selector: '.faded',
345
- style: {
346
- 'opacity': 0.08,
347
- 'text-opacity': 0
348
- }
349
- },
350
- {
351
- selector: 'node.highlighted',
352
- style: {
353
- 'border-color': 'rgba(253,126,20,0.95)',
354
- 'border-width': 2.5,
355
- 'opacity': 1,
356
- 'background-opacity': 0.95,
357
- 'text-opacity': 1,
358
- 'font-size': '11px',
359
- 'font-weight': 700,
360
- 'min-zoomed-font-size': 0,
361
- 'z-index': 9999
362
- }
363
- },
364
- {
365
- selector: 'edge.highlighted',
366
- style: {
367
- 'line-color': 'rgba(253,126,20,0.85)',
368
- 'target-arrow-color': 'rgba(253,126,20,0.85)',
369
- 'width': 2,
370
- 'opacity': 0.95,
371
- 'z-index': 9999
372
- }
373
- }
374
- ],
375
- layout: COSE_LAYOUT
447
+ wheelSensitivity: 0.3,
448
+ style: this.buildStyle(),
449
+ layout: { name: 'preset' } // real layout runs below, honouring settings
376
450
  });
377
451
 
378
- cy.on('tap', 'node', function (evt) {
379
- var node = evt.target;
380
- var url = node.data('url');
381
- if (url) {
382
- // Same-tab navigation; use Cmd/Ctrl-click for new tab via the
383
- // standard handler below.
384
- var native = evt.originalEvent;
385
- if (native && (native.metaKey || native.ctrlKey)) {
386
- window.open(url, '_blank', 'noopener');
387
- } else {
388
- window.location.href = url;
389
- }
452
+ this.cy.on('tap', 'node', function (evt) {
453
+ var url = evt.target.data('url');
454
+ if (!url) return;
455
+ var native = evt.originalEvent;
456
+ if (native && (native.metaKey || native.ctrlKey)) {
457
+ window.open(url, '_blank', 'noopener');
458
+ } else {
459
+ window.location.href = url;
390
460
  }
391
461
  });
392
462
 
393
- // Hover: highlight neighborhood, fade everything else.
394
- cy.on('mouseover', 'node', function (evt) {
463
+ // Hover: highlight neighborhood, fade everything else, show the preview.
464
+ this.cy.on('mouseover', 'node', function (evt) {
395
465
  var node = evt.target;
396
466
  var nhood = node.closedNeighborhood();
397
- cy.elements().not(nhood).addClass('faded');
467
+ app.cy.elements().not(nhood).addClass('faded');
398
468
  nhood.addClass('highlighted');
469
+ app.showPreview(node);
399
470
  });
400
- cy.on('mouseout', 'node', function () {
401
- cy.elements().removeClass('faded highlighted');
402
- updateLabelVisibility(cy);
471
+ this.cy.on('mouseout', 'node', function () {
472
+ app.cy.elements().removeClass('faded highlighted');
473
+ app.hidePreview();
474
+ app.updateLabelVisibility();
475
+ app.applySearch(); // restore an active search highlight after hover
403
476
  });
404
-
405
- cy.on('zoom', function () {
406
- updateLabelVisibility(cy);
477
+ this.cy.on('zoom pan', function () {
478
+ app.hidePreview();
479
+ app.updateLabelVisibility();
407
480
  });
481
+ this.cy.on('grab', 'node', function () { app.hidePreview(); });
482
+
483
+ this.applyFilters(false);
484
+ this.runLayout(true);
485
+ this.updateLabelVisibility();
486
+ };
408
487
 
409
- updateLabelVisibility(cy);
488
+ GraphApp.prototype.visibleElements = function () {
489
+ return this.cy.elements().not('.obsidian-hidden');
490
+ };
410
491
 
411
- return cy;
412
- }
492
+ GraphApp.prototype.runLayout = function (randomize) {
493
+ if (!this.cy) return;
494
+ var s = this.settings;
495
+ if (this.activeLayout) { try { this.activeLayout.stop(); } catch (e) { /* ignore */ } }
496
+ this.activeLayout = this.cy.layout({
497
+ name: 'cose',
498
+ animate: s.animateLayout && !prefersReducedMotion(),
499
+ randomize: !!randomize,
500
+ nodeRepulsion: function () { return 32000 * s.repulsion; },
501
+ idealEdgeLength: function () { return 190 * s.linkDistance; },
502
+ edgeElasticity: function () { return 60; },
503
+ nodeOverlap: 40,
504
+ gravity: 0.1,
505
+ nestingFactor: 1.5,
506
+ numIter: 3000,
507
+ padding: 80,
508
+ componentSpacing: 140,
509
+ fit: true
510
+ });
511
+ this.activeLayout.run();
512
+ };
413
513
 
414
- function wireSearch(cy, byKey) {
415
- var input = document.getElementById('obsidian-graph-search');
416
- var status = document.getElementById('obsidian-graph-status');
417
- if (!input || !cy) return;
514
+ GraphApp.prototype.applyDisplay = function () {
515
+ if (!this.cy) return;
516
+ var s = this.settings;
517
+ this.cy.style().fromJson(this.buildStyle()).update();
518
+ var app = this;
519
+ this.cy.batch(function () {
520
+ app.cy.nodes().forEach(function (node) {
521
+ node.data('size', baseNodeSize(node.data('degree')) * s.nodeScale);
522
+ });
523
+ });
524
+ this.updateLabelVisibility();
525
+ saveSettings(s);
526
+ };
418
527
 
419
- function run() {
420
- var q = normalize(input.value);
421
- cy.elements().removeClass('faded highlighted');
422
- if (!q) {
423
- if (status) status.textContent = '';
424
- cy.fit(undefined, 80);
425
- return;
426
- }
427
- var matches = cy.nodes().filter(function (n) {
428
- return normalize(n.data('label')).indexOf(q) !== -1;
528
+ GraphApp.prototype.applyFilters = function (refit) {
529
+ if (!this.cy) return;
530
+ var s = this.settings;
531
+ var app = this;
532
+ this.cy.batch(function () {
533
+ app.cy.nodes().forEach(function (node) {
534
+ var d = node.data();
535
+ var hide = false;
536
+ if (d.broken) {
537
+ hide = !s.showBroken;
538
+ } else if (s.collections[d.collection || 'page'] === false) {
539
+ hide = true;
540
+ }
541
+ if (!hide && !s.showOrphans && (d.degree || 0) === 0) hide = true;
542
+ node.toggleClass('obsidian-hidden', hide);
429
543
  });
430
- if (matches.length === 0) {
431
- if (status) status.textContent = 'No nodes match “' + input.value + '”.';
432
- return;
544
+ });
545
+ this.updateStats();
546
+ if (refit !== false) this.cy.fit(this.visibleElements(), 80);
547
+ saveSettings(s);
548
+ };
549
+
550
+ GraphApp.prototype.updateLabelVisibility = function () {
551
+ if (!this.cy) return;
552
+ var show = this.cy.zoom() >= this.settings.labelZoom;
553
+ this.cy.nodes().forEach(function (node) {
554
+ if (node.hasClass('highlighted')) return;
555
+ if ((node.data('degree') || 0) >= HUB_DEGREE_THRESHOLD) return;
556
+ node.style('text-opacity', show ? 0.9 : 0);
557
+ });
558
+ };
559
+
560
+ // Search supports Obsidian-flavoured operators alongside plain text:
561
+ // tag:foo entries tagged foo path:docs URL contains "docs"
562
+ // Plain terms match title/basename/aliases; all terms must match (AND).
563
+ GraphApp.prototype.matchesQuery = function (node, terms) {
564
+ var d = node.data();
565
+ var label = normalize(d.label);
566
+ var aliases = d.aliasText || '';
567
+ for (var i = 0; i < terms.length; i++) {
568
+ var term = terms[i];
569
+ if (term.slice(0, 4) === 'tag:') {
570
+ var tag = term.slice(4).replace(/^#/, '');
571
+ if (!tag) continue;
572
+ if ((d.tags || []).indexOf(tag) === -1) return false;
573
+ } else if (term.slice(0, 5) === 'path:') {
574
+ var path = term.slice(5);
575
+ if (!path) continue;
576
+ if (String(d.url || '').toLowerCase().indexOf(path) === -1) return false;
577
+ } else if (label.indexOf(term) === -1 && aliases.indexOf(term) === -1) {
578
+ return false;
433
579
  }
434
- var nhood = matches.closedNeighborhood();
435
- cy.elements().not(nhood).addClass('faded');
436
- matches.addClass('highlighted');
437
- if (status) status.textContent = matches.length + ' node' +
580
+ }
581
+ return true;
582
+ };
583
+
584
+ GraphApp.prototype.applySearch = function () {
585
+ if (!this.cy) return;
586
+ var status = document.getElementById('obsidian-graph-status');
587
+ var q = normalize(this.searchQuery);
588
+ this.cy.elements().removeClass('faded highlighted');
589
+ if (!q) {
590
+ if (status) status.textContent = '';
591
+ return;
592
+ }
593
+ var terms = q.split(/\s+/).filter(Boolean);
594
+ var app = this;
595
+ var matches = this.visibleElements().nodes().filter(function (n) {
596
+ return app.matchesQuery(n, terms);
597
+ });
598
+ if (matches.length === 0) {
599
+ if (status) status.textContent = 'No nodes match “' + this.searchQuery + '”.';
600
+ return;
601
+ }
602
+ var nhood = matches.closedNeighborhood().not('.obsidian-hidden');
603
+ this.cy.elements().not(nhood).addClass('faded');
604
+ matches.addClass('highlighted');
605
+ if (status) {
606
+ status.textContent = matches.length + ' node' +
438
607
  (matches.length === 1 ? '' : 's') + ' matched.';
439
- cy.fit(matches, 110);
440
608
  }
609
+ this.cy.fit(matches, 110);
610
+ };
611
+
612
+ GraphApp.prototype.updateStats = function () {
613
+ var el = document.getElementById('obsidian-graph-stats');
614
+ if (!el || !this.cy) return;
615
+ var total = this.cy.nodes('[!broken]').length;
616
+ var visible = this.cy.nodes('[!broken]').not('.obsidian-hidden').length;
617
+ var edges = this.visibleElements().edges().length;
618
+ var broken = this.cy.nodes('[?broken]').length;
619
+ var pages = (visible === total) ?
620
+ total + ' pages' : visible + ' of ' + total + ' pages';
621
+ el.innerHTML =
622
+ '<span class="badge text-bg-secondary me-2">' + pages + '</span>' +
623
+ '<span class="badge text-bg-info me-2">' + edges + ' links</span>' +
624
+ (broken > 0 ?
625
+ '<span class="badge text-bg-danger">' + broken + ' broken</span>' :
626
+ '<span class="badge text-bg-success">0 broken</span>');
627
+ };
628
+
629
+ // --- Hover preview card ----------------------------------------------------
630
+ GraphApp.prototype.showPreview = function (node) {
631
+ var card = document.getElementById('obsidian-graph-preview');
632
+ if (!card || node.data('broken')) { this.hidePreview(); return; }
633
+ var d = node.data();
634
+ var links = d.degree === 1 ? '1 link' : (d.degree || 0) + ' links';
635
+ card.innerHTML =
636
+ '<div class="card-body p-2">' +
637
+ '<div class="d-flex align-items-center gap-2 mb-1">' +
638
+ '<span class="obsidian-graph-preview-swatch" aria-hidden="true"></span>' +
639
+ '<strong class="small text-truncate">' + escapeHtml(d.label) + '</strong>' +
640
+ '</div>' +
641
+ '<div class="d-flex gap-2 align-items-center small text-secondary mb-1">' +
642
+ '<span class="badge text-bg-light border">' + escapeHtml(d.collection) + '</span>' +
643
+ '<span>' + links + '</span>' +
644
+ '</div>' +
645
+ (d.excerpt ?
646
+ '<p class="small mb-0 obsidian-graph-preview-excerpt">' + escapeHtml(d.excerpt) + '</p>' : '') +
647
+ '</div>';
648
+ var swatch = card.querySelector('.obsidian-graph-preview-swatch');
649
+ if (swatch) swatch.style.backgroundColor = d.color;
650
+ card.hidden = false;
651
+ // Position near the node, clamped inside the shell.
652
+ var pos = node.renderedPosition();
653
+ var shell = card.parentNode;
654
+ var maxX = shell.clientWidth - card.offsetWidth - 8;
655
+ var maxY = shell.clientHeight - card.offsetHeight - 8;
656
+ card.style.left = Math.max(8, Math.min(pos.x + 16, maxX)) + 'px';
657
+ card.style.top = Math.max(8, Math.min(pos.y + 16, maxY)) + 'px';
658
+ };
441
659
 
442
- input.addEventListener('input', run);
660
+ GraphApp.prototype.hidePreview = function () {
661
+ var card = document.getElementById('obsidian-graph-preview');
662
+ if (card) card.hidden = true;
663
+ };
664
+
665
+ // --- Theme reactivity ------------------------------------------------------
666
+ GraphApp.prototype.watchTheme = function () {
667
+ var app = this;
668
+ var restyle = debounce(function () { app.applyDisplay(); }, 50);
669
+ var observer = new MutationObserver(restyle);
670
+ [document.documentElement, document.body].forEach(function (el) {
671
+ observer.observe(el, { attributes: true, attributeFilter: ['data-bs-theme'] });
672
+ });
673
+ if (window.matchMedia) {
674
+ var mq = window.matchMedia('(prefers-color-scheme: dark)');
675
+ if (mq.addEventListener) mq.addEventListener('change', restyle);
676
+ }
677
+ };
678
+
679
+ // ---------------------------------------------------------------------------
680
+ // Control panel wiring
681
+ // ---------------------------------------------------------------------------
682
+ function bindCheckbox(id, value, onChange) {
683
+ var input = document.getElementById(id);
684
+ if (!input) return;
685
+ input.checked = !!value;
686
+ input.addEventListener('change', function () { onChange(input.checked); });
443
687
  }
444
688
 
445
- function wireFitButton(cy) {
446
- var btn = document.getElementById('obsidian-graph-fit');
447
- if (!btn || !cy) return;
448
- btn.addEventListener('click', function () {
449
- cy.elements().removeClass('faded highlighted');
450
- var input = document.getElementById('obsidian-graph-search');
451
- if (input) input.value = '';
452
- var status = document.getElementById('obsidian-graph-status');
453
- if (status) status.textContent = '';
454
- cy.fit(undefined, 80);
689
+ function bindRange(id, value, onInput) {
690
+ var input = document.getElementById(id);
691
+ if (!input) return;
692
+ input.value = value;
693
+ input.addEventListener('input', function () {
694
+ var n = parseFloat(input.value);
695
+ if (isFinite(n)) onInput(n);
455
696
  });
456
697
  }
457
698
 
458
- function applyOrphansVisibility(cy, show) {
459
- if (!cy) return;
460
- var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
461
- orphans.style('display', show ? 'element' : 'none');
462
- // The initial COSE pass already positioned every node, so just refit the
463
- // visible set — avoids a jarring full relayout of the stable core on toggle.
464
- cy.fit(cy.elements(':visible'), 80);
699
+ function wireCollectionFilters(app) {
700
+ var host = document.getElementById('obsidian-graph-collections');
701
+ if (!host) return;
702
+ var counts = Object.create(null);
703
+ app.entries.forEach(function (entry) {
704
+ var name = entry.collection || 'page';
705
+ counts[name] = (counts[name] || 0) + 1;
706
+ });
707
+ var names = Object.keys(counts).sort(function (a, b) {
708
+ return counts[b] - counts[a] || a.localeCompare(b);
709
+ });
710
+ host.innerHTML = '';
711
+ names.forEach(function (name) {
712
+ var id = 'obsidian-graph-col-' + name.replace(/[^a-z0-9-]/gi, '-');
713
+ var item = document.createElement('div');
714
+ item.className = 'form-check form-switch obsidian-graph-collection-filter';
715
+ item.innerHTML =
716
+ '<input class="form-check-input" type="checkbox" role="switch" id="' + id + '">' +
717
+ '<label class="form-check-label d-flex align-items-center gap-2" for="' + id + '">' +
718
+ '<span class="swatch" aria-hidden="true"></span>' +
719
+ '<span class="flex-grow-1 text-capitalize">' + escapeHtml(name) + '</span>' +
720
+ '<span class="badge text-bg-secondary">' + counts[name] + '</span>' +
721
+ '</label>';
722
+ var input = item.querySelector('input');
723
+ input.checked = app.settings.collections[name] !== false;
724
+ input.addEventListener('change', function () {
725
+ app.settings.collections[name] = input.checked;
726
+ app.applyFilters();
727
+ app.applySearch();
728
+ });
729
+ item.querySelector('.swatch').style.backgroundColor = collectionColor(name, 0.92);
730
+ host.appendChild(item);
731
+ });
465
732
  }
466
733
 
467
- function wireOrphansToggle(cy) {
468
- var toggle = document.getElementById('obsidian-graph-orphans');
469
- if (!toggle || !cy) return;
470
- // Hide orphans by default to match Obsidian's "Show orphans" off state.
471
- applyOrphansVisibility(cy, toggle.checked);
472
- toggle.addEventListener('change', function () {
473
- applyOrphansVisibility(cy, toggle.checked);
734
+ function wireControls(app) {
735
+ var s = app.settings;
736
+
737
+ var search = document.getElementById('obsidian-graph-search');
738
+ if (search) {
739
+ search.addEventListener('input', debounce(function () {
740
+ app.searchQuery = search.value;
741
+ app.applySearch();
742
+ }, 150));
743
+ }
744
+
745
+ wireCollectionFilters(app);
746
+
747
+ bindCheckbox('obsidian-graph-orphans', s.showOrphans, function (checked) {
748
+ s.showOrphans = checked;
749
+ app.applyFilters();
750
+ });
751
+ bindCheckbox('obsidian-graph-broken', s.showBroken, function (checked) {
752
+ s.showBroken = checked;
753
+ app.applyFilters();
754
+ });
755
+ bindCheckbox('obsidian-graph-arrows', s.showArrows, function (checked) {
756
+ s.showArrows = checked;
757
+ app.applyDisplay();
758
+ });
759
+ bindCheckbox('obsidian-graph-animate', s.animateLayout, function (checked) {
760
+ s.animateLayout = checked;
761
+ saveSettings(s);
762
+ });
763
+
764
+ bindRange('obsidian-graph-node-size', s.nodeScale, debounce(function (n) {
765
+ s.nodeScale = clamp(n, 0.5, 2, 1);
766
+ app.applyDisplay();
767
+ }, 80));
768
+ bindRange('obsidian-graph-link-width', s.linkScale, debounce(function (n) {
769
+ s.linkScale = clamp(n, 0.5, 3, 1);
770
+ app.applyDisplay();
771
+ }, 80));
772
+ bindRange('obsidian-graph-label-zoom', s.labelZoom, debounce(function (n) {
773
+ s.labelZoom = clamp(n, 0.4, 2.5, 1.25);
774
+ app.applyDisplay();
775
+ }, 80));
776
+
777
+ var relayout = debounce(function () { app.runLayout(false); }, 350);
778
+ bindRange('obsidian-graph-repulsion', s.repulsion, function (n) {
779
+ s.repulsion = clamp(n, 0.3, 3, 1);
780
+ saveSettings(s);
781
+ relayout();
474
782
  });
783
+ bindRange('obsidian-graph-link-distance', s.linkDistance, function (n) {
784
+ s.linkDistance = clamp(n, 0.4, 2.5, 1);
785
+ saveSettings(s);
786
+ relayout();
787
+ });
788
+
789
+ var rerun = document.getElementById('obsidian-graph-relayout');
790
+ if (rerun) {
791
+ rerun.addEventListener('click', function () { app.runLayout(true); });
792
+ }
793
+
794
+ var reset = document.getElementById('obsidian-graph-reset');
795
+ if (reset) {
796
+ reset.addEventListener('click', function () {
797
+ app.settings = JSON.parse(JSON.stringify(DEFAULTS));
798
+ s = app.settings;
799
+ try { window.localStorage.removeItem(STORAGE_KEY); } catch (e) { /* ignore */ }
800
+ // Reflect defaults back into every control, then re-apply the view.
801
+ syncControls(app);
802
+ wireCollectionFilters(app);
803
+ app.applyDisplay();
804
+ app.applyFilters();
805
+ window.ObsidianGraph.settings = app.settings;
806
+ });
807
+ }
808
+
809
+ var fit = document.getElementById('obsidian-graph-fit');
810
+ if (fit) {
811
+ fit.addEventListener('click', function () {
812
+ app.cy.elements().removeClass('faded highlighted');
813
+ if (search) search.value = '';
814
+ app.searchQuery = '';
815
+ var status = document.getElementById('obsidian-graph-status');
816
+ if (status) status.textContent = '';
817
+ app.cy.fit(app.visibleElements(), 80);
818
+ });
819
+ }
820
+
821
+ function zoomBy(factor) {
822
+ if (!app.cy) return;
823
+ app.cy.animate({
824
+ zoom: {
825
+ level: app.cy.zoom() * factor,
826
+ renderedPosition: { x: app.container.clientWidth / 2, y: app.container.clientHeight / 2 }
827
+ },
828
+ duration: prefersReducedMotion() ? 0 : 120
829
+ });
830
+ }
831
+ var zoomIn = document.getElementById('obsidian-graph-zoom-in');
832
+ if (zoomIn) zoomIn.addEventListener('click', function () { zoomBy(1.35); });
833
+ var zoomOut = document.getElementById('obsidian-graph-zoom-out');
834
+ if (zoomOut) zoomOut.addEventListener('click', function () { zoomBy(1 / 1.35); });
835
+
836
+ wireFullscreen(app);
475
837
  }
476
838
 
477
- function setStats(entries, elements) {
478
- var nodes = elements.filter(function (e) { return e.group === 'nodes'; });
479
- var edges = elements.filter(function (e) { return e.group === 'edges'; });
480
- var broken = nodes.filter(function (e) { return e.data.broken; }).length;
481
- var el = document.getElementById('obsidian-graph-stats');
482
- if (!el) return;
483
- el.innerHTML =
484
- '<span class="badge text-bg-secondary me-2">' + entries.length + ' pages</span>' +
485
- '<span class="badge text-bg-info me-2">' + edges.length + ' links</span>' +
486
- (broken > 0 ?
487
- '<span class="badge text-bg-danger">' + broken + ' broken</span>' :
488
- '<span class="badge text-bg-success">0 broken</span>');
839
+ // Reflect app.settings into the checkbox/range inputs (used by Reset).
840
+ function syncControls(app) {
841
+ var s = app.settings;
842
+ var map = {
843
+ 'obsidian-graph-orphans': s.showOrphans,
844
+ 'obsidian-graph-broken': s.showBroken,
845
+ 'obsidian-graph-arrows': s.showArrows,
846
+ 'obsidian-graph-animate': s.animateLayout
847
+ };
848
+ Object.keys(map).forEach(function (id) {
849
+ var el = document.getElementById(id);
850
+ if (el) el.checked = !!map[id];
851
+ });
852
+ var ranges = {
853
+ 'obsidian-graph-node-size': s.nodeScale,
854
+ 'obsidian-graph-link-width': s.linkScale,
855
+ 'obsidian-graph-label-zoom': s.labelZoom,
856
+ 'obsidian-graph-repulsion': s.repulsion,
857
+ 'obsidian-graph-link-distance': s.linkDistance
858
+ };
859
+ Object.keys(ranges).forEach(function (id) {
860
+ var el = document.getElementById(id);
861
+ if (el) el.value = ranges[id];
862
+ });
863
+ }
864
+
865
+ function wireFullscreen(app) {
866
+ var btn = document.getElementById('obsidian-graph-fullscreen');
867
+ var shell = document.getElementById('obsidian-graph-shell');
868
+ if (!btn || !shell) return;
869
+
870
+ function isFullscreen() {
871
+ return document.fullscreenElement === shell ||
872
+ shell.classList.contains('obsidian-graph-shell-expanded');
873
+ }
874
+
875
+ function refresh() {
876
+ var active = isFullscreen();
877
+ btn.setAttribute('aria-pressed', active ? 'true' : 'false');
878
+ var icon = btn.querySelector('.bi');
879
+ if (icon) {
880
+ icon.className = active ?
881
+ 'bi bi-fullscreen-exit' : 'bi bi-arrows-fullscreen';
882
+ }
883
+ if (app.cy) {
884
+ app.cy.resize();
885
+ app.cy.fit(app.visibleElements(), 80);
886
+ }
887
+ }
888
+
889
+ btn.addEventListener('click', function () {
890
+ if (isFullscreen()) {
891
+ if (document.fullscreenElement === shell && document.exitFullscreen) {
892
+ document.exitFullscreen();
893
+ }
894
+ shell.classList.remove('obsidian-graph-shell-expanded');
895
+ refresh();
896
+ return;
897
+ }
898
+ if (shell.requestFullscreen) {
899
+ shell.requestFullscreen().catch(function () {
900
+ // Fullscreen API blocked (iframe / permission) — CSS overlay fallback.
901
+ shell.classList.add('obsidian-graph-shell-expanded');
902
+ refresh();
903
+ });
904
+ } else {
905
+ shell.classList.add('obsidian-graph-shell-expanded');
906
+ refresh();
907
+ }
908
+ });
909
+ document.addEventListener('fullscreenchange', refresh);
910
+ document.addEventListener('keydown', function (evt) {
911
+ if (evt.key === 'Escape' && shell.classList.contains('obsidian-graph-shell-expanded')) {
912
+ shell.classList.remove('obsidian-graph-shell-expanded');
913
+ refresh();
914
+ }
915
+ });
489
916
  }
490
917
 
918
+ // ---------------------------------------------------------------------------
491
919
  function init() {
492
920
  var container = document.getElementById(CONTAINER_ID);
493
921
  if (!container) return;
@@ -505,8 +933,6 @@
505
933
  .then(function (payload) {
506
934
  var entries = (payload && payload.entries) || [];
507
935
  var byKey = buildLookup(entries);
508
- var elements = buildElements(entries, byKey);
509
- computeNodeDegree(elements);
510
936
  loadCytoscape(function (ok) {
511
937
  if (!ok) {
512
938
  container.innerHTML =
@@ -517,12 +943,24 @@
517
943
  return;
518
944
  }
519
945
  container.innerHTML = '';
520
- setStats(entries, elements);
521
- var cy = renderGraph(container, elements);
522
- wireSearch(cy, byKey);
523
- wireFitButton(cy);
524
- wireOrphansToggle(cy);
525
- window.ObsidianGraph = { cy: cy, byKey: byKey, entries: entries };
946
+ var app = new GraphApp(container, entries, byKey);
947
+ app.elements = buildElements(entries, byKey);
948
+ computeNodeDegree(app.elements);
949
+ // Apply the persisted node-size multiplier before first paint.
950
+ app.elements.forEach(function (el) {
951
+ if (el.group === 'nodes') el.data.size *= app.settings.nodeScale;
952
+ });
953
+ app.render();
954
+ app.updateStats();
955
+ wireControls(app);
956
+ app.watchTheme();
957
+ window.ObsidianGraph = {
958
+ cy: app.cy,
959
+ byKey: byKey,
960
+ entries: entries,
961
+ settings: app.settings,
962
+ app: app
963
+ };
526
964
  });
527
965
  })
528
966
  .catch(function (err) {