jekyll-theme-zer0 1.26.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 (111) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +558 -5
  3. data/README.md +10 -27
  4. data/_data/authors.yml +4 -3
  5. data/_data/backlog.yml +28 -0
  6. data/_data/consumers.yml +147 -0
  7. data/_data/features.yml +142 -23
  8. data/_data/i18n/fr.yml +98 -0
  9. data/_data/i18n/languages.yml +36 -0
  10. data/_data/i18n/manifest.yml +1502 -0
  11. data/_data/navigation/docs.yml +2 -0
  12. data/_data/navigation/main.yml +6 -0
  13. data/_data/series.yml +19 -0
  14. data/_data/theme-manifest.yml +348 -268
  15. data/_data/ui-text.yml +36 -246
  16. data/_includes/README.md +25 -2
  17. data/_includes/analytics/google-tag-manager-body.html +11 -2
  18. data/_includes/analytics/google-tag-manager-head.html +16 -6
  19. data/_includes/analytics/posthog.html +33 -4
  20. data/_includes/components/abc-letter.html +43 -0
  21. data/_includes/components/author-avatar-url.html +4 -2
  22. data/_includes/components/book-card.html +42 -0
  23. data/_includes/components/book-nav.html +80 -0
  24. data/_includes/components/book-plate.html +31 -0
  25. data/_includes/components/book-toc.html +47 -0
  26. data/_includes/components/bookshelf.html +68 -0
  27. data/_includes/components/card-grid.html +60 -0
  28. data/_includes/components/data-card.html +95 -0
  29. data/_includes/components/env-switcher.html +3 -1
  30. data/_includes/components/halfmoon.html +5 -1
  31. data/_includes/components/language-toggle.html +81 -0
  32. data/_includes/components/page-feedback.html +65 -2
  33. data/_includes/components/search-modal.html +2 -2
  34. data/_includes/components/shortcuts-modal.html +1 -1
  35. data/_includes/components/theme-controls-bar.html +10 -2
  36. data/_includes/components/theme-customizer.html +8 -2
  37. data/_includes/components/translation-notice.html +27 -0
  38. data/_includes/content/intro.html +16 -15
  39. data/_includes/content/seo.html +9 -3
  40. data/_includes/core/color-mode-init.html +13 -4
  41. data/_includes/core/favicon.html +46 -0
  42. data/_includes/core/footer.html +9 -4
  43. data/_includes/core/head.html +18 -0
  44. data/_includes/core/header.html +9 -4
  45. data/_includes/core/hreflang.html +33 -0
  46. data/_includes/core/i18n.html +36 -0
  47. data/_includes/custom/body-end.html +18 -0
  48. data/_includes/custom/body-start.html +17 -0
  49. data/_includes/custom/footer.html +18 -0
  50. data/_includes/custom/head.html +18 -0
  51. data/_includes/navigation/breadcrumbs.html +1 -1
  52. data/_includes/navigation/local-graph.html +28 -2
  53. data/_includes/navigation/nav-tree.html +3 -3
  54. data/_includes/navigation/navbar.html +5 -4
  55. data/_includes/navigation/sidebar-config.html +21 -0
  56. data/_includes/navigation/sidebar-nav.html +4 -0
  57. data/_includes/navigation/sidebar-pagetree.html +150 -0
  58. data/_includes/navigation/sidebar-right.html +3 -2
  59. data/_includes/navigation/unified-drawer.html +9 -3
  60. data/_includes/obsidian/full-graph.html +165 -136
  61. data/_layouts/404.html +260 -0
  62. data/_layouts/README.md +2 -0
  63. data/_layouts/article.html +7 -1
  64. data/_layouts/book-abc.html +106 -0
  65. data/_layouts/book-story.html +91 -0
  66. data/_layouts/book.html +113 -0
  67. data/_layouts/collection.html +17 -5
  68. data/_layouts/default.html +5 -3
  69. data/_layouts/home.html +5 -2
  70. data/_layouts/landing.html +9 -0
  71. data/_layouts/news.html +159 -33
  72. data/_layouts/root.html +29 -9
  73. data/_layouts/section.html +61 -17
  74. data/_sass/components/_book.scss +423 -0
  75. data/_sass/core/_navbar.scss +13 -0
  76. data/_sass/core/_obsidian.scss +295 -7
  77. data/_sass/layouts/_navbar-extras.scss +6 -1
  78. data/_sass/theme/_backgrounds.scss +21 -8
  79. data/assets/css/main.scss +1 -0
  80. data/assets/js/auto-hide-nav.js +5 -1
  81. data/assets/js/halfmoon.js +26 -0
  82. data/assets/js/obsidian-graph.js +707 -265
  83. data/assets/js/obsidian-local-graph.js +161 -54
  84. data/assets/js/search-modal.js +4 -1
  85. data/scripts/README.md +20 -26
  86. data/scripts/bin/audit-consumer +1 -1
  87. data/scripts/bin/manifest +31 -5
  88. data/scripts/bin/sync-plugins +0 -1
  89. data/scripts/bin/validate +5 -1
  90. data/scripts/dev/rasterize-svg.js +65 -0
  91. data/scripts/features/generate-preview-images +49 -1390
  92. data/scripts/features/install-preview-generator +55 -33
  93. data/scripts/install/README.md +55 -25
  94. data/scripts/install/ai/client.sh +302 -93
  95. data/scripts/install/ai/prompts/spec.schema.json +1 -1
  96. data/scripts/install/ai/prompts/wizard.system.md +8 -17
  97. data/scripts/install/ai/wizard.sh +10 -5
  98. data/scripts/install/apply.sh +7 -3
  99. data/scripts/install/cli.sh +54 -4
  100. data/scripts/install/config.sh +167 -0
  101. data/scripts/install/doctor.sh +38 -0
  102. data/scripts/install/plan.sh +10 -0
  103. data/scripts/install/spec.sh +15 -7
  104. data/scripts/install/template.sh +4 -0
  105. data/scripts/lib/README.md +1 -5
  106. data/scripts/lib/install/deploy/README.md +3 -9
  107. data/scripts/lib/preview_generator.py +2261 -1341
  108. data/scripts/propagate.rb +277 -0
  109. data/scripts/translate.rb +1204 -0
  110. metadata +34 -3
  111. data/_plugins/preview_image_generator.rb +0 -351
