@reqlan/analytical 0.5.4 → 0.6.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 (77) hide show
  1. package/README.md +13 -2
  2. package/out/analysis/git-dates-analyser.js +41 -12
  3. package/out/analysis/git-dates-analyser.js.map +1 -1
  4. package/out/core/analytical-store.d.ts +9 -2
  5. package/out/core/analytical-store.js +7 -5
  6. package/out/core/analytical-store.js.map +1 -1
  7. package/out/core/application-memory.d.ts +17 -2
  8. package/out/core/application-memory.js +23 -5
  9. package/out/core/application-memory.js.map +1 -1
  10. package/out/core/base-discovery.d.ts +40 -0
  11. package/out/core/base-discovery.js +166 -0
  12. package/out/core/base-discovery.js.map +1 -0
  13. package/out/core/context-model.d.ts +17 -0
  14. package/out/core/context-model.js.map +1 -1
  15. package/out/core/create-base.d.ts +11 -0
  16. package/out/core/create-base.js +33 -0
  17. package/out/core/create-base.js.map +1 -0
  18. package/out/core/rqignore.d.ts +25 -0
  19. package/out/core/rqignore.js +149 -0
  20. package/out/core/rqignore.js.map +1 -0
  21. package/out/core/types.d.ts +5 -1
  22. package/out/core/types.js.map +1 -1
  23. package/out/create-runtime.d.ts +29 -4
  24. package/out/create-runtime.js +47 -8
  25. package/out/create-runtime.js.map +1 -1
  26. package/out/export/html-export-assets.d.ts +2 -2
  27. package/out/export/html-export-assets.js +259 -239
  28. package/out/export/html-export-assets.js.map +1 -1
  29. package/out/graph/physics-core.d.ts +141 -0
  30. package/out/graph/physics-core.js +237 -0
  31. package/out/graph/physics-core.js.map +1 -0
  32. package/out/headless-index-service.d.ts +6 -23
  33. package/out/headless-index-service.js +5 -171
  34. package/out/headless-index-service.js.map +1 -1
  35. package/out/index-store/base-registry.d.ts +57 -0
  36. package/out/index-store/base-registry.js +192 -0
  37. package/out/index-store/base-registry.js.map +1 -0
  38. package/out/index-store/index-diagnostics-store.d.ts +72 -0
  39. package/out/index-store/index-diagnostics-store.js +238 -0
  40. package/out/index-store/index-diagnostics-store.js.map +1 -0
  41. package/out/index-store/index-file-error.d.ts +16 -0
  42. package/out/index-store/index-file-error.js +27 -0
  43. package/out/index-store/index-file-error.js.map +1 -0
  44. package/out/index-store/index-parse-issues.d.ts +9 -0
  45. package/out/index-store/index-parse-issues.js +62 -0
  46. package/out/index-store/index-parse-issues.js.map +1 -0
  47. package/out/index-store/index-status.d.ts +20 -0
  48. package/out/index-store/index-status.js +2 -0
  49. package/out/index-store/index-status.js.map +1 -0
  50. package/out/index-store/overview-coverage.d.ts +23 -0
  51. package/out/index-store/overview-coverage.js +254 -0
  52. package/out/index-store/overview-coverage.js.map +1 -0
  53. package/out/index-store/schema.d.ts +1 -1
  54. package/out/index-store/schema.js +4 -1
  55. package/out/index-store/schema.js.map +1 -1
  56. package/out/index-store/sqlite-store.d.ts +31 -2
  57. package/out/index-store/sqlite-store.js +152 -13
  58. package/out/index-store/sqlite-store.js.map +1 -1
  59. package/out/index-store/webview-table-queries.d.ts +65 -1
  60. package/out/index-store/webview-table-queries.js +188 -26
  61. package/out/index-store/webview-table-queries.js.map +1 -1
  62. package/out/index-store/workspace-index-file.d.ts +27 -0
  63. package/out/index-store/workspace-index-file.js +98 -0
  64. package/out/index-store/workspace-index-file.js.map +1 -0
  65. package/out/index-store/workspace-index-sync.d.ts +53 -0
  66. package/out/index-store/workspace-index-sync.js +164 -0
  67. package/out/index-store/workspace-index-sync.js.map +1 -0
  68. package/out/index-store/workspace-index.d.ts +80 -0
  69. package/out/index-store/workspace-index.js +525 -0
  70. package/out/index-store/workspace-index.js.map +1 -0
  71. package/out/index-store/workspace-mtime.d.ts +14 -0
  72. package/out/index-store/workspace-mtime.js +56 -0
  73. package/out/index-store/workspace-mtime.js.map +1 -0
  74. package/out/index.d.ts +25 -6
  75. package/out/index.js +12 -3
  76. package/out/index.js.map +1 -1
  77. package/package.json +7 -2
