@reqlan/analytical 0.6.2 → 0.7.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 (43) hide show
  1. package/README.md +17 -0
  2. package/out/analysis-api.d.ts +2 -2
  3. package/out/analysis-api.js +4 -3
  4. package/out/analysis-api.js.map +1 -1
  5. package/out/core/filter-specials.d.ts +35 -0
  6. package/out/core/filter-specials.js +98 -0
  7. package/out/core/filter-specials.js.map +1 -0
  8. package/out/core/types.d.ts +10 -0
  9. package/out/core/types.js.map +1 -1
  10. package/out/export/build-export-snapshot.d.ts +2 -2
  11. package/out/export/build-export-snapshot.js +43 -16
  12. package/out/export/build-export-snapshot.js.map +1 -1
  13. package/out/export/export-html.d.ts +2 -2
  14. package/out/export/export-html.js +7 -3
  15. package/out/export/export-html.js.map +1 -1
  16. package/out/export/html-export-assets.d.ts +1 -1
  17. package/out/export/html-export-assets.js +580 -33
  18. package/out/export/html-export-assets.js.map +1 -1
  19. package/out/export/html-export-template.js +71 -14
  20. package/out/export/html-export-template.js.map +1 -1
  21. package/out/export/html-export-utils.js +15 -5
  22. package/out/export/html-export-utils.js.map +1 -1
  23. package/out/export/types.d.ts +19 -0
  24. package/out/export/write-html-export.d.ts +2 -2
  25. package/out/export/write-html-export.js +61 -29
  26. package/out/export/write-html-export.js.map +1 -1
  27. package/out/graph/physics-core-source.d.ts +5 -0
  28. package/out/graph/physics-core-source.js +8 -0
  29. package/out/graph/physics-core-source.js.map +1 -0
  30. package/out/index-store/index-diagnostics-store.js +1 -2
  31. package/out/index-store/index-diagnostics-store.js.map +1 -1
  32. package/out/index-store/sqlite-store.js +4 -2
  33. package/out/index-store/sqlite-store.js.map +1 -1
  34. package/out/index-store/webview-graph-queries.d.ts +8 -2
  35. package/out/index-store/webview-graph-queries.js +77 -14
  36. package/out/index-store/webview-graph-queries.js.map +1 -1
  37. package/out/index-store/workspace-index.d.ts +2 -1
  38. package/out/index-store/workspace-index.js +4 -1
  39. package/out/index-store/workspace-index.js.map +1 -1
  40. package/out/index.d.ts +3 -1
  41. package/out/index.js +1 -0
  42. package/out/index.js.map +1 -1
  43. package/package.json +7 -3
@@ -1,8 +1,13 @@
1
- import { readFileSync } from 'node:fs';
2
- /** Embed shared physics core into the classic-script export app.js (strip ESM exports). */
1
+ import { PHYSICS_CORE_CLASSIC_SOURCE } from '../graph/physics-core-source.js';
2
+ /**
3
+ * physics-core.js with `export` keywords stripped, inlined at build time by
4
+ * scripts/generate-physics-core-source.mjs. Using a pre-generated string
5
+ * constant rather than readFileSync(import.meta.url) keeps this bundler-safe:
6
+ * esbuild with format cjs + target es2017 empties import.meta.url, and the
7
+ * source file does not exist on disk relative to the bundled main.cjs anyway.
8
+ */
3
9
  function embedPhysicsCoreSource() {
4
- return readFileSync(new URL('../graph/physics-core.js', import.meta.url), 'utf8')
5
- .replace(/^export\s+/gm, '');
10
+ return PHYSICS_CORE_CLASSIC_SOURCE;
6
11
  }