@@ -19,18 +19,86 @@
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';
31
- var INDEX_URL = (window.OBSIDIAN_WIKI_INDEX_URL ||
39
+ var STORAGE_KEY = 'zer0.obsidianGraph.v1';
40
+ // Index URL resolution mirrors obsidian-wiki-links.js: the baseurl-aware
41
+ // value js-cdn.html emits (OBSIDIAN_CONFIG.wikiIndexUrl), then the legacy
42
+ // per-page override global, then a <base>-relative fallback.
43
+ var INDEX_URL = ((window.OBSIDIAN_CONFIG || {}).wikiIndexUrl ||
44
+ window.OBSIDIAN_WIKI_INDEX_URL ||
32
45
  ((document.querySelector('base') || {}).href || '/') + 'assets/data/wiki-index.json');
33
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
+
34
102
  function normalize(value) {
35
103
  return String(value || '').toLowerCase().trim().replace(/\s+/g, ' ');
36
104
  }
@@ -39,6 +107,22 @@
39
107
  return !!(window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches);
40
108
  }
41
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
+
42
126
  // Cytoscape is vendored under assets/vendor/ (no runtime CDN). Path comes from
43
127
  // window.OBSIDIAN_CONFIG.cytoscapeUrl (set by Liquid), with a base fallback.
44
128
  function cytoscapeSrc() {
@@ -95,27 +179,7 @@
95
179
  }
96
180
 
97
181
  // Cytoscape needs concrete color values (not CSS vars). readTheme()
