@reqlan/analytical 0.6.3 → 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 +11 -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 +537 -15
  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,6 +1273,23 @@ 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();
@@ -892,6 +1386,7 @@ function wireGraph(root) {
892
1386
  ctx.fillRect(0, 0, cssWidth, cssHeight);
893
1387
 
894
1388
  const { scale, offsetX, offsetY } = viewportTransform();
1389
+ const ambientLabelOpacity = labelOpacityAtScale(scale);
895
1390
  ctx.save();
896
1391
  ctx.translate(offsetX, offsetY);
897
1392
  ctx.scale(scale, scale);
@@ -933,6 +1428,9 @@ function wireGraph(root) {
933
1428
  ctx.strokeStyle = nodeStroke(node, hover);
934
1429
  ctx.stroke();
935
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;
936
1434
  const meta = formatNodeMeta(node);
937
1435
  const attrs = formatNodeAttrs(node);
938
1436
  const nameFont = isSubject ? '600 11px Inter, system-ui, sans-serif' : '600 10px Inter, system-ui, sans-serif';
@@ -941,6 +1439,8 @@ function wireGraph(root) {
941
1439
  const metaLines = wrapLines(meta, GRAPH_LABEL_MAX_WIDTH, metaFont);
942
1440
  const attrLines = wrapLines(attrs, GRAPH_LABEL_MAX_WIDTH, metaFont);
943
1441
  let ty = pos.y + r + 12;
1442
+ ctx.save();
1443
+ ctx.globalAlpha = labelOpacity;
944
1444
  ctx.textAlign = 'center';
945
1445
  ctx.textBaseline = 'top';
946
1446
  ctx.fillStyle = isSubject ? '#0bbefb' : '#ebe4de';
@@ -963,6 +1463,7 @@ function wireGraph(root) {
963
1463
  ctx.fillText(line, pos.x, ty);
964
1464
  ty += 11;
965
1465
  }
1466
+ ctx.restore();
966
1467
  }
967
1468
  ctx.restore();
968
1469
  }
@@ -1055,6 +1556,7 @@ function wireGraph(root) {
1055
1556
  canvas.setAttribute('aria-label', \`Requirement graph with \${filteredNodes.length} nodes\`);
1056
1557
  ctx = canvas.getContext('2d');
1057
1558
  element.innerHTML = '';
1559
+ element.classList.remove('is-booting');
1058
1560
  element.appendChild(canvas);
1059
1561
  resizeCanvas();
1060
1562
  wireViewport(canvas);
@@ -1189,17 +1691,24 @@ function wireGraph(root) {
1189
1691
  }
1190
1692
 
1191
1693
  function matches(node) {
1694
+ const FILTER_NOT_PRESENT = '__not_present__';
1192
1695
  const query = lower(searchInput?.value?.trim());
1193
1696
  const pathQuery = lower(pathInput?.value?.trim());
1194
- const statusQuery = lower(statusSelect?.value?.trim());
1195
- const tagQuery = lower(tagSelect?.value?.trim());
1196
1697
  if (hideExternal && node.isExternal) return false;
1197
1698
  if (hideIdeasets && node.kind === 'ideaset') return false;
1198
1699
  const haystack = [node.name, node.kind, node.fileUri, node.status || '', ...(node.tags || [])].map(lower).join(' ');
1199
1700
  if (query && !haystack.includes(query)) return false;
1200
1701
  if (pathQuery && !lower(node.fileUri).includes(pathQuery)) return false;
1201
- if (statusQuery && lower(node.status || '') !== statusQuery) return false;
1202
- 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
+ }
1203
1712
  return true;
1204
1713
  }
1205
1714
 
@@ -1212,7 +1721,7 @@ function wireGraph(root) {
1212
1721
  mountGraph(visibleNodes, visibleEdges);
1213
1722
  }
1214
1723
 
1215
- for (const control of [searchInput, pathInput, statusSelect, tagSelect]) {
1724
+ for (const control of [searchInput, pathInput]) {
1216
1725
  control?.addEventListener('input', refresh);
1217
1726
  control?.addEventListener('change', refresh);
1218
1727
  }
@@ -1228,6 +1737,12 @@ function wireGraph(root) {
1228
1737
  toggleIdeasets.textContent = hideIdeasets ? 'Show ideasets' : 'Hide ideasets';
1229
1738
  refresh();
1230
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
+ });
1231
1746
  togglePhysics?.addEventListener('click', () => {
1232
1747
  livePhysics = !livePhysics;
1233
1748
  togglePhysics.classList.toggle('is-active', livePhysics);
@@ -1246,12 +1761,16 @@ function wireGraph(root) {
1246
1761
  resetButton?.addEventListener('click', () => {
1247
1762
  if (searchInput) searchInput.value = '';
1248
1763
  if (pathInput) pathInput.value = '';
1249
- if (statusSelect) statusSelect.value = '';
1250
- if (tagSelect) tagSelect.value = '';
1764
+ statusSelected = [];
1765
+ tagSelected = [];
1766
+ statusFilter.clear();
1767
+ tagFilter.clear();
1251
1768
  hideExternal = false;
1252
1769
  hideIdeasets = false;
1770
+ labelMode = 'auto';
1253
1771
  toggleExternal?.classList.remove('is-active');
1254
1772
  toggleIdeasets?.classList.remove('is-active');
1773
+ syncLabelsButton();
1255
1774
  if (toggleExternal) toggleExternal.textContent = 'Hide external';
1256
1775
  if (toggleIdeasets) toggleIdeasets.textContent = 'Hide ideasets';
1257
1776
  positions.clear();
@@ -1260,12 +1779,15 @@ function wireGraph(root) {
1260
1779
  userViewport = false;
1261
1780
  refresh();
1262
1781
  });
1782
+ syncLabelsButton();
1263
1783
  refresh();
1264
1784
  }
1265
1785
  }
1266
1786
 
1267
1787
  function boot() {
1268
1788
  const root = document.body;
1789
+ // Interactive Status/Tags triggers before heavy table/graph init.
1790
+ enhanceScdPlaceholders(root);
1269
1791
  wireTables(root);
1270
1792
  wireGraph(root);
1271
1793
  void wireGlobalSearch(root);