project-graph-mcp 2.2.6 → 2.3.1

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 (155) hide show
  1. package/ARCHITECTURE.md +81 -0
  2. package/CHANGELOG.md +57 -0
  3. package/README.md +9 -4
  4. package/package.json +4 -13
  5. package/project-graph-mcp-2.3.0.tgz +0 -0
  6. package/src/compact/expand.js +1 -1
  7. package/src/core/graph-builder.js +2 -2
  8. package/src/core/parser.js +2 -2
  9. package/src/network/server.js +1 -2
  10. package/src/network/web-server.js +1 -1
  11. package/vendor/symbiote-node/CHANGELOG.md +31 -0
  12. package/vendor/symbiote-node/LICENSE +21 -0
  13. package/vendor/symbiote-node/README.md +206 -0
  14. package/vendor/symbiote-node/canvas/AutoLayout.js +725 -0
  15. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.css.js +73 -0
  16. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.js +93 -0
  17. package/vendor/symbiote-node/canvas/Breadcrumb/Breadcrumb.tpl.js +9 -0
  18. package/vendor/symbiote-node/canvas/CanvasConnectionRenderer.js +962 -0
  19. package/vendor/symbiote-node/canvas/ConnectionRenderer.js +1468 -0
  20. package/vendor/symbiote-node/canvas/FlowSimulator.js +323 -0
  21. package/vendor/symbiote-node/canvas/ForceLayout.js +189 -0
  22. package/vendor/symbiote-node/canvas/ForceWorker.js +1325 -0
  23. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.css.js +97 -0
  24. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.js +176 -0
  25. package/vendor/symbiote-node/canvas/GraphTabs/GraphTabs.tpl.js +12 -0
  26. package/vendor/symbiote-node/canvas/LODManager.js +88 -0
  27. package/vendor/symbiote-node/canvas/Minimap/Minimap.css.js +71 -0
  28. package/vendor/symbiote-node/canvas/Minimap/Minimap.js +207 -0
  29. package/vendor/symbiote-node/canvas/Minimap/Minimap.tpl.js +9 -0
  30. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.css.js +261 -0
  31. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.js +1840 -0
  32. package/vendor/symbiote-node/canvas/NodeCanvas/NodeCanvas.tpl.js +22 -0
  33. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.css.js +97 -0
  34. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.js +132 -0
  35. package/vendor/symbiote-node/canvas/NodeSearch/NodeSearch.tpl.js +21 -0
  36. package/vendor/symbiote-node/canvas/NodeViewManager.js +584 -0
  37. package/vendor/symbiote-node/canvas/PinExpansion.js +131 -0
  38. package/vendor/symbiote-node/canvas/PseudoConnection.js +80 -0
  39. package/vendor/symbiote-node/canvas/SubgraphManager.js +201 -0
  40. package/vendor/symbiote-node/canvas/SubgraphRouter.js +443 -0
  41. package/vendor/symbiote-node/canvas/ViewportActions.js +446 -0
  42. package/vendor/symbiote-node/core/Connection.js +45 -0
  43. package/vendor/symbiote-node/core/Editor.js +451 -0
  44. package/vendor/symbiote-node/core/Frame.js +31 -0
  45. package/vendor/symbiote-node/core/GraphMermaid.js +348 -0
  46. package/vendor/symbiote-node/core/GraphText.js +210 -0
  47. package/vendor/symbiote-node/core/Node.js +143 -0
  48. package/vendor/symbiote-node/core/Portal.js +104 -0
  49. package/vendor/symbiote-node/core/Socket.js +185 -0
  50. package/vendor/symbiote-node/core/SubgraphNode.js +125 -0
  51. package/vendor/symbiote-node/index.js +103 -0
  52. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.css.js +361 -0
  53. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.js +332 -0
  54. package/vendor/symbiote-node/inspector/InspectorPanel/InspectorPanel.tpl.js +96 -0
  55. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.css.js +104 -0
  56. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.js +133 -0
  57. package/vendor/symbiote-node/inspector/TemplatePreview/TemplatePreview.tpl.js +33 -0
  58. package/vendor/symbiote-node/interactions/ConnectFlow.js +307 -0
  59. package/vendor/symbiote-node/interactions/Drag.js +102 -0
  60. package/vendor/symbiote-node/interactions/Selector.js +132 -0
  61. package/vendor/symbiote-node/interactions/SnapGrid.js +65 -0
  62. package/vendor/symbiote-node/interactions/Zoom.js +140 -0
  63. package/vendor/symbiote-node/layout/ActionZone/ActionZone.css.js +88 -0
  64. package/vendor/symbiote-node/layout/ActionZone/ActionZone.js +254 -0
  65. package/vendor/symbiote-node/layout/ActionZone/ActionZone.tpl.js +11 -0
  66. package/vendor/symbiote-node/layout/Layout/Layout.css.js +88 -0
  67. package/vendor/symbiote-node/layout/Layout/Layout.js +622 -0
  68. package/vendor/symbiote-node/layout/Layout/Layout.tpl.js +25 -0
  69. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.css.js +293 -0
  70. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.js +467 -0
  71. package/vendor/symbiote-node/layout/LayoutNode/LayoutNode.tpl.js +33 -0
  72. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.css.js +46 -0
  73. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.js +102 -0
  74. package/vendor/symbiote-node/layout/LayoutPreview/LayoutPreview.tpl.js +6 -0
  75. package/vendor/symbiote-node/layout/LayoutRouter/LayoutRouter.js +156 -0
  76. package/vendor/symbiote-node/layout/LayoutRouter/routerSync.js +250 -0
  77. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.css.js +379 -0
  78. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.js +263 -0
  79. package/vendor/symbiote-node/layout/LayoutSidebar/LayoutSidebar.tpl.js +20 -0
  80. package/vendor/symbiote-node/layout/LayoutSidebar/SidebarSection.js +183 -0
  81. package/vendor/symbiote-node/layout/LayoutTree.js +246 -0
  82. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.css.js +43 -0
  83. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.js +89 -0
  84. package/vendor/symbiote-node/layout/PanelMenu/PanelMenu.tpl.js +14 -0
  85. package/vendor/symbiote-node/layout/index.js +16 -0
  86. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.css.js +61 -0
  87. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.js +79 -0
  88. package/vendor/symbiote-node/menu/ContextMenu/ContextMenu.tpl.js +19 -0
  89. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.css.js +41 -0
  90. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.js +24 -0
  91. package/vendor/symbiote-node/node/CtrlItem/CtrlItem.tpl.js +16 -0
  92. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.css.js +65 -0
  93. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.js +29 -0
  94. package/vendor/symbiote-node/node/GraphFrame/GraphFrame.tpl.js +13 -0
  95. package/vendor/symbiote-node/node/GraphNode/GraphNode.css.js +683 -0
  96. package/vendor/symbiote-node/node/GraphNode/GraphNode.js +92 -0
  97. package/vendor/symbiote-node/node/GraphNode/GraphNode.tpl.js +17 -0
  98. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.js +25 -0
  99. package/vendor/symbiote-node/node/NodeSocket/NodeSocket.tpl.js +7 -0
  100. package/vendor/symbiote-node/node/PortItem/PortItem.css.js +90 -0
  101. package/vendor/symbiote-node/node/PortItem/PortItem.js +87 -0
  102. package/vendor/symbiote-node/node/PortItem/PortItem.tpl.js +10 -0
  103. package/vendor/symbiote-node/package.json +59 -0
  104. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.css.js +143 -0
  105. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.js +131 -0
  106. package/vendor/symbiote-node/palette/PaletteBrowser/PaletteBrowser.tpl.js +16 -0
  107. package/vendor/symbiote-node/plugins/History.js +384 -0
  108. package/vendor/symbiote-node/plugins/Readonly.js +59 -0
  109. package/vendor/symbiote-node/shapes/CircleShape.js +80 -0
  110. package/vendor/symbiote-node/shapes/CommentShape.js +35 -0
  111. package/vendor/symbiote-node/shapes/DiamondShape.js +115 -0
  112. package/vendor/symbiote-node/shapes/NodeShape.js +80 -0
  113. package/vendor/symbiote-node/shapes/PillShape.js +91 -0
  114. package/vendor/symbiote-node/shapes/RectShape.js +72 -0
  115. package/vendor/symbiote-node/shapes/SVGShape.js +494 -0
  116. package/vendor/symbiote-node/shapes/index.js +53 -0
  117. package/vendor/symbiote-node/themes/Palette.js +32 -0
  118. package/vendor/symbiote-node/themes/Skin.js +113 -0
  119. package/vendor/symbiote-node/themes/Theme.js +84 -0
  120. package/vendor/symbiote-node/themes/carbon.js +137 -0
  121. package/vendor/symbiote-node/themes/dark.js +137 -0
  122. package/vendor/symbiote-node/themes/ebook.js +138 -0
  123. package/vendor/symbiote-node/themes/grey.js +137 -0
  124. package/vendor/symbiote-node/themes/light.js +137 -0
  125. package/vendor/symbiote-node/themes/neon.js +138 -0
  126. package/vendor/symbiote-node/themes/pcb.js +273 -0
  127. package/vendor/symbiote-node/themes/synthwave.js +137 -0
  128. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.css.js +86 -0
  129. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.js +128 -0
  130. package/vendor/symbiote-node/toolbar/QuickToolbar/QuickToolbar.tpl.js +29 -0
  131. package/web/app.js +9 -5
  132. package/web/components/canvas-graph.js +1705 -0
  133. package/web/components/code-block.js +1 -1
  134. package/web/components/event-feed/CodeWidget.js +32 -0
  135. package/web/components/event-feed/EventWidget.js +97 -0
  136. package/web/components/event-feed/ListWidget.js +57 -0
  137. package/web/components/event-feed/MiniGraphWidget.js +159 -0
  138. package/web/components/follow-ribbon.js +134 -0
  139. package/web/dashboard.js +1 -1
  140. package/web/follow-controller.js +241 -0
  141. package/web/index.html +4 -0
  142. package/web/panels/ActionBoard/ActionBoard.js +1 -1
  143. package/web/panels/SettingsPanel/SettingsPanel.tpl.js +1 -1
  144. package/web/panels/code-viewer.js +50 -15
  145. package/web/panels/dep-graph.js +2691 -7
  146. package/web/panels/file-tree.js +5 -2
  147. package/web/panels/live-monitor.js +75 -3
  148. package/web/style.css +39 -0
  149. package/docs/img/explorer-compact.jpg +0 -0
  150. package/docs/img/explorer-expanded.jpg +0 -0
  151. package/src/.contextignore +0 -22
  152. package/src/.project-graph-cache.json +0 -1
  153. package/src/compact/.project-graph-cache.json +0 -1
  154. package/web/.project-graph-cache.json +0 -1
  155. package/web/panels/SettingsPanel/.project-graph-cache.json +0 -1