98
- // resolves light/dark at init time.
99
- // Shared cose layout — looser packing, more edge length, wider padding.
100
- var COSE_LAYOUT = {
101
- name: 'cose',
102
- animate: false,
103
- randomize: true,
104
- nodeRepulsion: function () { return 32000; },
105
- idealEdgeLength: function () { return 190; },
106
- edgeElasticity: function () { return 60; },
107
- nodeOverlap: 40,
108
- gravity: 0.1,
109
- nestingFactor: 1.5,
110
- numIter: 3000,
111
- padding: 80,
112
- componentSpacing: 140
113
- };
114
-
115
- // Labels appear when zoomed past this level or on hover/highlight.
116
- var LABEL_ZOOM_THRESHOLD = 1.25;
117
- var HUB_DEGREE_THRESHOLD = 12;
118
-
182
+ // resolves light/dark from the live Bootstrap color mode.
119
183
  function readTheme() {
120
184
  var attr = (document.documentElement.getAttribute('data-bs-theme') ||
121
185
  document.body.getAttribute('data-bs-theme') || '').toLowerCase();
@@ -126,56 +190,63 @@
126
190
  labelOutline: 'rgba(13,17,23,0.88)',
127
191
  edge: 'rgba(173,181,189,0.28)',
128
192
  edgeArrow: 'rgba(173,181,189,0.42)',
129
- canvasBg: 'transparent',
130
193
  nodeBorder: 'rgba(255,255,255,0.18)'
131
194
  } : {
132
195
  label: 'rgba(27,31,35,0.92)',
133
196
  labelOutline: 'rgba(248,249,250,0.90)',
134
197
  edge: 'rgba(73,80,87,0.22)',
135
198
  edgeArrow: 'rgba(73,80,87,0.38)',
136
- canvasBg: 'transparent',
137
199
  nodeBorder: 'rgba(0,0,0,0.14)'
138
200
  };
139
201
  }
140
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
+
141
216
  function collectionColor(name, alpha) {
142
217
  alpha = alpha == null ? 0.72 : alpha;
143
- // Stable, distinguishable palette per collection. Falls back for pages.
144
- var palette = {
145
- posts: [13, 110, 253],
146
- docs: [25, 135, 84],
147
- notes: [111, 66, 193],
148
- notebooks: [214, 51, 132],
149
- quickstart: [253, 126, 20],
150
- about: [32, 201, 151],
151
- hobbies: [255, 193, 7],
152
- news: [102, 16, 242],
153
- services: [13, 202, 240]
154
- };
155
- var rgb = palette[name] || [108, 117, 125];
218
+ var rgb = COLLECTION_PALETTE[name] || [108, 117, 125];
156
219
  return 'rgba(' + rgb[0] + ',' + rgb[1] + ',' + rgb[2] + ',' + alpha + ')';
157
220
  }
158
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
+
159
229
  function buildElements(entries, byKey) {
160
230
  var elements = [];
161
- var seenTargets = Object.create(null);
162
231
  var brokenIds = Object.create(null);
163
232
 
164
233
  entries.forEach(function (entry) {
165
- var nid = entry.url;
166
234
  elements.push({
167
235
  group: 'nodes',
168
236
  data: {
169
- id: nid,
170
- label: entry.title || entry.basename || nid,
237
+ id: entry.url,
238
+ label: entry.title || entry.basename || entry.url,
171
239
  url: entry.url,
172
240
  collection: entry.collection || 'page',
173
241
  color: collectionColor(entry.collection),
174
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(' | '),
175
247
  broken: false
176
248
  }
177
249
  });
178
- seenTargets[nid] = true;
179
250
  });
180
251
 
181
252
  entries.forEach(function (entry) {
@@ -199,6 +270,8 @@
199
270
  collection: 'broken',
200
271
  color: 'rgba(220,53,69,0.78)',
201
272
  excerpt: 'Unresolved wiki-link',
273
+ tags: [],
274
+ aliasText: '',
202
275
  broken: true
203
276
  }
204
277
  });