@@ -1,3 +1,9 @@
1
+ import { readFileSync } from 'node:fs';
2
+ /** Embed shared physics core into the classic-script export app.js (strip ESM exports). */
3
+ function embedPhysicsCoreSource() {
4
+ return readFileSync(new URL('../graph/physics-core.js', import.meta.url), 'utf8')
5
+ .replace(/^export\s+/gm, '');
6
+ }
1
7
  export const SHARED_STYLES = `
2
8
  :root {
3
9
  color-scheme: dark;
@@ -263,56 +269,18 @@ body[data-runtime-mode="interactive"] .scroll-window thead .column-filter-row.is
263
269
  background: linear-gradient(90deg, var(--accent-dim), var(--accent-strong));
264
270
  min-width: 0;
265
271
  }
266
- .graph-root svg {
267
- width: 100%; min-height: 620px; background: var(--bg); border-radius: 10px; border: 1px solid var(--line);
268
- }
269
- .graph-root line {
270
- stroke: color-mix(in srgb, var(--line) 80%, transparent); stroke-width: 1.2;
271
- opacity: 0.85; transition: opacity 180ms ease;
272
- }
273
- .graph-root .node { cursor: pointer; }
274
- .graph-root circle {
275
- fill: var(--accent); stroke: color-mix(in srgb, var(--fg) 35%, transparent); stroke-width: 1.25;
276
- transition: fill 180ms ease, stroke 180ms ease, r 180ms ease;
277
- }
278
- .graph-root .external circle { fill: var(--rust-muted); stroke: color-mix(in srgb, var(--rust) 50%, transparent); }
279
- .graph-root .ideaset circle { fill: color-mix(in srgb, #d18616 78%, white); stroke: #d18616; }
280
- .graph-root .subject circle {
281
- fill: var(--rust);
282
- stroke: var(--accent-strong);
283
- stroke-width: 2.75;
284
- animation: subject-pulse 2.4s ease-in-out infinite;
285
- }
286
- .graph-root text {
287
- fill: var(--fg); font-size: 11px; paint-order: stroke;
288
- stroke: var(--bg); stroke-width: 3px; stroke-linejoin: round;
289
- pointer-events: none;
290
- }
291
- .graph-root .subject text {
292
- fill: var(--accent-strong); font-size: 12.5px; font-weight: 600;
293
- }
294
- .graph-root .node:hover circle { stroke: var(--accent-strong); stroke-width: 2; }
295
- .graph-root .graph-label {
296
- width: 160px;
297
- color: var(--fg);
298
- font: 11px/1.3 Inter, system-ui, sans-serif;
299
- text-align: center;
300
- word-break: break-word;
301
- overflow-wrap: anywhere;
302
- pointer-events: none;
303
- user-select: none;
304
- }
305
- .graph-root .graph-label strong { display: block; font-weight: 600; }
306
- .graph-root .graph-label .meta { display: block; color: var(--muted); font-size: 10px; margin-top: 0.15rem; }
307
- .graph-root .graph-label .attrs { display: block; color: var(--rust); font-size: 9.5px; margin-top: 0.1rem; }
308
- .graph-root .subject .graph-label strong { color: var(--accent-strong); }
309
- .graph-root svg { touch-action: none; cursor: grab; }
310
- .graph-root svg.is-panning { cursor: grabbing; }
311
- .graph-root .node { cursor: pointer; }
312
- @keyframes subject-pulse {
313
- 0%, 100% { filter: drop-shadow(0 0 0 transparent); }
314
- 50% { filter: drop-shadow(0 0 8px color-mix(in srgb, var(--rust) 55%, transparent)); }
272
+ .graph-root canvas {
273
+ display: block;
274
+ width: 100%;
275
+ min-height: 620px;
276
+ background: var(--bg);
277
+ border-radius: 10px;
278
+ border: 1px solid var(--line);
279
+ touch-action: none;
280
+ cursor: grab;
315
281
  }
282
+ .graph-root canvas.is-panning { cursor: grabbing; }
283
+ .graph-root canvas.is-dragging-node { cursor: pointer; }
316
284
  .search-results { display: grid; gap: 0.7rem; margin-top: 1rem; }
317
285
  .search-result:hover { border-color: var(--accent-dim); }
318
286
  .hidden { display: none !important; }
@@ -384,6 +352,8 @@ pre.code-like {
384
352
  }
385
353
  `;