@@ -0,0 +1,494 @@
1
+ /**
2
+ * SVGShape — universal shape from any SVG path
3
+ *
4
+ * Uses SVGPathElement.getPointAtLength() for dynamic connector placement.
5
+ * Any SVG icon path can become a node shape — the outer contour defines
6
+ * the visual fill and connector positions are computed along the perimeter.
7
+ *
8
+ * Port placement strategy:
9
+ * - Inputs placed on left portion of path perimeter
10
+ * - Outputs placed on right portion of path perimeter
11
+ * - Connector angle = normal from center → edge point
12
+ * - Aspect ratio of original SVG is preserved (xMidYMid meet)
13
+ *
14
+ * @module symbiote-node/shapes/SVGShape
15
+ */
16
+
17
+ import { NodeShape } from './NodeShape.js';
18
+
19
+ /**
20
+ * Offscreen SVG namespace for path computation
21
+ * @type {SVGSVGElement|null}
22
+ */
23
+ let _offscreenSVG = null;
24
+
25
+ /**
26
+ * Get or create an offscreen SVG element for path calculations
27
+ * @returns {SVGSVGElement}
28
+ */
29
+ function getOffscreenSVG() {
30
+ if (_offscreenSVG) return _offscreenSVG;
31
+ if (typeof document === 'undefined') return null;
32
+ _offscreenSVG = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
33
+ _offscreenSVG.style.position = 'absolute';
34
+ _offscreenSVG.style.width = '0';
35
+ _offscreenSVG.style.height = '0';
36
+ _offscreenSVG.style.overflow = 'hidden';
37
+ document.body.appendChild(_offscreenSVG);
38
+ return _offscreenSVG;
39
+ }
40
+
41
+ /**
42
+ * Compute scaling params for viewBox → element mapping
43
+ * Preserves aspect ratio (equivalent to SVG preserveAspectRatio="xMidYMid meet")
44
+ *
45
+ * @param {number[]} vb - [x, y, w, h] viewBox
46
+ * @param {{ width: number, height: number }} size - element size
47
+ * @returns {{ scale: number, offsetX: number, offsetY: number }}
48
+ */
49
+ function computeMapping(vb, size) {
50
+ const [vx, vy, vw, vh] = vb;
51
+ const scale = Math.min(size.width / vw, size.height / vh);
52
+ const renderedW = vw * scale;
53
+ const renderedH = vh * scale;
54
+ return {
55
+ scale,
56
+ offsetX: (size.width - renderedW) / 2 - vx * scale,
57
+ offsetY: (size.height - renderedH) / 2 - vy * scale,
58
+ };
59
+ }
60
+
61
+ export class SVGShape extends NodeShape {
62
+ /** @type {string} */
63
+ name;
64
+
65
+ /** @type {string} - SVG path d attribute */
66
+ pathData;
67
+
68
+ /** @type {string} - viewBox of original SVG */
69
+ viewBox;
70
+
71
+ /** @type {number[]} - parsed viewBox [x, y, w, h] */
72
+ #vb;
73
+
74
+ /** @type {boolean} - whether shape has standard header */
75
+ #header;
76
+
77
+ /** @type {{ minWidth: number, minHeight: number }} */
78
+ #minSize;
79
+
80
+ /** @type {Map<string, {x:number,y:number,angle:number}>} - position cache */
81
+ #posCache = new Map();
82
+
83
+ /**
84
+ * @param {string} name - Shape identifier
85
+ * @param {object} options
86
+ * @param {string} options.pathData - SVG path d attribute
87
+ * @param {string} [options.viewBox='0 0 24 24'] - Original SVG viewBox
88
+ * @param {boolean} [options.header=false] - Show header/controls
89
+ * @param {{ minWidth?: number, minHeight?: number }} [options.minSize]
90
+ */
91
+ constructor(name, { pathData, viewBox = '0 0 24 24', header = false, minSize = {} }) {
92
+ super();
93
+ this.name = name;
94
+ this.pathData = pathData;
95
+ this.viewBox = viewBox;
96
+ this.#vb = viewBox.split(' ').map(Number);
97
+ this.#header = header;
98
+ this.#minSize = {
99
+ minWidth: minSize.minWidth || 100,
100
+ minHeight: minSize.minHeight || 100,
101
+ };
102
+ }
103
+
104
+ /**
105
+ * Scale a point from viewBox coordinates to element pixel coordinates
106
+ * Uses aspect-ratio-preserving mapping (xMidYMid meet)
107
+ *
108
+ * @param {number} px - x in viewBox
109
+ * @param {number} py - y in viewBox
110
+ * @param {{ width: number, height: number }} size - element size
111
+ * @returns {{ x: number, y: number }}
112
+ */
113
+ #scalePoint(px, py, size) {
114
+ const { scale, offsetX, offsetY } = computeMapping(this.#vb, size);
115
+ return {
116
+ x: px * scale + offsetX,
117
+ y: py * scale + offsetY,
118
+ };
119
+ }
120
+
121
+ /**
122
+ * Get center of SVG shape in viewBox coordinates
123
+ * @returns {{ x: number, y: number }}
124
+ */
125
+ #getCenter() {
126
+ const [vx, vy, vw, vh] = this.#vb;
127
+ return { x: vx + vw / 2, y: vy + vh / 2 };
128
+ }
129
+
130
+ /**
131
+ * Get a path element for computations (uses offscreen SVG)
132
+ * @returns {SVGPathElement|null}
133
+ */
134
+ #getPathElement() {
135
+ const svg = getOffscreenSVG();
136
+ if (!svg) return null;
137
+ let pathEl = svg.querySelector(`[data-shape="${this.name}"]`);
138
+ if (!pathEl) {
139
+ pathEl = document.createElementNS('http://www.w3.org/2000/svg', 'path');
140
+ pathEl.setAttribute('d', this.pathData);
141
+ pathEl.setAttribute('data-shape', this.name);
142
+ svg.appendChild(pathEl);
143
+ }
144
+ return pathEl;
145
+ }
146
+
147
+ /**
148
+ * Find the point on the SVG path that a ray from center at a given angle hits.
149
+ * Uses dense sampling along the path perimeter and finds the point
150
+ * whose angle from center best matches the target angle.
151
+ *
152
+ * @param {number} targetAngle - angle in radians from center
153
+ * @param {SVGPathElement} pathEl
154
+ * @returns {{ x: number, y: number }} - point in viewBox coordinates
155
+ */
156
+ #findPointAtAngle(targetAngle, pathEl) {
157
+ const totalLen = pathEl.getTotalLength();
158
+ const center = this.#getCenter();
159
+
160
+ // Phase 1: coarse scan (128 samples)
161
+ let bestDist = Infinity;
162
+ let bestLen = 0;
163
+ const COARSE = 128;
164
+
165
+ for (let i = 0; i <= COARSE; i++) {
166
+ const len = (totalLen * i) / COARSE;
167
+ const pt = pathEl.getPointAtLength(len);
168
+ const angle = Math.atan2(pt.y - center.y, pt.x - center.x);
169
+ let diff = Math.abs(angle - targetAngle);
170
+ if (diff > Math.PI) diff = 2 * Math.PI - diff;
171
+ if (diff < bestDist) {
172
+ bestDist = diff;
173
+ bestLen = len;
174
+ }
175
+ }
176
+
177
+ // Phase 2: refine with binary-like search around bestLen
178
+ const searchRadius = totalLen / COARSE;
179
+ const FINE = 32;
180
+ const startLen = Math.max(0, bestLen - searchRadius);
181
+ const endLen = Math.min(totalLen, bestLen + searchRadius);
182
+
183
+ for (let i = 0; i <= FINE; i++) {
184
+ const len = startLen + ((endLen - startLen) * i) / FINE;
185
+ const pt = pathEl.getPointAtLength(len);
186
+ const angle = Math.atan2(pt.y - center.y, pt.x - center.x);
187
+ let diff = Math.abs(angle - targetAngle);
188
+ if (diff > Math.PI) diff = 2 * Math.PI - diff;
189
+ if (diff < bestDist) {
190
+ bestDist = diff;
191
+ bestLen = len;
192
+ }
193
+ }
194
+
195
+ return pathEl.getPointAtLength(bestLen);
196
+ }
197
+
198
+ /**
199
+ * Get socket position on the shape outline.
200
+ * Results are cached — same params always return same position.
201
+ *
202
+ * @param {'input'|'output'} side
203
+ * @param {number} index - ordinal index of this port
204
+ * @param {number} total - total ports on this side
205
+ * @param {{ width: number, height: number }} size - node dimensions
206
+ * @returns {{ x: number, y: number, angle: number }}
207
+ */
208
+ getSocketPosition(side, index, total, size) {
209
+ // Cache key: position depends on side, index, total, and element size
210
+ const key = `${side}|${index}|${total}|${size.width}|${size.height}`;
211
+ if (this.#posCache.has(key)) return this.#posCache.get(key);
212
+
213
+ const pathEl = this.#getPathElement();
214
+
215
+ if (!pathEl) {
216
+ const y = size.height * (index + 1) / (total + 1);
217
+ const result = side === 'input'
218
+ ? { x: 0, y, angle: 180 }
219
+ : { x: size.width, y, angle: 0 };
220
+ this.#posCache.set(key, result);
221
+ return result;
222
+ }
223
+
224
+ // Distribute ports along the relevant side of the path perimeter
225
+ const centerAngle = side === 'input' ? Math.PI : 0;
226
+ const arcSpan = Math.PI * 0.6; // 108° spread
227
+ let targetAngle;
228
+
229
+ if (total === 1) {
230
+ targetAngle = centerAngle;
231
+ } else {
232
+ const startAngle = centerAngle - arcSpan / 2;
233
+ const step = arcSpan / (total - 1);
234
+ targetAngle = startAngle + step * index;
235
+ }
236
+
237
+ const pt = this.#findPointAtAngle(targetAngle, pathEl);
238
+ const scaled = this.#scalePoint(pt.x, pt.y, size);
239
+
240
+ // Compute outward surface normal (same as getEdgePoint)
241
+ const totalLen = pathEl.getTotalLength();
242
+ const center = this.#getCenter();
243
+ let bestLen = 0, bestDist = Infinity;
244
+ const SCAN = 128;
245
+ for (let i = 0; i <= SCAN; i++) {
246
+ const len = (totalLen * i) / SCAN;
247
+ const p = pathEl.getPointAtLength(len);
248
+ const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
249
+ if (dist < bestDist) { bestDist = dist; bestLen = len; }
250
+ }
251
+ const delta = 0.5;
252
+ const prevPt = pathEl.getPointAtLength(Math.max(0, bestLen - delta));
253
+ const nextPt = pathEl.getPointAtLength(Math.min(totalLen, bestLen + delta));
254
+ const tx = nextPt.x - prevPt.x, ty = nextPt.y - prevPt.y;
255
+ let nx = -ty, ny = tx;
256
+ const radX = pt.x - center.x, radY = pt.y - center.y;
257
+ if (nx * radX + ny * radY < 0) { nx = ty; ny = -tx; }
258
+ const angleDeg = Math.atan2(ny, nx) * 180 / Math.PI;
259
+
260
+ const result = { x: scaled.x, y: scaled.y, angle: angleDeg };
261
+ this.#posCache.set(key, result);
262
+ return result;
263
+ }
264
+
265
+ /**
266
+ * Get edge point at a specific angle (direction toward target node).
267
+ * Used for dynamic connectors that slide along the perimeter.
268
+ *
269
+ * @param {number} angle - angle in radians from center to target
270
+ * @param {{ width: number, height: number }} size - element dimensions
271
+ * @returns {{ x: number, y: number, angle: number }}
272
+ */
273
+ getEdgePoint(angle, size) {
274
+ // Round to 3 decimal places (~0.06° precision, invisible jitter)
275
+ const rounded = Math.round(angle * 1000) / 1000;
276
+ const key = `edge|${rounded}|${size.width}|${size.height}`;
277
+ if (this.#posCache.has(key)) return this.#posCache.get(key);
278
+
279
+ const pathEl = this.#getPathElement();
280
+ if (!pathEl) {
281
+ const cx = size.width / 2;
282
+ const cy = size.height / 2;
283
+ return { x: cx + Math.cos(angle) * cx, y: cy + Math.sin(angle) * cy, angle: angle * 180 / Math.PI };
284
+ }
285
+
286
+ const pt = this.#findPointAtAngle(angle, pathEl);
287
+ const scaled = this.#scalePoint(pt.x, pt.y, size);
288
+
289
+ // Compute outward surface normal (perpendicular to edge), NOT radial angle.
290
+ // For polygons, the radial angle varies across a flat edge,
291
+ // but the surface normal is constant — this gives correct perpendicular stubs.
292
+ const totalLen = pathEl.getTotalLength();
293
+ const center = this.#getCenter();
294
+
295
+ // Find the path length for this point (re-use findPointAtAngle's logic)
296
+ let bestLen = 0, bestDist = Infinity;
297
+ const SCAN = 128;
298
+ for (let i = 0; i <= SCAN; i++) {
299
+ const len = (totalLen * i) / SCAN;
300
+ const p = pathEl.getPointAtLength(len);
301
+ const dist = (p.x - pt.x) ** 2 + (p.y - pt.y) ** 2;
302
+ if (dist < bestDist) { bestDist = dist; bestLen = len; }
303
+ }
304
+
305
+ // Sample neighbors to get tangent vector
306
+ const delta = 0.5; // small step along path
307
+ const prevLen = Math.max(0, bestLen - delta);
308
+ const nextLen = Math.min(totalLen, bestLen + delta);
309
+ const prevPt = pathEl.getPointAtLength(prevLen);
310
+ const nextPt = pathEl.getPointAtLength(nextLen);
311
+
312
+ // Tangent = direction along edge
313
+ const tx = nextPt.x - prevPt.x;
314
+ const ty = nextPt.y - prevPt.y;
315
+
316
+ // Normal = tangent rotated 90° (two candidates: +90° and -90°)
317
+ // Pick the one pointing outward (away from center)
318
+ let nx = -ty, ny = tx; // candidate 1: rotate -90°
319
+ const radX = pt.x - center.x;
320
+ const radY = pt.y - center.y;
321
+ // Dot product with radial vector — if negative, flip
322
+ if (nx * radX + ny * radY < 0) {
323
+ nx = ty; ny = -tx; // candidate 2: rotate +90°
324
+ }
325
+
326
+ const angleDeg = Math.atan2(ny, nx) * 180 / Math.PI;
327
+
328
+ const result = { x: scaled.x, y: scaled.y, angle: angleDeg };
329
+
330
+ // Bounded cache: evict oldest when exceeding 360 entries
331
+ if (this.#posCache.size > 360) {
332
+ const first = this.#posCache.keys().next().value;
333
+ this.#posCache.delete(first);
334
+ }
335
+ this.#posCache.set(key, result);
336
+ return result;
337
+ }
338
+
339
+ /**
340
+ * Get a pin position on a specific side of the shape.
341
+ *
342
+ * Side-based placement: pins are placed along a side edge (top/right/bottom/left).
343
+ * The position within the side is controlled by t (0 = start, 1 = end).
344
+ *
345
+ * @param {'top'|'right'|'bottom'|'left'} side - which side to place pin on
346
+ * @param {number} t - position along the side (0..1), 0.5 = center
347
+ * @param {{ width: number, height: number }} size - element dimensions
348
+ * @returns {{ x: number, y: number, angle: number }} - angle is outward normal in degrees
349
+ */
350
+ getSidePosition(side, t, size) {
351
+ const key = `side|${side}|${(t * 100) | 0}|${size.width}|${size.height}`;
352
+ if (this.#posCache.has(key)) return this.#posCache.get(key);
353
+
354
+ // Outward normal angles for each side (screen coords: Y down)
355
+ const NORMALS = { top: -90, right: 0, bottom: 90, left: 180 };
356
+ const angleDeg = NORMALS[side];
357
+
358
+ // For shapes with pathData: sample points on the side's angular range
359
+ // and interpolate along the edge
360
+ const pathEl = this.#getPathElement();
361
+ if (pathEl) {
362
+ // Angular ranges for each side (radians, screen coords)
363
+ // Generous ranges that cover the flat + diagonal edges
364
+ const RANGES = {
365
+ right: { from: -Math.PI / 4, to: Math.PI / 4 },
366
+ bottom: { from: Math.PI / 4, to: 3 * Math.PI / 4 },
367
+ left: { from: 3 * Math.PI / 4, to: 5 * Math.PI / 4 },
368
+ top: { from: -3 * Math.PI / 4, to: -Math.PI / 4 },
369
+ };
370
+
371
+ const range = RANGES[side];
372
+ // Use side-level caching for points to prevent SVG DOM explosion
373
+ const sideKey = `sidepts|${side}|${size.width}|${size.height}`;
374
+ let sidePoints;
375
+
376
+ if (this.#posCache.has(sideKey)) {
377
+ sidePoints = this.#posCache.get(sideKey);
378
+ } else {
379
+ sidePoints = [];
380
+ const SAMPLES = 16;
381
+ for (let i = 0; i <= SAMPLES; i++) {
382
+ const a = range.from + (range.to - range.from) * (i / SAMPLES);
383
+ const pt = this.#findPointAtAngle(a, pathEl);
384
+ const sp = this.#scalePoint(pt.x, pt.y, size);
385
+ sidePoints.push(sp);
386
+ }
387
+ this.#posCache.set(sideKey, sidePoints);
388
+ }
389
+
390
+ // Use inset range (20%-80%) to avoid placing on corners
391
+ const MARGIN = 0.2;
392
+ const effectiveT = MARGIN + t * (1 - 2 * MARGIN);
393
+ const idx = effectiveT * (sidePoints.length - 1);
394
+ const lo = Math.floor(idx);
395
+ const hi = Math.min(lo + 1, sidePoints.length - 1);
396
+ const frac = idx - lo;
397
+
398
+ const x = sidePoints[lo].x + (sidePoints[hi].x - sidePoints[lo].x) * frac;
399
+ const y = sidePoints[lo].y + (sidePoints[hi].y - sidePoints[lo].y) * frac;
400
+
401
+ const result = { x, y, angle: angleDeg };
402
+ this.#posCache.set(key, result);
403
+ return result;
404
+ }
405
+
406
+ // Fallback for shapes without path: rectangle approximation
407
+ let x, y;
408
+ switch (side) {
409
+ case 'top': x = size.width * (0.2 + t * 0.6); y = 0; break;
410
+ case 'right': x = size.width; y = size.height * (0.2 + t * 0.6); break;
411
+ case 'bottom': x = size.width * (0.2 + t * 0.6); y = size.height; break;
412
+ case 'left': x = 0; y = size.height * (0.2 + t * 0.6); break;
413
+ }
414
+
415
+ const result = { x, y, angle: angleDeg };
416
+ this.#posCache.set(key, result);
417
+ return result;
418
+ }
419
+
420
+ getClipPath(size) {
421
+ return null; // We use SVG background layer instead of clip-path
422
+ }
423
+
424
+ getOutlinePath(size) {
425
+ return this.pathData;
426
+ }
427
+
428
+ getBorderRadius() {
429
+ return '0';
430
+ }
431
+
432
+ get hasHeader() {
433
+ return this.#header;
434
+ }
435
+
436
+ get hasControls() {
437
+ return this.#header;
438
+ }
439
+
440
+ getMinSize() {
441
+ return this.#minSize;
442
+ }
443
+ }
444
+
445
+ // --- Preset SVG shapes (Material Symbols paths) ---
446
+
447
+ /**
448
+ * Register an SVG shape from a path string
449
+ * @param {string} name
450
+ * @param {string} pathData - SVG d attribute
451
+ * @param {object} [options]
452
+ */
453
+ export function createSVGShape(name, pathData, options = {}) {
454
+ return new SVGShape(name, { pathData, ...options });
455
+ }
456
+
457
+ // Common icon paths from Material Symbols (24x24 viewBox)
458
+ export const SVG_PRESETS = {
459
+ // Hexagon
460
+ hexagon: 'M12 2L22 8.5V15.5L12 22L2 15.5V8.5Z',
461
+
462
+ // Pentagon
463
+ pentagon: 'M12 2L22 9.27L18.18 21H5.82L2 9.27Z',
464
+
465
+ // Star (5-pointed)
466
+ star: 'M12 2L15.09 8.26L22 9.27L17 14.14L18.18 21.02L12 17.77L5.82 21.02L7 14.14L2 9.27L8.91 8.26Z',
467
+
468
+ // Cloud
469
+ cloud: 'M19.35 10.04A7.49 7.49 0 0012 4C9.11 4 6.6 5.64 5.35 8.04A5.994 5.994 0 000 14c0 3.31 2.69 6 6 6h13c2.76 0 5-2.24 5-5 0-2.64-2.05-4.78-4.65-4.96z',
470
+
471
+ // Shield
472
+ shield: 'M12 1L3 5v6c0 5.55 3.84 10.74 9 12 5.16-1.26 9-6.45 9-12V5l-9-4z',
473
+
474
+ // Octagon
475
+ octagon: 'M7.86 2H16.14L22 7.86V16.14L16.14 22H7.86L2 16.14V7.86Z',
476
+
477
+ // Parallelogram
478
+ parallelogram: 'M6 2H22L18 22H2Z',
479
+
480
+ // Trapezoid
481
+ trapezoid: 'M4 22H20L23 2H1Z',
482
+
483
+ // Cylinder (approximation)
484
+ cylinder: 'M4 6C4 4 8 2 12 2S20 4 20 6V18C20 20 16 22 12 22S4 20 4 18Z',
485
+
486
+ // Database
487
+ database: 'M12 3C7.58 3 4 4.79 4 7V17C4 19.21 7.59 21 12 21S20 19.21 20 17V7C20 4.79 16.42 3 12 3Z',
488
+
489
+ // Lightning bolt
490
+ bolt: 'M7 2V13H10V22L17 10H13L17 2Z',
491
+
492
+ // Heart
493
+ heart: 'M12 21.35L10.55 20.03C5.4 15.36 2 12.28 2 8.5C2 5.42 4.42 3 7.5 3C9.24 3 10.91 3.81 12 5.09C13.09 3.81 14.76 3 16.5 3C19.58 3 22 5.42 22 8.5C22 12.28 18.6 15.36 13.45 20.04L12 21.35Z',
494
+ };
@@ -0,0 +1,53 @@
1
+ /**
2
+ * Shape registry — maps shape names to implementations
3
+ * @module symbiote-node/shapes/index
4
+ */
5
+
6
+ import { NodeShape } from './NodeShape.js';
7
+ import { RectShape } from './RectShape.js';
8
+ import { PillShape } from './PillShape.js';
9
+ import { CircleShape } from './CircleShape.js';
10
+ import { DiamondShape } from './DiamondShape.js';
11
+ import { CommentShape } from './CommentShape.js';
12
+ import { SVGShape, createSVGShape, SVG_PRESETS } from './SVGShape.js';
13
+
14
+ /** @type {Map<string, NodeShape>} */
15
+ const registry = new Map();
16
+
17
+ // Register built-in shapes
18
+ const RECT = new RectShape();
19
+ const PILL = new PillShape();
20
+ const CIRCLE = new CircleShape();
21
+ const DIAMOND = new DiamondShape();
22
+ const COMMENT = new CommentShape();
23
+
24
+ registry.set('rect', RECT);
25
+ registry.set('pill', PILL);
26
+ registry.set('circle', CIRCLE);
27
+ registry.set('diamond', DIAMOND);
28
+ registry.set('comment', COMMENT);
29
+
30
+ // Register SVG preset shapes
31
+ for (const [name, pathData] of Object.entries(SVG_PRESETS)) {
32
+ registry.set(name, createSVGShape(name, pathData));
33
+ }
34
+
35
+ /**
36
+ * Get shape by name
37
+ * @param {string} name
38
+ * @returns {NodeShape}
39
+ */
40
+ export function getShape(name) {
41
+ return registry.get(name) || RECT;
42
+ }
43
+
44
+ /**
45
+ * Register custom shape
46
+ * @param {string} name
47
+ * @param {NodeShape} shape
48
+ */
49
+ export function registerShape(name, shape) {
50
+ registry.set(name, shape);
51
+ }
52
+
53
+ export { NodeShape, RectShape, PillShape, CircleShape, DiamondShape, CommentShape, SVGShape, createSVGShape, SVG_PRESETS };
@@ -0,0 +1,32 @@
1
+ /**
2
+ * Palette — color-only design tokens
3
+ *
4
+ * Contains only chromatic properties: backgrounds, text colors,
5
+ * accents, category colors, connection colors.
6
+ * Separated from geometry (Skin) for independent swapping.
7
+ *
8
+ * @module symbiote-node/themes/Palette
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} PaletteDefinition
13
+ * @property {string} name
14
+ * @property {Object<string, string>} colors
15
+ */
16
+
17
+ // Re-export all built-in palettes
18
+ export { DARK_PALETTE } from './dark.js';
19
+ export { LIGHT_PALETTE } from './light.js';
20
+ export { SYNTHWAVE_PALETTE } from './synthwave.js';
21
+ export { GREY_PALETTE } from './grey.js';
22
+
23
+ /**
24
+ * Apply palette to element
25
+ * @param {HTMLElement} element
26
+ * @param {PaletteDefinition} palette
27
+ */
28
+ export function applyPalette(element, palette) {
29
+ for (const [key, value] of Object.entries(palette.colors)) {
30
+ element.style.setProperty(key, value);
31
+ }
32
+ }
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Skin — geometry-only design tokens
3
+ *
4
+ * Built on a T-shirt spacing scale: change the scale values and all
5
+ * semantic tokens (radius, sockets, grid) update harmoniously.
6
+ * Shadow is split into geometry (here) and color (from Palette).
7
+ *
8
+ * @module symbiote-node/themes/Skin
9
+ */
10
+
11
+ /**
12
+ * @typedef {Object} SkinDefinition
13
+ * @property {string} name
14
+ * @property {Object<string, string>} geometry
15
+ */
16
+
17
+ /** @type {SkinDefinition} */
18
+ export const MODERN_SKIN = {
19
+ name: 'modern',
20
+ geometry: {
21
+ // === Spacing scale (atomic — AI changes these) ===
22
+ '--sn-space-xs': '4px',
23
+ '--sn-space-sm': '8px',
24
+ '--sn-space-md': '12px',
25
+ '--sn-space-lg': '16px',
26
+ '--sn-space-xl': '24px',
27
+
28
+ // === Semantic geometry (references scale) ===
29
+ '--sn-node-radius': 'var(--sn-space-md)',
30
+ '--sn-comment-radius': 'var(--sn-space-sm)',
31
+ '--sn-socket-size': 'var(--sn-space-md)',
32
+ '--sn-socket-border-width': '2px',
33
+ '--sn-grid-size': 'var(--sn-space-xl)',
34
+ '--sn-conn-width': '2',
35
+
36
+ // === Typography ===
37
+ '--sn-font': "'Inter', sans-serif",
38
+ '--sn-font-size': '13px',
39
+
40
+ // === Shadow geometry (color comes from Palette) ===
41
+ '--sn-shadow-geometry': '0 4px 16px',
42
+ '--sn-node-shadow': 'var(--sn-shadow-geometry) var(--sn-shadow-color, rgba(0, 0, 0, 0.3))',
43
+ },
44
+ };
45
+
46
+ /** @type {SkinDefinition} */
47
+ export const COMPACT_SKIN = {
48
+ name: 'compact',
49
+ geometry: {
50
+ // === Spacing scale ===
51
+ '--sn-space-xs': '3px',
52
+ '--sn-space-sm': '5px',
53
+ '--sn-space-md': '8px',
54
+ '--sn-space-lg': '12px',
55
+ '--sn-space-xl': '16px',
56
+
57
+ // === Semantic geometry ===
58
+ '--sn-node-radius': 'var(--sn-space-md)',
59
+ '--sn-comment-radius': 'var(--sn-space-sm)',
60
+ '--sn-socket-size': 'var(--sn-space-md)',
61
+ '--sn-socket-border-width': '1.5px',
62
+ '--sn-grid-size': 'var(--sn-space-xl)',
63
+ '--sn-conn-width': '1.5',
64
+
65
+ // === Typography ===
66
+ '--sn-font': "'Inter', sans-serif",
67
+ '--sn-font-size': '12px',
68
+
69
+ // === Shadow geometry ===
70
+ '--sn-shadow-geometry': '0 2px 8px',
71
+ '--sn-node-shadow': 'var(--sn-shadow-geometry) var(--sn-shadow-color, rgba(0, 0, 0, 0.2))',
72
+ },
73
+ };
74
+
75
+ /** @type {SkinDefinition} */
76
+ export const ROUNDED_SKIN = {
77
+ name: 'rounded',
78
+ geometry: {
79
+ // === Spacing scale ===
80
+ '--sn-space-xs': '5px',
81
+ '--sn-space-sm': '10px',
82
+ '--sn-space-md': '14px',
83
+ '--sn-space-lg': '20px',
84
+ '--sn-space-xl': '28px',
85
+
86
+ // === Semantic geometry ===
87
+ '--sn-node-radius': 'var(--sn-space-lg)', // larger radius for "rounded" feel
88
+ '--sn-comment-radius': 'var(--sn-space-md)',
89
+ '--sn-socket-size': 'var(--sn-space-md)',
90
+ '--sn-socket-border-width': '2.5px',
91
+ '--sn-grid-size': 'var(--sn-space-xl)',
92
+ '--sn-conn-width': '2.5',
93
+
94
+ // === Typography ===
95
+ '--sn-font': "'Space Grotesk', sans-serif",
96
+ '--sn-font-size': '14px',
97
+
98
+ // === Shadow geometry ===
99
+ '--sn-shadow-geometry': '0 6px 24px',
100
+ '--sn-node-shadow': 'var(--sn-shadow-geometry) var(--sn-shadow-color, rgba(0, 0, 0, 0.25))',
101
+ },
102
+ };
103
+
104
+ /**
105
+ * Apply skin to element
106
+ * @param {HTMLElement} element
107
+ * @param {SkinDefinition} skin
108
+ */
109
+ export function applySkin(element, skin) {
110
+ for (const [key, value] of Object.entries(skin.geometry)) {
111
+ element.style.setProperty(key, value);
112
+ }
113
+ }