@@ -229,261 +302,620 @@
229
302
  elements.forEach(function (el) {
230
303
  if (el.group !== 'nodes') return;
231
304
  el.data.degree = degree[el.data.id] || 0;
305
+ el.data.size = baseNodeSize(el.data.degree);
232
306
  });
233
307
  }
234
308
 
235
- function updateLabelVisibility(cy) {
236
- if (!cy) return;
237
- var show = cy.zoom() >= LABEL_ZOOM_THRESHOLD;
238
- cy.nodes().forEach(function (node) {
239
- if (node.hasClass('highlighted')) return;
240
- if ((node.data('degree') || 0) >= HUB_DEGREE_THRESHOLD) return;
241
- node.style('text-opacity', show ? 0.9 : 0);
242
- });
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 = '';
243
320
  }
244
321
 
245
- function renderGraph(container, elements) {
246
- if (typeof window.cytoscape !== 'function') {
247
- container.innerHTML =
248
- '<div class="alert alert-danger" role="alert">' +
249
- 'Graph view failed to load: <code>cytoscape</code> library is not ' +
250
- 'available. Check your network connection or content security policy.' +
251
- '</div>';
252
- return null;
253
- }
254
-
322
+ GraphApp.prototype.buildStyle = function () {
255
323
  var theme = readTheme();
256
- container.style.backgroundColor = theme.canvasBg;
257
- var nodeDur = prefersReducedMotion() ? '0ms' : '160ms';
258
- 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
+ };
259
439
 
260
- var cy = window.cytoscape({
261
- container: container,
262
- elements: elements,
440
+ GraphApp.prototype.render = function () {
441
+ var app = this;
442
+ this.cy = window.cytoscape({
443
+ container: this.container,
444
+ elements: this.elements,
263
445
  minZoom: 0.1,
264
446
  maxZoom: 4,
265
- style: [
266
- {
267
- selector: 'node',
268
- style: {
269
- 'background-color': 'data(color)',
270
- 'background-opacity': 0.82,
271
- 'label': 'data(label)',
272
- 'font-size': '10px',
273
- 'font-weight': 500,
274
- 'color': theme.label,
275
- 'text-outline-color': theme.labelOutline,
276
- 'text-outline-width': 3,
277
- 'text-outline-opacity': 1,
278
- 'text-background-opacity': 0,
279
- 'text-border-opacity': 0,
280
- 'text-valign': 'bottom',
281
- 'text-margin-y': 6,
282
- 'text-wrap': 'ellipsis',
283
- 'text-max-width': '120px',
284
- // Hidden until zoomed-in, hovered, or a high-degree hub.
285
- 'min-zoomed-font-size': 11,
286
- 'text-opacity': 0,
287
- 'width': 'mapData(degree, 0, 25, 8, 34)',
288
- 'height': 'mapData(degree, 0, 25, 8, 34)',
289
- 'border-width': 1,
290
- 'border-color': theme.nodeBorder,
291
- 'opacity': 0.88,
292
- 'transition-property': 'background-color, border-color, width, height, text-opacity, opacity',
293
- 'transition-duration': nodeDur
294
- }
295
- },
296
- {
297
- // Hub landmarks stay labeled when zoomed out.
298
- selector: 'node[degree >= ' + HUB_DEGREE_THRESHOLD + ']',
299
- style: {
300
- 'text-opacity': 0.88,
301
- 'font-size': '11px',
302
- 'font-weight': 600,
303
- 'min-zoomed-font-size': 0
304
- }
305
- },
306
- {
307
- selector: 'node[?broken]',
308
- style: {
309
- 'border-style': 'dashed',
310
- 'border-color': 'rgba(220,53,69,0.75)',
311
- 'border-width': 1.5,
312
- 'background-opacity': 0.55,
313
- 'opacity': 0.78
314
- }
315
- },
316
- {
317
- selector: 'edge',
318
- style: {
319
- 'curve-style': 'bezier',
320
- 'width': 1,
321
- 'line-color': theme.edge,
322
- 'target-arrow-color': theme.edgeArrow,
323
- 'target-arrow-shape': 'triangle',
324
- 'arrow-scale': 0.75,
325
- 'opacity': 0.55,
326
- 'transition-property': 'line-color, width, opacity',
327
- 'transition-duration': edgeDur
328
- }
329
- },
330
- {
331
- selector: 'edge[?broken]',
332
- style: {
333
- 'line-color': 'rgba(220,53,69,0.32)',
334
- 'target-arrow-color': 'rgba(220,53,69,0.42)',
335
- 'line-style': 'dashed',
336
- 'opacity': 0.45
337
- }
338
- },
339
- {
340
- selector: '.faded',
341
- style: {
342
- 'opacity': 0.08,
343
- 'text-opacity': 0
344
- }
345
- },
346
- {
347
- selector: 'node.highlighted',
348
- style: {
349
- 'border-color': 'rgba(253,126,20,0.95)',
350
- 'border-width': 2.5,
351
- 'opacity': 1,
352
- 'background-opacity': 0.95,
353
- 'text-opacity': 1,
354
- 'font-size': '11px',
355
- 'font-weight': 700,
356
- 'min-zoomed-font-size': 0,
357
- 'z-index': 9999
358
- }
359
- },
360
- {
361
- selector: 'edge.highlighted',
362
- style: {
363
- 'line-color': 'rgba(253,126,20,0.85)',
364
- 'target-arrow-color': 'rgba(253,126,20,0.85)',
365
- 'width': 2,
366
- 'opacity': 0.95,
367
- 'z-index': 9999
368
- }
369
- }
370
- ],
371
- layout: COSE_LAYOUT
447
+ wheelSensitivity: 0.3,
448
+ style: this.buildStyle(),
449
+ layout: { name: 'preset' } // real layout runs below, honouring settings
372
450
  });
373
451
 
374
- cy.on('tap', 'node', function (evt) {
375
- var node = evt.target;
376
- var url = node.data('url');
377
- if (url) {
378
- // Same-tab navigation; use Cmd/Ctrl-click for new tab via the
379
- // standard handler below.
380
- var native = evt.originalEvent;
381
- if (native && (native.metaKey || native.ctrlKey)) {
382
- window.open(url, '_blank', 'noopener');
383
- } else {
384
- window.location.href = url;
385
- }
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;
386
460
  }
387
461
  });
388
462
 
389
- // Hover: highlight neighborhood, fade everything else.
390
- cy.on('mouseover', 'node', function (evt) {
463
+ // Hover: highlight neighborhood, fade everything else, show the preview.
464
+ this.cy.on('mouseover', 'node', function (evt) {
391
465
  var node = evt.target;
392
466
  var nhood = node.closedNeighborhood();
393
- cy.elements().not(nhood).addClass('faded');
467
+ app.cy.elements().not(nhood).addClass('faded');
394
468
  nhood.addClass('highlighted');
469
+ app.showPreview(node);
395
470
  });
396
- cy.on('mouseout', 'node', function () {
397
- cy.elements().removeClass('faded highlighted');
398
- 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
399
476
  });
400
-
401
- cy.on('zoom', function () {
402
- updateLabelVisibility(cy);
477
+ this.cy.on('zoom pan', function () {
478
+ app.hidePreview();
479
+ app.updateLabelVisibility();
403
480
  });
481
+ this.cy.on('grab', 'node', function () { app.hidePreview(); });
482
+
483
+ this.applyFilters(false);
484
+ this.runLayout(true);
485
+ this.updateLabelVisibility();
486
+ };
404
487
 
405
- updateLabelVisibility(cy);
488
+ GraphApp.prototype.visibleElements = function () {
489
+ return this.cy.elements().not('.obsidian-hidden');
490
+ };
406
491
 
407
- return cy;
408
- }
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
+ };
409
513
 
410
- function wireSearch(cy, byKey) {
411
- var input = document.getElementById('obsidian-graph-search');
412
- var status = document.getElementById('obsidian-graph-status');
413
- 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
+ };
414
527
 
415
- function run() {
416
- var q = normalize(input.value);
417
- cy.elements().removeClass('faded highlighted');
418
- if (!q) {
419
- if (status) status.textContent = '';
420
- cy.fit(undefined, 80);
421
- return;
422
- }
423
- var matches = cy.nodes().filter(function (n) {
424
- 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);
425
543
  });
426
- if (matches.length === 0) {
427
- if (status) status.textContent = 'No nodes match “' + input.value + '”.';
428
- 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;
429
579
  }
430
- var nhood = matches.closedNeighborhood();
431
- cy.elements().not(nhood).addClass('faded');
432
- matches.addClass('highlighted');
433
- 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' +
434
607
  (matches.length === 1 ? '' : 's') + ' matched.';
435
- cy.fit(matches, 110);
436
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
+ };
437
659
 
438
- 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); });
439
687
  }