386
354
  export const APP_JS = `
355
+ ${embedPhysicsCoreSource()}
356
+
387
357
  const searchIndexByRoot = new WeakMap();
388
358
 
389
359
  function exportRootPrefix(root) {
@@ -658,26 +628,13 @@ function wireTables(root) {
658
628
  }
659
629
  }
660
630
 
661
- function hashAngle(seed) {
662
- let hash = 0;
663
- for (let index = 0; index < seed.length; index += 1) {
664
- hash = (hash * 31 + seed.charCodeAt(index)) | 0;
665
- }
666
- return ((hash >>> 0) % 6283) / 1000;
667
- }
631
+ const EXPORT_PHYSICS_SETTINGS = ReqlanGraphPhysics.DEFAULT_PHYSICS_SETTINGS;
668
632
 
669
- // Matches packages/extension/webviews/shared/graph/graph-physics.ts DEFAULT_PHYSICS_SETTINGS.
670
- const EXPORT_PHYSICS_SETTINGS = {
671
- gravity: 0.002,
672
- repulsion: 20000,
673
- linkStrength: 0.015,
674
- linkDistance: 120,
675
- damping: 0.5,
676
- maxVelocity: 10,
677
- minSeparation: 24,
678
- restSpeed: 0.02,
679
- restTicks: 90
680
- };
633
+ // Tight visual radii for denser canvas packing (labels still show full names).
634
+ const GRAPH_NODE_RADIUS = 7;
635
+ const GRAPH_SUBJECT_RADIUS = 10;
636
+ const GRAPH_LABEL_MAX_WIDTH = 132;
637
+ const GRAPH_HIT_PAD = 6;
681
638
 
682
639
  function wireGraph(root) {
683
640
  const graphRoots = root.querySelectorAll('[data-graph-json]');
@@ -718,9 +675,10 @@ function wireGraph(root) {
718
675
  let simEdges = [];
719
676
  const positions = new Map();
720
677
  const velocities = new Map();
721
- let svg;
722
- let edgeEls = [];
723
- let nodeEls = [];
678
+ let canvas;
679
+ let ctx;
680
+ let cssWidth = 1200;
681
+ let cssHeight = 640;
724
682
  const pinnedIds = new Set();
725
683
  let view = { x: 0, y: 0, w: 1200, h: 640 };
726
684
  let userViewport = false;
@@ -729,6 +687,8 @@ function wireGraph(root) {
729
687
  let panMode = false;
730
688
  let panOrigin = null;
731
689
  let suppressClickUntil = 0;
690
+ let hoverNodeId = null;
691
+ let pulsePhase = 0;
732
692
 
733
693
  function resolveGraphNodeUrl(pageUrl) {
734
694
  let cleaned = String(pageUrl || '').replace(/^\\.\\//, '');
@@ -738,14 +698,6 @@ function wireGraph(root) {
738
698
  return resolveExportUrl(root, cleaned);
739
699
  }
740
700
 
741
- function escapeXml(value) {
742
- return String(value || '')
743
- .replace(/&/g, '&amp;')
744
- .replace(/</g, '&lt;')
745
- .replace(/>/g, '&gt;')
746
- .replace(/"/g, '&quot;');
747
- }
748
-
749
701
  function formatNodeMeta(node) {
750
702
  const bits = [];
751
703
  if (node.status) bits.push(String(node.status));
@@ -760,11 +712,15 @@ function wireGraph(root) {
760
712
  return keys.length ? keys.map(key => '@' + key).join(' ') : '';
761
713
  }
762
714
 
715
+ function nodeRadius(node) {
716
+ return node.isSubject || node.id === graph.centerId ? GRAPH_SUBJECT_RADIUS : GRAPH_NODE_RADIUS;
717
+ }
718
+
763
719
  function seedMissingPositions(nodes, width, height, centerId) {
764
720
  const cx = width / 2;
765
721
  const cy = height / 2;
766
722
  const subject = nodes.find(node => node.isSubject || node.id === centerId);
767
- const radius = Math.min(width, height) * 0.34;
723
+ const radius = Math.min(width, height) * 0.22;
768
724
  nodes.forEach((node, index) => {
769
725
  if (positions.has(node.id)) return;
770
726
  if (subject && node.id === subject.id) {
@@ -772,7 +728,7 @@ function wireGraph(root) {
772
728
  return;
773
729
  }
774
730
  const angle = (Math.PI * 2 * index) / Math.max(nodes.length, 1) - Math.PI / 2;
775
- const ring = radius * (0.72 + (index % 3) * 0.14);
731
+ const ring = radius * (0.7 + (index % 3) * 0.12);
776
732
  positions.set(node.id, {
777
733
  x: cx + Math.cos(angle) * ring,
778
734
  y: cy + Math.sin(angle) * ring
@@ -790,7 +746,6 @@ function wireGraph(root) {
790
746
  }
791
747
 
792
748
  function physicsStep(nodes, edges) {
793
- const settings = EXPORT_PHYSICS_SETTINGS;
794
749
  const count = nodes.length;
795
750
  if (count === 0) return 0;
796
751
  const ids = new Array(count);
@@ -798,90 +753,27 @@ function wireGraph(root) {
798
753
  const ys = new Float64Array(count);
799
754
  const fxs = new Float64Array(count);
800
755
  const fys = new Float64Array(count);
801
- const indexById = new Map();
802
756
  for (let i = 0; i < count; i += 1) {
803
757
  const node = nodes[i];
804
758
  const pos = positions.get(node.id) || { x: 0, y: 0 };
805
759
  ids[i] = node.id;
806
760
  xs[i] = pos.x;
807
761
  ys[i] = pos.y;
808
- indexById.set(node.id, i);
809
- }
810
- let centroidX = 0;
811
- let centroidY = 0;
812
- for (let i = 0; i < count; i += 1) {
813
- centroidX += xs[i];
814
- centroidY += ys[i];
815
- }
816
- centroidX /= count;
817
- centroidY /= count;
818
- for (let i = 0; i < count; i += 1) {
819
- fxs[i] -= settings.gravity * (xs[i] - centroidX);
820
- fys[i] -= settings.gravity * (ys[i] - centroidY);
821
- }
822
- const minSeparationSq = settings.minSeparation * settings.minSeparation;
823
- for (let i = 0; i < count; i += 1) {
824
- for (let j = i + 1; j < count; j += 1) {
825
- let dx = xs[j] - xs[i];
826
- let dy = ys[j] - ys[i];
827
- let distSq = dx * dx + dy * dy;
828
- if (distSq < 1e-6) {
829
- const angle = hashAngle(ids[i] + ids[j]);
830
- dx = Math.cos(angle);
831
- dy = Math.sin(angle);
832
- distSq = 1;
833
- }
834
- const clampedSq = Math.max(distSq, minSeparationSq);
835
- const dist = Math.sqrt(distSq);
836
- const force = settings.repulsion / clampedSq;
837
- const fx = (dx / dist) * force;
838
- const fy = (dy / dist) * force;
839
- fxs[i] -= fx;
840
- fys[i] -= fy;
841
- fxs[j] += fx;
842
- fys[j] += fy;
843
- }
844
762
  }
845
- for (const edge of edges) {
846
- const sourceIndex = indexById.get(edge.sourceId);
847
- const targetIndex = indexById.get(edge.targetId);
848
- if (sourceIndex === undefined || targetIndex === undefined || sourceIndex === targetIndex) continue;
849
- const dx = xs[targetIndex] - xs[sourceIndex];
850
- const dy = ys[targetIndex] - ys[sourceIndex];
851
- const dist = Math.sqrt(dx * dx + dy * dy);
852
- if (dist < 1e-3) continue;
853
- const force = settings.linkStrength * (dist - settings.linkDistance);
854
- const fx = (dx / dist) * force;
855
- const fy = (dy / dist) * force;
856
- fxs[sourceIndex] += fx;
857
- fys[sourceIndex] += fy;
858
- fxs[targetIndex] -= fx;
859
- fys[targetIndex] -= fy;
860
- }
861
- let speedSum = 0;
862
- let movingCount = 0;
763
+ const averageSpeed = ReqlanGraphPhysics.stepPhysics({
764
+ ids,
765
+ xs,
766
+ ys,
767
+ fxs,
768
+ fys,
769
+ velocities,
770
+ pinnedIds,
771
+ edges
772
+ }, EXPORT_PHYSICS_SETTINGS);
863
773
  for (let i = 0; i < count; i += 1) {
864
- const id = ids[i];
865
- if (pinnedIds.has(id)) {
866
- continue;
867
- }
868
- const velocity = velocities.get(id) || { vx: 0, vy: 0 };
869
- let vx = (velocity.vx + fxs[i]) * settings.damping;
870
- let vy = (velocity.vy + fys[i]) * settings.damping;
871
- const speed = Math.sqrt(vx * vx + vy * vy);
872
- if (speed > settings.maxVelocity) {
873
- const scale = settings.maxVelocity / speed;
874
- vx *= scale;
875
- vy *= scale;
876
- }
877
- velocity.vx = vx;
878
- velocity.vy = vy;
879
- velocities.set(id, velocity);
880
- positions.set(id, { x: xs[i] + vx, y: ys[i] + vy });
881
- speedSum += Math.min(speed, settings.maxVelocity);
882
- movingCount += 1;
774
+ positions.set(ids[i], { x: xs[i], y: ys[i] });
883
775
  }
884
- return movingCount > 0 ? speedSum / movingCount : 0;
776
+ return averageSpeed;
885
777
  }
886
778
 
887
779
  function contentBounds() {
@@ -889,11 +781,14 @@ function wireGraph(root) {
889
781
  let minY = Infinity;
890
782
  let maxX = -Infinity;
891
783
  let maxY = -Infinity;
892
- for (const pos of positions.values()) {
893
- minX = Math.min(minX, pos.x);
894
- minY = Math.min(minY, pos.y);
895
- maxX = Math.max(maxX, pos.x);
896
- maxY = Math.max(maxY, pos.y);
784
+ for (const node of simNodes) {
785
+ const pos = positions.get(node.id);
786
+ if (!pos) continue;
787
+ const r = nodeRadius(node);
788
+ minX = Math.min(minX, pos.x - r);
789
+ minY = Math.min(minY, pos.y - r);
790
+ maxX = Math.max(maxX, pos.x + r);
791
+ maxY = Math.max(maxY, pos.y + r + 54);
897
792
  }
898
793
  if (!Number.isFinite(minX)) {
899
794
  return { minX: 0, minY: 0, maxX: 1200, maxY: 640 };
@@ -901,53 +796,155 @@ function wireGraph(root) {
901
796
  return { minX, minY, maxX, maxY };
902
797
  }
903
798
 
904
- function applyView() {
905
- if (!svg) return;
906
- svg.setAttribute('viewBox', \`\${view.x} \${view.y} \${view.w} \${view.h}\`);
907
- }
908
-
909
799
  function fitView(force = false) {
910
- if (userViewport && !force) {
911
- applyView();
912
- return;
913
- }
800
+ if (userViewport && !force) return;
914
801
  const bounds = contentBounds();
915
- const pad = 80;
802
+ const pad = 56;
916
803
  view = {
917
804
  x: bounds.minX - pad,
918
805
  y: bounds.minY - pad,
919
806
  w: Math.max(360, bounds.maxX - bounds.minX + pad * 2),
920
- h: Math.max(360, bounds.maxY - bounds.minY + pad * 2 + 70)
807
+ h: Math.max(360, bounds.maxY - bounds.minY + pad * 2)
921
808
  };
922
809
  userViewport = false;
923
- applyView();
810
+ }
811
+
812
+ function wrapLines(text, maxWidth, font) {
813
+ if (!text) return [];
814
+ ctx.font = font;
815
+ const words = String(text).split(/\\s+/);
816
+ const lines = [];
817
+ let current = '';
818
+ for (const word of words) {
819
+ const next = current ? \`\${current} \${word}\` : word;
820
+ if (ctx.measureText(next).width <= maxWidth) {
821
+ current = next;
822
+ } else {
823
+ if (current) lines.push(current);
824
+ if (ctx.measureText(word).width <= maxWidth) {
825
+ current = word;
826
+ } else {
827
+ let chunk = '';
828
+ for (const ch of word) {
829
+ const trial = chunk + ch;
830
+ if (ctx.measureText(trial).width <= maxWidth) chunk = trial;
831
+ else {
832
+ if (chunk) lines.push(chunk);
833
+ chunk = ch;
834
+ }
835
+ }
836
+ current = chunk;
837
+ }
838
+ }
839
+ }
840
+ if (current) lines.push(current);
841
+ return lines;
842
+ }
843
+
844
+ function nodeFill(node) {
845
+ if (node.isSubject || node.id === graph.centerId) return '#b85c38';
846
+ if (node.isExternal) return '#8a4530';
847
+ if (node.kind === 'ideaset') return '#e0a24a';
848
+ return '#07a0e5';
849
+ }
850
+
851
+ function nodeStroke(node, hover) {
852
+ if (hover) return '#0bbefb';
853
+ if (node.isSubject || node.id === graph.centerId) return '#0bbefb';
854
+ if (node.isExternal) return 'rgba(184,92,56,0.55)';
855
+ if (node.kind === 'ideaset') return '#d18616';
856
+ return 'rgba(235,228,222,0.35)';
924
857
  }
925
858
 
926
859
  function paint() {
927
- if (!svg) return;
928
- for (const { node, circle, label } of nodeEls) {
929
- const pos = positions.get(node.id);
930
- if (!pos) continue;
931
- circle.setAttribute('cx', String(pos.x));
932
- circle.setAttribute('cy', String(pos.y));
933
- label.setAttribute('x', String(pos.x - 80));
934
- label.setAttribute('y', String(pos.y + 18));
860
+ if (!canvas || !ctx) return;
861
+ if (!userViewport) fitView(false);
862
+ const dpr = Math.min(window.devicePixelRatio || 1, 1.5);
863
+ const nextW = Math.max(1, Math.floor(cssWidth * dpr));
864
+ const nextH = Math.max(1, Math.floor(cssHeight * dpr));
865
+ if (canvas.width !== nextW || canvas.height !== nextH) {
866
+ canvas.width = nextW;
867
+ canvas.height = nextH;
935
868
  }
936
- for (const { edge, line } of edgeEls) {
869
+ ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
870
+ ctx.clearRect(0, 0, cssWidth, cssHeight);
871
+ ctx.fillStyle = '#14100e';
872
+ ctx.fillRect(0, 0, cssWidth, cssHeight);
873
+
874
+ const sx = cssWidth / view.w;
875
+ const sy = cssHeight / view.h;
876
+ ctx.save();
877
+ ctx.translate(-view.x * sx, -view.y * sy);
878
+ ctx.scale(sx, sy);
879
+
880
+ ctx.lineWidth = 1.1 / Math.max(sx, sy);
881
+ ctx.strokeStyle = 'rgba(61,47,40,0.85)';
882
+ ctx.globalAlpha = 0.85;
883
+ for (const edge of simEdges) {
937
884
  const source = positions.get(edge.sourceId);
938
885
  const target = positions.get(edge.targetId);
939
886
  if (!source || !target) continue;
940
- line.setAttribute('x1', String(source.x));
941
- line.setAttribute('y1', String(source.y));
942
- line.setAttribute('x2', String(target.x));
943
- line.setAttribute('y2', String(target.y));
944
- line.setAttribute('opacity', '0.85');
887
+ ctx.beginPath();
888
+ ctx.moveTo(source.x, source.y);
889
+ ctx.lineTo(target.x, target.y);
890
+ ctx.stroke();
945
891
  }
946
- if (!userViewport) {
947
- fitView(false);
948
- } else {
949
- applyView();
892
+ ctx.globalAlpha = 1;
893
+
894
+ pulsePhase = (pulsePhase + 0.035) % (Math.PI * 2);
895
+ for (const node of simNodes) {
896
+ const pos = positions.get(node.id);
897
+ if (!pos) continue;
898
+ const r = nodeRadius(node);
899
+ const hover = node.id === hoverNodeId || node.id === dragNodeId;
900
+ const isSubject = node.isSubject || node.id === graph.centerId;
901
+ if (isSubject) {
902
+ const glow = 4 + Math.sin(pulsePhase) * 3;
903
+ ctx.beginPath();
904
+ ctx.arc(pos.x, pos.y, r + glow, 0, Math.PI * 2);
905
+ ctx.fillStyle = 'rgba(184,92,56,0.22)';
906
+ ctx.fill();
907
+ }
908
+ ctx.beginPath();
909
+ ctx.arc(pos.x, pos.y, r, 0, Math.PI * 2);
910
+ ctx.fillStyle = nodeFill(node);
911
+ ctx.fill();
912
+ ctx.lineWidth = (isSubject ? 2.2 : hover ? 2 : 1.15) / Math.max(sx, sy);
913
+ ctx.strokeStyle = nodeStroke(node, hover);
914
+ ctx.stroke();
915
+
916
+ const meta = formatNodeMeta(node);
917
+ const attrs = formatNodeAttrs(node);
918
+ const nameFont = isSubject ? '600 11px Inter, system-ui, sans-serif' : '600 10px Inter, system-ui, sans-serif';
919
+ const metaFont = '9.5px Inter, system-ui, sans-serif';
920
+ const nameLines = wrapLines(node.name, GRAPH_LABEL_MAX_WIDTH, nameFont);
921
+ const metaLines = wrapLines(meta, GRAPH_LABEL_MAX_WIDTH, metaFont);
922
+ const attrLines = wrapLines(attrs, GRAPH_LABEL_MAX_WIDTH, metaFont);
923
+ let ty = pos.y + r + 12;
924
+ ctx.textAlign = 'center';
925
+ ctx.textBaseline = 'top';
926
+ ctx.fillStyle = isSubject ? '#0bbefb' : '#ebe4de';
927
+ ctx.font = nameFont;
928
+ for (const line of nameLines) {
929
+ ctx.lineWidth = 3 / Math.max(sx, sy);
930
+ ctx.strokeStyle = '#14100e';
931
+ ctx.strokeText(line, pos.x, ty);
932
+ ctx.fillText(line, pos.x, ty);
933
+ ty += 12;
934
+ }
935
+ ctx.fillStyle = '#a89488';
936
+ ctx.font = metaFont;
937
+ for (const line of metaLines) {
938
+ ctx.fillText(line, pos.x, ty);
939
+ ty += 11;
940
+ }
941
+ ctx.fillStyle = '#b85c38';
942
+ for (const line of attrLines) {
943
+ ctx.fillText(line, pos.x, ty);
944
+ ty += 11;
945
+ }
950
946
  }
947
+ ctx.restore();
951
948
  }
952
949
 
953
950
  function stopLoop() {
@@ -961,7 +958,10 @@ function wireGraph(root) {
961
958
  if (animationFrame) return;
962
959
  animationFrame = requestAnimationFrame(() => {
963
960
  animationFrame = 0;
964
- if (!livePhysics) return;
961
+ if (!livePhysics) {
962
+ paint();
963
+ return;
964
+ }
965
965
  const averageSpeed = physicsStep(simNodes, simEdges);
966
966
  paint();
967
967
  if (averageSpeed < EXPORT_PHYSICS_SETTINGS.restSpeed) {
@@ -982,7 +982,7 @@ function wireGraph(root) {
982
982
  }
983
983
 
984
984
  function batchSettle(nodes, edges) {
985
- const iterations = Math.min(120, 48 + nodes.length);
985
+ const iterations = Math.min(96, 36 + Math.floor(nodes.length * 0.45));
986
986
  for (let i = 0; i < iterations; i += 1) {
987
987
  physicsStep(nodes, edges);
988
988
  }
@@ -993,6 +993,34 @@ function wireGraph(root) {
993
993
  calmTicks = EXPORT_PHYSICS_SETTINGS.restTicks;
994
994
  }
995
995
 
996
+ function hitTest(point) {
997
+ let best = null;
998
+ let bestDist = Infinity;
999
+ for (const node of simNodes) {
1000
+ const pos = positions.get(node.id);
1001
+ if (!pos) continue;
1002
+ const dx = point.x - pos.x;
1003
+ const dy = point.y - pos.y;
1004
+ const dist = Math.sqrt(dx * dx + dy * dy);
1005
+ const threshold = nodeRadius(node) + GRAPH_HIT_PAD;
1006
+ if (dist <= threshold && dist < bestDist) {
1007
+ best = node;
1008
+ bestDist = dist;
1009
+ }
1010
+ }
1011
+ return best;
1012
+ }
1013
+
1014
+ function resizeCanvas() {
1015
+ const rect = element.getBoundingClientRect();
1016
+ cssWidth = Math.max(320, Math.floor(rect.width || 1200));
1017
+ cssHeight = Math.max(620, Math.min(1400, 220 + simNodes.length * 18));
1018
+ if (canvas) {
1019
+ canvas.style.width = '100%';
1020
+ canvas.style.height = \`\${cssHeight}px\`;
1021
+ }
1022
+ }
1023
+
996
1024
  function mountGraph(filteredNodes, filteredEdges) {
997
1025
  stopLoop();
998
1026
  simNodes = filteredNodes;
@@ -1000,44 +1028,16 @@ function wireGraph(root) {
1000
1028
  const validIds = new Set(filteredNodes.map(node => node.id));
1001
1029
  pruneState(validIds);
1002
1030
  const width = 1200;
1003
- const height = Math.max(640, Math.min(1400, 220 + filteredNodes.length * 28));
1031
+ const height = Math.max(640, Math.min(1400, 220 + filteredNodes.length * 18));
1004
1032
  seedMissingPositions(filteredNodes, width, height, graph.centerId);
1005
- svg = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
1006
- svg.setAttribute('viewBox', \`0 0 \${width} \${height}\`);
1007
- edgeEls = [];
1008
- for (const edge of filteredEdges) {
1009
- const line = document.createElementNS('http://www.w3.org/2000/svg', 'line');
1010
- svg.appendChild(line);
1011
- edgeEls.push({ edge, line });
1012
- }
1013
- nodeEls = [];
1014
- for (const node of filteredNodes) {
1015
- const group = document.createElementNS('http://www.w3.org/2000/svg', 'g');
1016
- const classes = ['node'];
1017
- if (node.isExternal) classes.push('external');
1018
- if (node.kind === 'ideaset') classes.push('ideaset');
1019
- if (node.isSubject || node.id === graph.centerId) classes.push('subject');
1020
- group.setAttribute('class', classes.join(' '));
1021
- group.dataset.nodeId = node.id;
1022
- const circle = document.createElementNS('http://www.w3.org/2000/svg', 'circle');
1023
- circle.setAttribute('r', node.isSubject || node.id === graph.centerId ? '18' : '13');
1024
- const label = document.createElementNS('http://www.w3.org/2000/svg', 'foreignObject');
1025
- label.setAttribute('width', '160');
1026
- label.setAttribute('height', '96');
1027
- const meta = formatNodeMeta(node);
1028
- const attrs = formatNodeAttrs(node);
1029
- label.innerHTML = \`<div xmlns="http://www.w3.org/1999/xhtml" class="graph-label"><strong>\${escapeXml(node.name)}</strong>\${meta ? \`<span class="meta">\${escapeXml(meta)}</span>\` : ''}\${attrs ? \`<span class="attrs">\${escapeXml(attrs)}</span>\` : ''}</div>\`;
1030
- const title = document.createElementNS('http://www.w3.org/2000/svg', 'title');
1031
- title.textContent = [node.name, meta, attrs].filter(Boolean).join('\\n');
1032
- group.appendChild(title);
1033
- group.appendChild(circle);
1034
- group.appendChild(label);
1035
- svg.appendChild(group);
1036
- nodeEls.push({ node, circle, label, group });
1037
- }
1033
+ canvas = document.createElement('canvas');
1034
+ canvas.setAttribute('role', 'img');
1035
+ canvas.setAttribute('aria-label', \`Requirement graph with \${filteredNodes.length} nodes\`);
1036
+ ctx = canvas.getContext('2d');
1038
1037
  element.innerHTML = '';
1039
- element.appendChild(svg);
1040
- wireViewport(svg);
1038
+ element.appendChild(canvas);
1039
+ resizeCanvas();
1040
+ wireViewport(canvas);
1041
1041
  if (statusText) {
1042
1042
  statusText.textContent = \`\${filteredNodes.length} nodes, \${filteredEdges.length} edges\`;
1043
1043
  }
@@ -1052,7 +1052,7 @@ function wireGraph(root) {
1052
1052
  }
1053
1053
 
1054
1054
  function clientToGraph(event) {
1055
- const rect = svg.getBoundingClientRect();
1055
+ const rect = canvas.getBoundingClientRect();
1056
1056
  const x = view.x + ((event.clientX - rect.left) / rect.width) * view.w;
1057
1057
  const y = view.y + ((event.clientY - rect.top) / rect.height) * view.h;
1058
1058
  return { x, y };
@@ -1069,19 +1069,21 @@ function wireGraph(root) {
1069
1069
  view.x += before.x - after.x;
1070
1070
  view.y += before.y - after.y;
1071
1071
  userViewport = true;
1072
- applyView();
1072
+ paint();
1073
1073
  }, { passive: false });
1074
1074
 
1075
1075
  target.addEventListener('pointerdown', (event) => {
1076
- const nodeGroup = event.target.closest?.('.node');
1077
- if (nodeGroup) {
1078
- const nodeId = nodeGroup.dataset.nodeId;
1079
- dragNodeId = nodeId;
1076
+ const point = clientToGraph(event);
1077
+ const hit = hitTest(point);
1078
+ if (hit) {
1079
+ dragNodeId = hit.id;
1080
1080
  dragMoved = false;
1081
- pinnedIds.add(nodeId);
1082
- velocities.set(nodeId, { vx: 0, vy: 0 });
1081
+ pinnedIds.add(hit.id);
1082
+ velocities.set(hit.id, { vx: 0, vy: 0 });
1083
+ target.classList.add('is-dragging-node');
1083
1084
  target.setPointerCapture(event.pointerId);
1084
1085
  event.preventDefault();
1086
+ paint();
1085
1087
  return;
1086
1088
  }
1087
1089
  panMode = true;
@@ -1107,11 +1109,19 @@ function wireGraph(root) {
1107
1109
  view.x = panOrigin.viewX - dx;
1108
1110
  view.y = panOrigin.viewY - dy;
1109
1111
  userViewport = true;
1110
- applyView();
1112
+ paint();
1113
+ return;
1114
+ }
1115
+ const hit = hitTest(clientToGraph(event));
1116
+ const nextHover = hit?.id || null;
1117
+ if (nextHover !== hoverNodeId) {
1118
+ hoverNodeId = nextHover;
1119
+ paint();
1111
1120
  }
1112
1121
  });
1113
1122
 
1114
1123
  target.addEventListener('pointerup', (event) => {
1124
+ target.classList.remove('is-dragging-node');
1115
1125
  if (dragNodeId) {
1116
1126
  const node = simNodes.find(item => item.id === dragNodeId);
1117
1127
  const moved = dragMoved;
@@ -1119,6 +1129,7 @@ function wireGraph(root) {
1119
1129
  dragNodeId = null;
1120
1130
  pinnedIds.delete(nodeId);
1121
1131
  wake();
1132
+ paint();
1122
1133
  if (!moved && node?.pageUrl && Date.now() > suppressClickUntil) {
1123
1134
  window.location.href = resolveGraphNodeUrl(node.pageUrl);
1124
1135
  } else if (moved) {
@@ -1134,6 +1145,7 @@ function wireGraph(root) {
1134
1145
  });
1135
1146
 
1136
1147
  target.addEventListener('pointercancel', () => {
1148
+ target.classList.remove('is-dragging-node');
1137
1149
  if (dragNodeId) {
1138
1150
  pinnedIds.delete(dragNodeId);
1139
1151
  dragNodeId = null;
@@ -1142,6 +1154,12 @@ function wireGraph(root) {
1142
1154
  panMode = false;
1143
1155
  panOrigin = null;
1144
1156
  target.classList.remove('is-panning');
1157
+ paint();
1158
+ });
1159
+
1160
+ window.addEventListener('resize', () => {
1161
+ resizeCanvas();
1162
+ paint();
1145
1163
  });
1146
1164
  }
1147
1165
 
@@ -1193,10 +1211,12 @@ function wireGraph(root) {
1193
1211
  wake();
1194
1212
  } else {
1195
1213
  stopLoop();
1214
+ paint();
1196
1215
  }
1197
1216
  });
1198
1217
  fitButton?.addEventListener('click', () => {
1199
1218
  fitView(true);
1219
+ paint();
1200
1220
  });
1201
1221
  resetButton?.addEventListener('click', () => {
1202
1222
  if (searchInput) searchInput.value = '';