7
12
  export const SHARED_STYLES = `
8
13
  :root {
@@ -127,6 +132,165 @@ a.pill:hover { color: var(--accent-strong); border-color: color-mix(in srgb, var
127
132
  flex: 1 1 180px;
128
133
  min-width: 160px;
129
134
  }
135
+ .scd {
136
+ position: relative;
137
+ flex: 1 1 180px;
138
+ min-width: 160px;
139
+ }
140
+ .scd-trigger {
141
+ display: flex;
142
+ align-items: center;
143
+ justify-content: space-between;
144
+ gap: 0.55rem;
145
+ width: 100%;
146
+ min-height: 2.35rem;
147
+ padding: 0.4rem 0.75rem;
148
+ border: 1px solid var(--line);
149
+ border-radius: 8px;
150
+ background: var(--bg-soft);
151
+ color: var(--fg);
152
+ font: inherit;
153
+ cursor: pointer;
154
+ text-align: left;
155
+ }
156
+ .scd.is-open .scd-trigger,
157
+ .scd-trigger:hover {
158
+ border-color: var(--accent-dim);
159
+ }
160
+ .scd.has-selection .scd-trigger {
161
+ border-color: color-mix(in srgb, var(--accent) 45%, var(--line));
162
+ }
163
+ .scd.is-searching .scd-trigger {
164
+ border-color: var(--line);
165
+ }
166
+ .scd.is-loading .scd-trigger {
167
+ opacity: 0.72;
168
+ }
169
+ .scd-trigger-label {
170
+ overflow: hidden;
171
+ text-overflow: ellipsis;
172
+ white-space: nowrap;
173
+ }
174
+ .scd-chevron {
175
+ width: 0;
176
+ height: 0;
177
+ border-left: 4px solid transparent;
178
+ border-right: 4px solid transparent;
179
+ border-top: 5px solid currentColor;
180
+ opacity: 0.7;
181
+ flex: 0 0 auto;
182
+ }
183
+ .scd.is-open .scd-chevron { transform: rotate(180deg); }
184
+ .scd-panel {
185
+ position: absolute;
186
+ z-index: 50;
187
+ top: calc(100% + 0.35rem);
188
+ left: 0;
189
+ right: auto;
190
+ width: max(100%, 15rem);
191
+ max-width: min(20rem, 80vw);
192
+ max-height: 18rem;
193
+ display: flex;
194
+ flex-direction: column;
195
+ border: 1px solid var(--line);
196
+ border-radius: 10px;
197
+ background: var(--bg-elev);
198
+ box-shadow: 0 16px 40px rgba(0, 0, 0, 0.35);
199
+ overflow: hidden;
200
+ }
201
+ /* Author display:flex overrides the UA [hidden] rule; keep closed panels invisible. */
202
+ .scd-panel[hidden] {
203
+ display: none;
204
+ }
205
+ .scd-search-row {
206
+ display: flex;
207
+ gap: 0.45rem;
208
+ padding: 0.65rem;
209
+ border-bottom: 1px solid var(--line);
210
+ opacity: 0.72;
211
+ }
212
+ .scd-search-row.is-active { opacity: 1; }
213
+ .scd-search {
214
+ flex: 1;
215
+ min-width: 0;
216
+ padding: 0.4rem 0.55rem;
217
+ border: 1px solid transparent;
218
+ border-radius: 6px;
219
+ background: color-mix(in srgb, var(--bg) 88%, transparent);
220
+ color: var(--muted);
221
+ font: inherit;
222
+ }
223
+ .scd.is-searching .scd-search,
224
+ .scd-search:focus {
225
+ border-color: var(--accent-dim);
226
+ background: var(--bg);
227
+ color: var(--fg);
228
+ outline: none;
229
+ }
230
+ .scd-clear {
231
+ border: none;
232
+ background: transparent;
233
+ color: var(--accent-strong);
234
+ font: inherit;
235
+ cursor: pointer;
236
+ }
237
+ .scd-list {
238
+ overflow: auto;
239
+ padding: 0.25rem 0 0.55rem;
240
+ }
241
+ .scd-group-label {
242
+ padding: 0.55rem 0.85rem 0.25rem;
243
+ font-size: 0.72rem;
244
+ letter-spacing: 0.06em;
245
+ text-transform: uppercase;
246
+ color: var(--muted);
247
+ }
248
+ .scd-option {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 0.55rem;
252
+ padding: 0.45rem 0.85rem;
253
+ cursor: pointer;
254
+ color: var(--fg);
255
+ }
256
+ .scd-option:hover { background: color-mix(in srgb, var(--accent) 10%, transparent); }
257
+ .scd-option input { margin: 0; }
258
+ .scd-option-label {
259
+ flex: 1;
260
+ min-width: 0;
261
+ overflow: hidden;
262
+ text-overflow: ellipsis;
263
+ white-space: nowrap;
264
+ }
265
+ .scd-option-count {
266
+ flex: 0 0 auto;
267
+ font-variant-numeric: tabular-nums;
268
+ font-size: 0.85em;
269
+ color: var(--muted);
270
+ opacity: 0.65;
271
+ }
272
+ .scd-option.is-special .scd-option-label { font-style: italic; }
273
+ .scd-option.is-not-present .scd-option-label { color: var(--faint); }
274
+ .scd-option.is-empty .scd-option-label { color: var(--accent-strong); }
275
+ .scd-option.is-unspecified .scd-option-label { color: var(--rust-muted); }
276
+ .scd-empty {
277
+ margin: 0;
278
+ padding: 0.85rem;
279
+ color: var(--muted);
280
+ font-size: 0.9rem;
281
+ }
282
+ .rollup-list .rollup-special dt {
283
+ font-style: italic;
284
+ }
285
+ .rollup-list .rollup-not-present dt {
286
+ color: var(--faint);
287
+ }
288
+ .rollup-list .rollup-empty dt {
289
+ color: var(--accent-strong);
290
+ }
291
+ .rollup-list .rollup-unspecified dt {
292
+ color: var(--rust-muted);
293
+ }
130
294
  .graph-action {
131
295
  border: 1px solid var(--line);
132
296
  background: var(--bg-soft);
@@ -269,6 +433,37 @@ body[data-runtime-mode="interactive"] .scroll-window thead .column-filter-row.is
269
433
  background: linear-gradient(90deg, var(--accent-dim), var(--accent-strong));
270
434
  min-width: 0;
271
435
  }
436
+ .graph-root {
437
+ position: relative;
438
+ min-height: 620px;
439
+ }
440
+ .graph-root.is-booting {
441
+ display: grid;
442
+ place-items: center;
443
+ border: 1px solid var(--line);
444
+ border-radius: 10px;
445
+ background: var(--bg);
446
+ }
447
+ .graph-boot {
448
+ display: grid;
449
+ justify-items: center;
450
+ gap: 0.75rem;
451
+ padding: 1.5rem;
452
+ text-align: center;
453
+ color: var(--muted);
454
+ }
455
+ .graph-boot p { margin: 0; }
456
+ .graph-boot-spinner {
457
+ width: 1.35rem;
458
+ height: 1.35rem;
459
+ border: 2px solid color-mix(in srgb, var(--muted) 35%, transparent);
460
+ border-top-color: var(--accent-strong);
461
+ border-radius: 50%;
462
+ animation: graph-boot-spin 0.7s linear infinite;
463
+ }
464
+ @keyframes graph-boot-spin {
465
+ to { transform: rotate(360deg); }
466
+ }
272
467
  .graph-root canvas {
273
468
  display: block;
274
469
  width: 100%;
@@ -391,6 +586,267 @@ function lower(value) {
391
586
  return String(value || '').toLowerCase();
392
587
  }
393
588
 
589
+ function mountSearchableCheckboxDropdown(host, onChange) {
590
+ if (!host) {
591
+ return {
592
+ getSelected: () => [],
593
+ clear: () => undefined,
594
+ setOnChange: () => undefined
595
+ };
596
+ }
597
+ if (host.__scdApi) {
598
+ host.__scdApi.setOnChange(onChange);
599
+ return host.__scdApi;
600
+ }
601
+ let options = [];
602
+ try {
603
+ options = JSON.parse(host.dataset.options || '[]');
604
+ } catch {
605
+ options = [];
606
+ }
607
+ const label = host.dataset.label || 'Filter';
608
+ const placeholder = host.dataset.placeholder || 'Search…';
609
+ let selected = new Set();
610
+ let open = false;
611
+ let searching = false;
612
+ let query = '';
613
+ let panel = null;
614
+ let search = null;
615
+ let searchRow = null;
616
+ let clearBtn = null;
617
+ let list = null;
618
+ let listenersBound = false;
619
+ let changeHandler = onChange;
620
+
621
+ let trigger = host.querySelector('.scd-trigger');
622
+ let triggerLabel = host.querySelector('.scd-trigger-label');
623
+ if (!trigger) {
624
+ trigger = document.createElement('button');
625
+ trigger.type = 'button';
626
+ trigger.className = 'scd-trigger';
627
+ trigger.setAttribute('aria-haspopup', 'listbox');
628
+ triggerLabel = document.createElement('span');
629
+ triggerLabel.className = 'scd-trigger-label';
630
+ const chevron = document.createElement('span');
631
+ chevron.className = 'scd-chevron';
632
+ chevron.setAttribute('aria-hidden', 'true');
633
+ trigger.append(triggerLabel, chevron);
634
+ host.appendChild(trigger);
635
+ }
636
+ if (!trigger.querySelector('.scd-chevron')) {
637
+ const chevron = document.createElement('span');
638
+ chevron.className = 'scd-chevron';
639
+ chevron.setAttribute('aria-hidden', 'true');
640
+ trigger.appendChild(chevron);
641
+ }
642
+ trigger.setAttribute('aria-expanded', 'false');
643
+ trigger.setAttribute('aria-busy', 'false');
644
+ host.classList.remove('is-loading');
645
+ host.dataset.scdReady = '1';
646
+
647
+ function emitChange() {
648
+ changeHandler?.([...selected]);
649
+ }
650
+
651
+ function syncTrigger() {
652
+ const values = [...selected];
653
+ host.dataset.selected = JSON.stringify(values);
654
+ host.classList.toggle('has-selection', values.length > 0);
655
+ if (!triggerLabel) return;
656
+ if (values.length === 0) triggerLabel.textContent = label;
657
+ else if (values.length === 1) {
658
+ const match = options.find(option => option.value === values[0]);
659
+ triggerLabel.textContent = match?.label || values[0];
660
+ } else triggerLabel.textContent = \`\${values.length} selected\`;
661
+ if (clearBtn) clearBtn.hidden = values.length === 0;
662
+ }
663
+
664
+ function setSearching(next) {
665
+ searching = next;
666
+ host.classList.toggle('is-searching', searching);
667
+ if (searchRow) searchRow.classList.toggle('is-active', searching);
668
+ }
669
+
670
+ function endSearching() {
671
+ query = '';
672
+ if (search) search.value = '';
673
+ setSearching(false);
674
+ if (search && document.activeElement === search) search.blur();
675
+ }
676
+
677
+ function ensurePanel() {
678
+ if (panel) return;
679
+ panel = document.createElement('div');
680
+ panel.className = 'scd-panel';
681
+ panel.hidden = true;
682
+ panel.setAttribute('role', 'listbox');
683
+ panel.setAttribute('aria-multiselectable', 'true');
684
+ panel.setAttribute('aria-label', label);
685
+
686
+ searchRow = document.createElement('div');
687
+ searchRow.className = 'scd-search-row';
688
+ search = document.createElement('input');
689
+ search.type = 'search';
690
+ search.className = 'scd-search';
691
+ search.placeholder = placeholder;
692
+ search.setAttribute('aria-label', \`Search \${label}\`);
693
+ clearBtn = document.createElement('button');
694
+ clearBtn.type = 'button';
695
+ clearBtn.className = 'scd-clear';
696
+ clearBtn.textContent = 'Clear';
697
+ clearBtn.hidden = true;
698
+ searchRow.append(search, clearBtn);
699
+
700
+ list = document.createElement('div');
701
+ list.className = 'scd-list';
702
+ panel.append(searchRow, list);
703
+ host.appendChild(panel);
704
+
705
+ search.addEventListener('focus', () => setSearching(true));
706
+ search.addEventListener('blur', () => {
707
+ if (!query.trim()) setSearching(false);
708
+ });
709
+ search.addEventListener('input', () => {
710
+ query = search.value;
711
+ if (query.trim()) setSearching(true);
712
+ renderList();
713
+ });
714
+ clearBtn.addEventListener('click', () => {
715
+ selected.clear();
716
+ syncTrigger();
717
+ renderList();
718
+ emitChange();
719
+ });
720
+ }
721
+
722
+ function renderList() {
723
+ if (!list) return;
724
+ const needle = lower(query.trim());
725
+ const visible = options.filter(option => !needle || lower(option.label).includes(needle));
726
+ list.innerHTML = '';
727
+ if (visible.length === 0) {
728
+ const empty = document.createElement('p');
729
+ empty.className = 'scd-empty';
730
+ empty.textContent = 'No matches';
731
+ list.appendChild(empty);
732
+ return;
733
+ }
734
+ let lastGroup = '';
735
+ for (const option of visible) {
736
+ const group = option.special ? 'Special' : label;
737
+ if (group !== lastGroup) {
738
+ const groupLabel = document.createElement('div');
739
+ groupLabel.className = 'scd-group-label';
740
+ groupLabel.textContent = group;
741
+ list.appendChild(groupLabel);
742
+ lastGroup = group;
743
+ }
744
+ const row = document.createElement('label');
745
+ row.className = 'scd-option';
746
+ if (option.kind) row.classList.add(\`is-\${option.kind}\`);
747
+ if (option.special) row.classList.add('is-special');
748
+ const box = document.createElement('input');
749
+ box.type = 'checkbox';
750
+ box.checked = selected.has(option.value);
751
+ box.addEventListener('change', () => {
752
+ if (box.checked) selected.add(option.value);
753
+ else selected.delete(option.value);
754
+ syncTrigger();
755
+ emitChange();
756
+ });
757
+ const text = document.createElement('span');
758
+ text.className = 'scd-option-label';
759
+ text.textContent = option.label;
760
+ row.append(box, text);
761
+ if (typeof option.count === 'number') {
762
+ const count = document.createElement('span');
763
+ count.className = 'scd-option-count';
764
+ count.textContent = String(option.count);
765
+ row.appendChild(count);
766
+ }
767
+ list.appendChild(row);
768
+ }
769
+ }
770
+
771
+ function onDocPointerDown(event) {
772
+ if (!open) return;
773
+ if (!host.contains(event.target)) setOpen(false);
774
+ }
775
+
776
+ function onKeyDown(event) {
777
+ if (event.key !== 'Escape' || !open) return;
778
+ if (searching || query.trim()) {
779
+ endSearching();
780
+ renderList();
781
+ event.stopPropagation();
782
+ return;
783
+ }
784
+ setOpen(false);
785
+ }
786
+
787
+ function onPeerOpen(event) {
788
+ if (!open) return;
789
+ if (event.detail?.source && event.detail.source !== host) setOpen(false);
790
+ }
791
+
792
+ function bindListeners() {
793
+ if (listenersBound) return;
794
+ document.addEventListener('pointerdown', onDocPointerDown);
795
+ document.addEventListener('keydown', onKeyDown);
796
+ document.addEventListener('reqlan-scd-open', onPeerOpen);
797
+ listenersBound = true;
798
+ }
799
+
800
+ function unbindListeners() {
801
+ if (!listenersBound) return;
802
+ document.removeEventListener('pointerdown', onDocPointerDown);
803
+ document.removeEventListener('keydown', onKeyDown);
804
+ document.removeEventListener('reqlan-scd-open', onPeerOpen);
805
+ listenersBound = false;
806
+ }
807
+
808
+ function setOpen(next) {
809
+ open = next;
810
+ host.classList.toggle('is-open', open);
811
+ if (open) {
812
+ ensurePanel();
813
+ panel.hidden = false;
814
+ trigger.setAttribute('aria-expanded', 'true');
815
+ setSearching(false);
816
+ renderList();
817
+ bindListeners();
818
+ document.dispatchEvent(new CustomEvent('reqlan-scd-open', { detail: { source: host } }));
819
+ } else {
820
+ if (panel) panel.hidden = true;
821
+ trigger.setAttribute('aria-expanded', 'false');
822
+ endSearching();
823
+ unbindListeners();
824
+ }
825
+ }
826
+
827
+ trigger.addEventListener('click', () => setOpen(!open));
828
+ syncTrigger();
829
+ const api = {
830
+ getSelected: () => [...selected],
831
+ clear: () => {
832
+ selected.clear();
833
+ syncTrigger();
834
+ if (open) renderList();
835
+ },
836
+ setOnChange: (fn) => {
837
+ changeHandler = fn;
838
+ }
839
+ };
840
+ host.__scdApi = api;
841
+ return api;
842
+ }
843
+
844
+ function enhanceScdPlaceholders(root) {
845
+ for (const host of root.querySelectorAll('[data-graph-status-scd], [data-graph-tag-scd]')) {
846
+ mountSearchableCheckboxDropdown(host, null);
847
+ }
848
+ }
849
+
394
850
  function scoreDocument(doc, query) {
395
851
  const haystack = [
396
852
  doc.title,
@@ -635,6 +1091,10 @@ const GRAPH_NODE_RADIUS = 7;
635
1091
  const GRAPH_SUBJECT_RADIUS = 10;
636
1092
  const GRAPH_LABEL_MAX_WIDTH = 132;
637
1093
  const GRAPH_HIT_PAD = 6;
1094
+ // Auto-mode opacity ramp: fully hidden below START, fully opaque at END (brief muted band between).
1095
+ const GRAPH_LABEL_FADE_START = 0.62;
1096
+ const GRAPH_LABEL_FADE_END = 0.82;
1097
+ const GRAPH_LABEL_MODES = ['auto', 'on', 'off'];
638
1098
 
639
1099
  function wireGraph(root) {
640
1100
  const graphRoots = root.querySelectorAll('[data-graph-json]');
@@ -653,21 +1113,37 @@ function wireGraph(root) {
653
1113
  controls = document.createElement('div');
654
1114
  controls.className = 'graph-controls-bar';
655
1115
  controls.dataset.graphControls = scriptId;
656
- controls.innerHTML = '<button type="button" class="graph-action" data-graph-toggle-physics aria-pressed="false">Live physics</button><button type="button" class="graph-action" data-graph-fit>Fit</button><span class="graph-status" data-graph-status-text></span>';
1116
+ controls.innerHTML = '<button type="button" class="graph-action is-active" data-graph-toggle-labels data-label-mode="auto" aria-pressed="mixed">Labels: auto</button><button type="button" class="graph-action" data-graph-toggle-physics aria-pressed="false">Live physics</button><button type="button" class="graph-action" data-graph-fit>Fit</button><span class="graph-status" data-graph-status-text></span>';
657
1117
  element.parentElement?.insertBefore(controls, element);
658
1118
  }
659
1119
  const searchInput = controls.querySelector('[data-graph-search]');
660
1120
  const pathInput = controls.querySelector('[data-graph-path]');
661
- const statusSelect = controls.querySelector('[data-graph-status]');
662
- const tagSelect = controls.querySelector('[data-graph-tag]');
1121
+ const statusHost = controls.querySelector('[data-graph-status-scd]');
1122
+ const tagHost = controls.querySelector('[data-graph-tag-scd]');
663
1123
  const toggleExternal = controls.querySelector('[data-graph-toggle-external]');
664
1124
  const toggleIdeasets = controls.querySelector('[data-graph-toggle-ideasets]');
1125
+ const toggleLabels = controls.querySelector('[data-graph-toggle-labels]');
665
1126
  const togglePhysics = controls.querySelector('[data-graph-toggle-physics]');
666
1127
  const fitButton = controls.querySelector('[data-graph-fit]');
667
1128
  const resetButton = controls.querySelector('[data-graph-reset]');
668
1129
  const statusText = controls.querySelector('[data-graph-status-text]');
1130
+ if (statusText) statusText.textContent = 'Initialising graph…';
1131
+ element.classList.add('is-booting');
669
1132
  let hideExternal = false;
670
1133
  let hideIdeasets = false;
1134
+ let labelMode = 'auto';
1135
+ let statusSelected = [];
1136
+ let tagSelected = [];
1137
+ const statusFilter = mountSearchableCheckboxDropdown(statusHost, (values) => {
1138
+ statusSelected = values;
1139
+ refresh();
1140
+ });
1141
+ const tagFilter = mountSearchableCheckboxDropdown(tagHost, (values) => {
1142
+ tagSelected = values;
1143
+ refresh();
1144
+ });
1145
+ statusSelected = statusFilter.getSelected();
1146
+ tagSelected = tagFilter.getSelected();
671
1147
  let livePhysics = false;
672
1148
  let animationFrame = 0;
673
1149
  let calmTicks = 0;
@@ -788,7 +1264,8 @@ function wireGraph(root) {
788
1264
  minX = Math.min(minX, pos.x - r);
789
1265
  minY = Math.min(minY, pos.y - r);
790
1266
  maxX = Math.max(maxX, pos.x + r);
791
- maxY = Math.max(maxY, pos.y + r + 54);
1267
+ // Reserve label space only when labels are forced on; auto hides when fitted/zoomed out.
1268
+ maxY = Math.max(maxY, pos.y + r + (labelMode === 'on' ? 54 : 0));
792
1269
  }
793
1270
  if (!Number.isFinite(minX)) {
794
1271
  return { minX: 0, minY: 0, maxX: 1200, maxY: 640 };
@@ -796,19 +1273,56 @@ function wireGraph(root) {
796
1273
  return { minX, minY, maxX, maxY };
797
1274
  }
798
1275
 
1276
+ function syncLabelsButton() {
1277
+ if (!toggleLabels) return;
1278
+ const label = labelMode === 'auto' ? 'Labels: auto' : labelMode === 'on' ? 'Labels: on' : 'Labels: off';
1279
+ toggleLabels.textContent = label;
1280
+ toggleLabels.dataset.labelMode = labelMode;
1281
+ toggleLabels.classList.toggle('is-active', labelMode !== 'off');
1282
+ toggleLabels.setAttribute('aria-pressed', labelMode === 'auto' ? 'mixed' : labelMode === 'on' ? 'true' : 'false');
1283
+ }
1284
+
1285
+ function labelOpacityAtScale(scale) {
1286
+ if (labelMode === 'on') return 1;
1287
+ if (labelMode === 'off') return 0;
1288
+ if (scale <= GRAPH_LABEL_FADE_START) return 0;
1289
+ if (scale >= GRAPH_LABEL_FADE_END) return 1;
1290
+ return (scale - GRAPH_LABEL_FADE_START) / (GRAPH_LABEL_FADE_END - GRAPH_LABEL_FADE_START);
1291
+ }
1292
+
799
1293
  function fitView(force = false) {
800
1294
  if (userViewport && !force) return;
801
1295
  const bounds = contentBounds();
802
1296
  const pad = 56;
803
- view = {
804
- x: bounds.minX - pad,
805
- y: bounds.minY - pad,
806
- w: Math.max(360, bounds.maxX - bounds.minX + pad * 2),
807
- h: Math.max(360, bounds.maxY - bounds.minY + pad * 2)
808
- };
1297
+ let x = bounds.minX - pad;
1298
+ let y = bounds.minY - pad;
1299
+ let w = Math.max(360, bounds.maxX - bounds.minX + pad * 2);
1300
+ let h = Math.max(360, bounds.maxY - bounds.minY + pad * 2);
1301
+ // Match canvas aspect so uniform scale fills without squashing (SVG viewBox meet).
1302
+ const canvasAspect = cssWidth / Math.max(1, cssHeight);
1303
+ const viewAspect = w / h;
1304
+ if (viewAspect < canvasAspect) {
1305
+ const widened = h * canvasAspect;
1306
+ x -= (widened - w) / 2;
1307
+ w = widened;
1308
+ } else if (viewAspect > canvasAspect) {
1309
+ const heightened = w / canvasAspect;
1310
+ y -= (heightened - h) / 2;
1311
+ h = heightened;
1312
+ }
1313
+ view = { x, y, w, h };
809
1314
  userViewport = false;
810
1315
  }
811
1316
 
1317
+ function viewportTransform() {
1318
+ const scale = Math.min(cssWidth / Math.max(1, view.w), cssHeight / Math.max(1, view.h));
1319
+ return {
1320
+ scale,
1321
+ offsetX: (cssWidth - view.w * scale) / 2,
1322
+ offsetY: (cssHeight - view.h * scale) / 2
1323
+ };
1324
+ }
1325
+
812
1326
  function wrapLines(text, maxWidth, font) {
813
1327
  if (!text) return [];
814
1328
  ctx.font = font;
@@ -871,13 +1385,14 @@ function wireGraph(root) {
871
1385
  ctx.fillStyle = '#14100e';
872
1386
  ctx.fillRect(0, 0, cssWidth, cssHeight);
873
1387
 
874
- const sx = cssWidth / view.w;
875
- const sy = cssHeight / view.h;
1388
+ const { scale, offsetX, offsetY } = viewportTransform();
1389
+ const ambientLabelOpacity = labelOpacityAtScale(scale);
876
1390
  ctx.save();
877
- ctx.translate(-view.x * sx, -view.y * sy);
878
- ctx.scale(sx, sy);
1391
+ ctx.translate(offsetX, offsetY);
1392
+ ctx.scale(scale, scale);
1393
+ ctx.translate(-view.x, -view.y);
879
1394
 
880
- ctx.lineWidth = 1.1 / Math.max(sx, sy);
1395
+ ctx.lineWidth = 1.1 / scale;
881
1396
  ctx.strokeStyle = 'rgba(61,47,40,0.85)';
882
1397
  ctx.globalAlpha = 0.85;
883
1398
  for (const edge of simEdges) {
@@ -909,10 +1424,13 @@ function wireGraph(root) {
909
1424
  ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
910
1425
  ctx.fillStyle = nodeFill(node);
911
1426
  ctx.fill();
912
- ctx.lineWidth = (isSubject ? 2.2 : hover ? 2 : 1.15) / Math.max(sx, sy);
1427
+ ctx.lineWidth = (isSubject ? 2.2 : hover ? 2 : 1.15) / scale;
913
1428
  ctx.strokeStyle = nodeStroke(node, hover);
914
1429
  ctx.stroke();
915
1430
 
1431
+ // Auto: continuous opacity from zoom; hovered/dragged nodes stay fully opaque.
1432
+ const labelOpacity = hover && labelMode === 'auto' ? 1 : ambientLabelOpacity;
1433
+ if (labelOpacity <= 0) continue;
916
1434
  const meta = formatNodeMeta(node);
917
1435
  const attrs = formatNodeAttrs(node);
918
1436
  const nameFont = isSubject ? '600 11px Inter, system-ui, sans-serif' : '600 10px Inter, system-ui, sans-serif';
@@ -921,12 +1439,14 @@ function wireGraph(root) {
921
1439
  const metaLines = wrapLines(meta, GRAPH_LABEL_MAX_WIDTH, metaFont);
922
1440
  const attrLines = wrapLines(attrs, GRAPH_LABEL_MAX_WIDTH, metaFont);
923
1441
  let ty = pos.y + r + 12;
1442
+ ctx.save();
1443
+ ctx.globalAlpha = labelOpacity;
924
1444
  ctx.textAlign = 'center';
925
1445
  ctx.textBaseline = 'top';
926
1446
  ctx.fillStyle = isSubject ? '#0bbefb' : '#ebe4de';
927
1447
  ctx.font = nameFont;
928
1448
  for (const line of nameLines) {
929
- ctx.lineWidth = 3 / Math.max(sx, sy);
1449
+ ctx.lineWidth = 3 / scale;
930
1450
  ctx.strokeStyle = '#14100e';
931
1451
  ctx.strokeText(line, pos.x, ty);
932
1452
  ctx.fillText(line, pos.x, ty);
@@ -943,6 +1463,7 @@ function wireGraph(root) {
943
1463
  ctx.fillText(line, pos.x, ty);
944
1464
  ty += 11;
945
1465
  }
1466
+ ctx.restore();
946
1467
  }
947
1468
  ctx.restore();
948
1469
  }
@@ -1035,6 +1556,7 @@ function wireGraph(root) {
1035
1556
  canvas.setAttribute('aria-label', \`Requirement graph with \${filteredNodes.length} nodes\`);
1036
1557
  ctx = canvas.getContext('2d');
1037
1558
  element.innerHTML = '';
1559
+ element.classList.remove('is-booting');
1038
1560
  element.appendChild(canvas);
1039
1561
  resizeCanvas();
1040
1562
  wireViewport(canvas);
@@ -1053,9 +1575,13 @@ function wireGraph(root) {
1053
1575
 
1054
1576
  function clientToGraph(event) {
1055
1577
  const rect = canvas.getBoundingClientRect();
1056
- const x = view.x + ((event.clientX - rect.left) / rect.width) * view.w;
1057
- const y = view.y + ((event.clientY - rect.top) / rect.height) * view.h;
1058
- return { x, y };
1578
+ const { scale, offsetX, offsetY } = viewportTransform();
1579
+ const cssX = ((event.clientX - rect.left) / rect.width) * cssWidth;
1580
+ const cssY = ((event.clientY - rect.top) / rect.height) * cssHeight;
1581
+ return {
1582
+ x: view.x + (cssX - offsetX) / scale,
1583
+ y: view.y + (cssY - offsetY) / scale
1584
+ };
1059
1585
  }
1060
1586
 
1061
1587
  function wireViewport(target) {
@@ -1104,8 +1630,9 @@ function wireGraph(root) {
1104
1630
  }
1105
1631
  if (panMode && panOrigin) {
1106
1632
  const rect = target.getBoundingClientRect();
1107
- const dx = ((event.clientX - panOrigin.x) / rect.width) * view.w;
1108
- const dy = ((event.clientY - panOrigin.y) / rect.height) * view.h;
1633
+ const { scale } = viewportTransform();
1634
+ const dx = ((event.clientX - panOrigin.x) / rect.width) * cssWidth / scale;
1635
+ const dy = ((event.clientY - panOrigin.y) / rect.height) * cssHeight / scale;
1109
1636
  view.x = panOrigin.viewX - dx;
1110
1637
  view.y = panOrigin.viewY - dy;
1111
1638
  userViewport = true;
@@ -1164,17 +1691,24 @@ function wireGraph(root) {
1164
1691
  }
1165
1692
 
1166
1693
  function matches(node) {
1694
+ const FILTER_NOT_PRESENT = '__not_present__';
1167
1695
  const query = lower(searchInput?.value?.trim());
1168
1696
  const pathQuery = lower(pathInput?.value?.trim());
1169
- const statusQuery = lower(statusSelect?.value?.trim());
1170
- const tagQuery = lower(tagSelect?.value?.trim());
1171
1697
  if (hideExternal && node.isExternal) return false;
1172
1698
  if (hideIdeasets && node.kind === 'ideaset') return false;
1173
1699
  const haystack = [node.name, node.kind, node.fileUri, node.status || '', ...(node.tags || [])].map(lower).join(' ');
1174
1700
  if (query && !haystack.includes(query)) return false;
1175
1701
  if (pathQuery && !lower(node.fileUri).includes(pathQuery)) return false;
1176
- if (statusQuery && lower(node.status || '') !== statusQuery) return false;
1177
- if (tagQuery && !(node.tags || []).some(tag => lower(tag) === tagQuery)) return false;
1702
+ if (statusSelected.length) {
1703
+ const key = node.statusKey || (String(node.status || '').trim() ? String(node.status).trim() : FILTER_NOT_PRESENT);
1704
+ if (!statusSelected.includes(key)) return false;
1705
+ }
1706
+ if (tagSelected.length) {
1707
+ const keys = Array.isArray(node.tagsKeys) && node.tagsKeys.length
1708
+ ? node.tagsKeys
1709
+ : (Array.isArray(node.tags) && node.tags.length ? node.tags : [FILTER_NOT_PRESENT]);
1710
+ if (!tagSelected.some(value => keys.includes(value))) return false;
1711
+ }
1178
1712
  return true;
1179
1713
  }
1180
1714
 
@@ -1187,7 +1721,7 @@ function wireGraph(root) {
1187
1721
  mountGraph(visibleNodes, visibleEdges);
1188
1722
  }
1189
1723
 
1190
- for (const control of [searchInput, pathInput, statusSelect, tagSelect]) {
1724
+ for (const control of [searchInput, pathInput]) {
1191
1725
  control?.addEventListener('input', refresh);
1192
1726
  control?.addEventListener('change', refresh);
1193
1727
  }
@@ -1203,6 +1737,12 @@ function wireGraph(root) {
1203
1737
  toggleIdeasets.textContent = hideIdeasets ? 'Show ideasets' : 'Hide ideasets';
1204
1738
  refresh();
1205
1739
  });
1740
+ toggleLabels?.addEventListener('click', () => {
1741
+ const index = GRAPH_LABEL_MODES.indexOf(labelMode);
1742
+ labelMode = GRAPH_LABEL_MODES[(index + 1) % GRAPH_LABEL_MODES.length];
1743
+ syncLabelsButton();
1744
+ paint();
1745
+ });
1206
1746
  togglePhysics?.addEventListener('click', () => {
1207
1747
  livePhysics = !livePhysics;
1208
1748
  togglePhysics.classList.toggle('is-active', livePhysics);
@@ -1221,12 +1761,16 @@ function wireGraph(root) {
1221
1761
  resetButton?.addEventListener('click', () => {
1222
1762
  if (searchInput) searchInput.value = '';
1223
1763
  if (pathInput) pathInput.value = '';
1224
- if (statusSelect) statusSelect.value = '';
1225
- if (tagSelect) tagSelect.value = '';
1764
+ statusSelected = [];
1765
+ tagSelected = [];
1766
+ statusFilter.clear();
1767
+ tagFilter.clear();
1226
1768
  hideExternal = false;
1227
1769
  hideIdeasets = false;
1770
+ labelMode = 'auto';
1228
1771
  toggleExternal?.classList.remove('is-active');
1229
1772
  toggleIdeasets?.classList.remove('is-active');
1773
+ syncLabelsButton();
1230
1774
  if (toggleExternal) toggleExternal.textContent = 'Hide external';
1231
1775
  if (toggleIdeasets) toggleIdeasets.textContent = 'Hide ideasets';
1232
1776
  positions.clear();
@@ -1235,12 +1779,15 @@ function wireGraph(root) {
1235
1779
  userViewport = false;
1236
1780
  refresh();
1237
1781
  });
1782
+ syncLabelsButton();
1238
1783
  refresh();
1239
1784
  }
1240
1785
  }
1241
1786
 
1242
1787
  function boot() {
1243
1788
  const root = document.body;
1789
+ // Interactive Status/Tags triggers before heavy table/graph init.
1790
+ enhanceScdPlaceholders(root);
1244
1791
  wireTables(root);
1245
1792
  wireGraph(root);
1246
1793
  void wireGlobalSearch(root);