440
688
 
441
- function wireFitButton(cy) {
442
- var btn = document.getElementById('obsidian-graph-fit');
443
- if (!btn || !cy) return;
444
- btn.addEventListener('click', function () {
445
- cy.elements().removeClass('faded highlighted');
446
- var input = document.getElementById('obsidian-graph-search');
447
- if (input) input.value = '';
448
- var status = document.getElementById('obsidian-graph-status');
449
- if (status) status.textContent = '';
450
- 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);
451
696
  });
452
697
  }
453
698
 
454
- function applyOrphansVisibility(cy, show) {
455
- if (!cy) return;
456
- var orphans = cy.nodes().filter(function (n) { return n.degree(false) === 0; });
457
- orphans.style('display', show ? 'element' : 'none');
458
- // The initial COSE pass already positioned every node, so just refit the
459
- // visible set — avoids a jarring full relayout of the stable core on toggle.
460
- 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
+ });
461
732
  }
462
733
 
463
- function wireOrphansToggle(cy) {
464
- var toggle = document.getElementById('obsidian-graph-orphans');
465
- if (!toggle || !cy) return;
466
- // Hide orphans by default to match Obsidian's "Show orphans" off state.
467
- applyOrphansVisibility(cy, toggle.checked);
468
- toggle.addEventListener('change', function () {
469
- 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();
470
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);
471
837
  }
472
838
 
473
- function setStats(entries, elements) {
474
- var nodes = elements.filter(function (e) { return e.group === 'nodes'; });
475
- var edges = elements.filter(function (e) { return e.group === 'edges'; });
476
- var broken = nodes.filter(function (e) { return e.data.broken; }).length;
477
- var el = document.getElementById('obsidian-graph-stats');
478
- if (!el) return;
479
- el.innerHTML =
480
- '<span class="badge text-bg-secondary me-2">' + entries.length + ' pages</span>' +
481
- '<span class="badge text-bg-info me-2">' + edges.length + ' links</span>' +
482
- (broken > 0 ?
483
- '<span class="badge text-bg-danger">' + broken + ' broken</span>' :
484
- '<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
+ });
485
916
  }
486
917
 
918
+ // ---------------------------------------------------------------------------
487
919
  function init() {
488
920
  var container = document.getElementById(CONTAINER_ID);
489
921
  if (!container) return;
@@ -501,8 +933,6 @@
501
933
  .then(function (payload) {
502
934
  var entries = (payload && payload.entries) || [];
503
935
  var byKey = buildLookup(entries);
504
- var elements = buildElements(entries, byKey);
505
- computeNodeDegree(elements);
506
936
  loadCytoscape(function (ok) {
507
937
  if (!ok) {
508
938
  container.innerHTML =
@@ -513,12 +943,24 @@
513
943
  return;
514
944
  }
515
945
  container.innerHTML = '';
516
- setStats(entries, elements);
517
- var cy = renderGraph(container, elements);
518
- wireSearch(cy, byKey);
519
- wireFitButton(cy);
520
- wireOrphansToggle(cy);
521
- 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
+ };
522
964
  });
523
965
  })
524
966
  .catch(function (err) {