How do I have so many partners??
1// Browser-only. Bundled by esbuild — no Node.js APIs.
2import { forceSimulation, forceLink, forceManyBody, forceCenter, forceCollide } from 'd3-force';
3import { select } from 'd3-selection';
4import { zoom as d3zoom, zoomIdentity, type ZoomTransform } from 'd3-zoom';
5import { drag as d3drag } from 'd3-drag';
6import type { PolyculeData, Person, Relationship } from '../types.js';
7import { RELATIONSHIP_STYLES, nodeColor, initials } from '../styles.js';
8
9// ─── Types ────────────────────────────────────────────────────────────────────
10
11interface NodeDatum extends Person {
12 index?: number;
13 x: number;
14 y: number;
15 vx: number;
16 vy: number;
17 fx: number | null;
18 fy: number | null;
19 connectionCount: number;
20}
21
22interface LinkDatum {
23 source: NodeDatum;
24 target: NodeDatum;
25 relationship: Relationship;
26 index?: number;
27}
28
29// ─── Constants ────────────────────────────────────────────────────────────────
30
31const BASE_RADIUS = 28;
32const LABEL_OFFSET = 16;
33
34// ─── Theme helpers ────────────────────────────────────────────────────────────
35
36function getThemeColors(dark: boolean) {
37 return {
38 bg: dark ? '#0d1117' : '#f0f4f8',
39 grid: dark ? 'rgba(255,255,255,0.025)' : 'rgba(0,0,0,0.04)',
40 text: dark ? '#e6edf3' : '#1c1e21',
41 textMuted: dark ? '#8b949e' : '#65676b',
42 nodeLabelBg: dark ? 'rgba(0,0,0,0.55)' : 'rgba(255,255,255,0.75)',
43 edgeLabelBg: dark ? 'rgba(13,17,23,0.75)' : 'rgba(240,244,248,0.8)',
44 panelBg: dark ? 'rgba(22,27,34,0.95)' : 'rgba(255,255,255,0.97)',
45 legendBg: dark ? 'rgba(13,17,23,0.45)' : 'rgba(255,255,255,0.5)',
46 panelBorder: dark ? 'rgba(48,54,61,0.9)' : 'rgba(208,215,222,0.9)',
47 panelText: dark ? '#e6edf3' : '#1c1e21',
48 panelMuted: dark ? '#8b949e' : '#65676b',
49 btnBg: dark ? 'rgba(33,38,45,0.9)' : 'rgba(255,255,255,0.9)',
50 btnBorder: dark ? 'rgba(48,54,61,0.8)' : 'rgba(208,215,222,0.8)',
51 btnText: dark ? '#8b949e' : '#65676b',
52 };
53}
54
55// ─── CSS injection ────────────────────────────────────────────────────────────
56
57function injectStyles(): void {
58 if (document.getElementById('polymap-styles')) return;
59 const style = document.createElement('style');
60 style.id = 'polymap-styles';
61 style.textContent = `
62 .polymap-wrap { position: relative; width: 100%; height: 100%; overflow: hidden; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
63 .polymap-wrap svg { display: block; width: 100%; height: 100%; cursor: grab; }
64 .polymap-wrap svg:active { cursor: grabbing; }
65 .polymap-node { cursor: pointer; }
66 .polymap-node:hover .pm-halo { opacity: 0.35 !important; }
67 .polymap-node:hover .pm-ring { stroke-width: 3 !important; }
68
69 .pm-info-panel {
70 position: absolute;
71 min-width: 200px;
72 max-width: 280px;
73 border-radius: 12px;
74 padding: 14px 16px;
75 box-shadow: 0 8px 32px rgba(0,0,0,0.35);
76 backdrop-filter: blur(12px);
77 -webkit-backdrop-filter: blur(12px);
78 border: 1px solid;
79 pointer-events: auto;
80 z-index: 100;
81 transition: opacity 0.15s ease;
82 }
83 .pm-info-panel.hidden { opacity: 0; pointer-events: none; }
84 .pm-info-header { display: flex; align-items: center; gap: 10px; margin-bottom: 8px; }
85 .pm-info-avatar {
86 width: 40px; height: 40px; border-radius: 50%; flex-shrink: 0;
87 display: flex; align-items: center; justify-content: center;
88 font-size: 14px; font-weight: 700; color: #fff;
89 background-size: cover; background-position: center;
90 overflow: hidden;
91 }
92 .pm-info-avatar img { width: 100%; height: 100%; object-fit: cover; border-radius: 50%; }
93 .pm-info-name { font-size: 15px; font-weight: 600; line-height: 1.2; }
94 .pm-info-pronouns { font-size: 12px; margin-top: 1px; }
95 .pm-info-close {
96 margin-left: auto; background: none; border: none; cursor: pointer;
97 font-size: 18px; line-height: 1; padding: 0 2px; opacity: 0.5;
98 transition: opacity 0.1s;
99 }
100 .pm-info-close:hover { opacity: 1; }
101 .pm-info-links { display: flex; flex-wrap: wrap; gap: 6px; margin-top: 8px; }
102 .pm-info-link {
103 font-size: 12px; padding: 3px 9px; border-radius: 20px;
104 border: 1px solid; text-decoration: none; opacity: 0.85;
105 transition: opacity 0.1s;
106 }
107 .pm-info-link:hover { opacity: 1; }
108
109 .pm-controls {
110 position: absolute; top: 12px; right: 12px;
111 display: flex; flex-direction: column; gap: 6px; z-index: 50;
112 }
113 .pm-btn {
114 border: 1px solid; border-radius: 8px; padding: 6px 12px;
115 font-size: 12px; cursor: pointer; backdrop-filter: blur(8px);
116 -webkit-backdrop-filter: blur(8px); transition: opacity 0.1s;
117 white-space: nowrap;
118 }
119 .pm-btn:hover { opacity: 0.8; }
120
121 .pm-legend {
122 position: absolute; bottom: 12px; left: 12px; z-index: 50;
123 border: 1px solid; border-radius: 10px; padding: 10px 14px;
124 backdrop-filter: blur(8px); -webkit-backdrop-filter: blur(8px);
125 min-width: 160px;
126 }
127 .pm-legend.hidden { display: none; }
128 .pm-legend-title { font-size: 11px; font-weight: 600; text-transform: uppercase; letter-spacing: 0.05em; margin-bottom: 8px; }
129 .pm-legend-item { display: flex; align-items: center; gap: 8px; margin-bottom: 5px; font-size: 12px; }
130 .pm-legend-line { flex-shrink: 0; }
131 `;
132 document.head.appendChild(style);
133}
134
135// ─── Main export ─────────────────────────────────────────────────────────────
136
137export function init(container: HTMLElement, data: PolyculeData): void {
138 injectStyles();
139
140 let isDark = data.settings.theme !== 'light';
141 let legendVisible = true;
142 let labelsVisible = true;
143 let namesVisible = true;
144 let currentTransform: ZoomTransform = zoomIdentity;
145
146 const wrap = document.createElement('div');
147 wrap.className = 'polymap-wrap';
148 container.appendChild(wrap);
149
150 // ── Build node/link data ──────────────────────────────────────────────────
151
152 const connectionCounts = new Map<string, number>();
153 data.people.forEach(p => connectionCounts.set(p.id, 0));
154 data.relationships.forEach(r => {
155 connectionCounts.set(r.from, (connectionCounts.get(r.from) ?? 0) + 1);
156 connectionCounts.set(r.to, (connectionCounts.get(r.to) ?? 0) + 1);
157 });
158
159 const mainNodeId = data.settings.mainNode;
160 const nodeCount = data.people.length;
161 const nonMainCount = mainNodeId ? data.people.filter(p => p.id !== mainNodeId).length : nodeCount;
162 let nonMainIdx = 0;
163 const nodes: NodeDatum[] = data.people.map(p => {
164 if (p.id === mainNodeId) {
165 return {
166 ...p,
167 x: 480, y: 360,
168 vx: 0, vy: 0,
169 fx: 480, fy: 360, // pinned at center initially
170 connectionCount: connectionCounts.get(p.id) ?? 0,
171 };
172 }
173 const angle = (nonMainIdx++ / Math.max(nonMainCount, 1)) * 2 * Math.PI;
174 return {
175 ...p,
176 x: 480 + Math.cos(angle) * 220,
177 y: 360 + Math.sin(angle) * 220,
178 vx: 0, vy: 0, fx: null, fy: null,
179 connectionCount: connectionCounts.get(p.id) ?? 0,
180 };
181 });
182
183 const nodeById = new Map(nodes.map(n => [n.id, n]));
184
185 const links: LinkDatum[] = data.relationships.map(r => ({
186 source: nodeById.get(r.from)!,
187 target: nodeById.get(r.to)!,
188 relationship: r,
189 }));
190
191 function nodeRadius(d: NodeDatum): number {
192 if (data.settings.nodeScale === 'connections') {
193 return BASE_RADIUS + d.connectionCount * 4;
194 }
195 return BASE_RADIUS;
196 }
197
198 // ── SVG scaffold ─────────────────────────────────────────────────────────
199
200 const svgEl = document.createElementNS('http://www.w3.org/2000/svg', 'svg');
201 wrap.appendChild(svgEl);
202 const svg = select(svgEl);
203
204 const defs = svg.append('defs');
205
206 // Grid pattern
207 const gridPat = defs.append('pattern')
208 .attr('id', 'pm-grid')
209 .attr('width', 40).attr('height', 40)
210 .attr('patternUnits', 'userSpaceOnUse');
211 gridPat.append('path')
212 .attr('d', 'M 40 0 L 0 0 0 40')
213 .attr('fill', 'none')
214 .attr('class', 'pm-grid-path')
215 .attr('stroke-width', '1');
216
217 // Single clip path for all circular nodes (applied in local group space)
218 defs.append('clipPath').attr('id', 'pm-node-clip')
219 .append('circle').attr('r', BASE_RADIUS);
220
221 // Glow filter for nodes
222 const nodeGlow = defs.append('filter')
223 .attr('id', 'pm-node-glow')
224 .attr('x', '-60%').attr('y', '-60%')
225 .attr('width', '220%').attr('height', '220%');
226 nodeGlow.append('feGaussianBlur')
227 .attr('in', 'SourceGraphic').attr('stdDeviation', '5').attr('result', 'blur');
228 const nodeGlowMerge = nodeGlow.append('feMerge');
229 nodeGlowMerge.append('feMergeNode').attr('in', 'blur');
230 nodeGlowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
231
232 // Glow filter for edges
233 const edgeGlow = defs.append('filter')
234 .attr('id', 'pm-edge-glow')
235 .attr('x', '-40%').attr('y', '-40%')
236 .attr('width', '180%').attr('height', '180%');
237 edgeGlow.append('feGaussianBlur')
238 .attr('in', 'SourceGraphic').attr('stdDeviation', '2.5').attr('result', 'blur');
239 const edgeGlowMerge = edgeGlow.append('feMerge');
240 edgeGlowMerge.append('feMergeNode').attr('in', 'blur');
241 edgeGlowMerge.append('feMergeNode').attr('in', 'SourceGraphic');
242
243 // Background
244 const bgRect = svg.append('rect')
245 .attr('class', 'pm-bg')
246 .attr('width', '100%').attr('height', '100%');
247
248 // Grid overlay
249 svg.append('rect')
250 .attr('class', 'pm-grid-rect')
251 .attr('width', '100%').attr('height', '100%')
252 .attr('fill', 'url(#pm-grid)');
253
254 // Main transform group (zoom target)
255 const g = svg.append('g').attr('class', 'pm-graph-root');
256
257 const edgeGroup = g.append('g').attr('class', 'pm-edges');
258 const nodeGroup = g.append('g').attr('class', 'pm-nodes');
259
260 // ── Render edges ─────────────────────────────────────────────────────────
261
262 const edgeGs = edgeGroup.selectAll<SVGGElement, LinkDatum>('g.pm-edge')
263 .data(links).join('g').attr('class', 'pm-edge');
264
265 // Background line (for double-stroke style)
266 const edgeBg = edgeGs.append('line')
267 .attr('class', 'pm-edge-bg')
268 .attr('stroke-linecap', 'round');
269
270 const edgeLine = edgeGs.append('line')
271 .attr('class', 'pm-edge-line')
272 .attr('stroke-linecap', 'round');
273
274 // Edge label group (rect + text)
275 const edgeLabelG = edgeGs.append('g').attr('class', 'pm-edge-label');
276 edgeLabelG.append('rect')
277 .attr('rx', 4).attr('ry', 4)
278 .attr('x', -28).attr('y', -8)
279 .attr('width', 56).attr('height', 14);
280 edgeLabelG.append('text')
281 .attr('text-anchor', 'middle')
282 .attr('dominant-baseline', 'central')
283 .attr('y', 0)
284 .attr('font-size', '9')
285 .attr('font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif')
286 .attr('font-weight', '500');
287
288 // Apply edge styles — set dynamic label rect width based on text length
289 edgeGs.each(function(d) {
290 const s = RELATIONSHIP_STYLES[d.relationship.type];
291 const labelStr = d.relationship.label ?? s.label;
292 const lw = Math.max(40, labelStr.length * 5.5 + 12);
293 select(this).select('rect').attr('x', -lw / 2).attr('width', lw);
294
295 const g = select(this);
296 const bg = g.select<SVGLineElement>('.pm-edge-bg');
297 const line = g.select<SVGLineElement>('.pm-edge-line');
298 const labelG = g.select<SVGGElement>('.pm-edge-label');
299 const labelText = labelG.select('text');
300
301 if (s.double) {
302 bg.attr('stroke-width', s.width * 2.6).style('visibility', 'visible');
303 } else {
304 bg.style('visibility', 'hidden');
305 }
306
307 line
308 .attr('stroke', s.color)
309 .attr('stroke-width', s.width)
310 .attr('stroke-dasharray', s.dashArray || null)
311 .attr('opacity', '0.85')
312 .attr('filter', 'url(#pm-edge-glow)');
313
314 labelText.text(labelStr).attr('fill', s.color);
315 });
316
317 // ── Render nodes ─────────────────────────────────────────────────────────
318
319 const nodeGs = nodeGroup.selectAll<SVGGElement, NodeDatum>('g.polymap-node')
320 .data(nodes).join('g')
321 .attr('class', 'polymap-node')
322 .attr('data-id', d => d.id);
323
324 nodeGs.each(function(d) {
325 const r = nodeRadius(d);
326 const color = nodeColor(d.id, d.color);
327 const g = select(this);
328
329 // Outer halo glow
330 g.append('circle')
331 .attr('class', 'pm-halo')
332 .attr('r', r + 10)
333 .attr('fill', color)
334 .attr('opacity', 0.15);
335
336 if (d.photo) {
337 // Clip path circle (large radius to accommodate scaled nodes)
338 // We reuse pm-node-clip but with inline style override via foreignObject isn't needed —
339 // the defs clip is fine for BASE_RADIUS. For connections-scaled nodes, append per-node clip.
340 const clipId = `pm-clip-${d.id}`;
341 select(svgEl).select('defs')
342 .append('clipPath').attr('id', clipId)
343 .append('circle').attr('r', r);
344
345 g.append('image')
346 .attr('href', d.photo)
347 .attr('x', -r).attr('y', -r)
348 .attr('width', r * 2).attr('height', r * 2)
349 .attr('clip-path', `url(#${clipId})`)
350 .attr('preserveAspectRatio', 'xMidYMid slice');
351 } else {
352 g.append('circle')
353 .attr('r', r)
354 .attr('fill', color)
355 .attr('filter', 'url(#pm-node-glow)');
356 g.append('text')
357 .attr('text-anchor', 'middle')
358 .attr('dominant-baseline', 'central')
359 .attr('font-size', Math.round(r * 0.5))
360 .attr('font-weight', '700')
361 .attr('font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif')
362 .attr('fill', '#ffffff')
363 .attr('pointer-events', 'none')
364 .text(initials(d.name));
365 }
366
367 // Border ring
368 g.append('circle')
369 .attr('class', 'pm-ring')
370 .attr('r', r)
371 .attr('fill', 'none')
372 .attr('stroke', color)
373 .attr('stroke-width', 2)
374 .attr('opacity', 0.9);
375
376 // Name label background + text
377 const labelY = r + LABEL_OFFSET;
378 g.append('rect')
379 .attr('class', 'pm-label-bg')
380 .attr('rx', 4).attr('ry', 4)
381 .attr('x', -36).attr('y', labelY - 9)
382 .attr('width', 72).attr('height', 15);
383 g.append('text')
384 .attr('class', 'pm-label-text')
385 .attr('text-anchor', 'middle')
386 .attr('y', labelY)
387 .attr('font-size', '11')
388 .attr('font-family', '-apple-system, BlinkMacSystemFont, "Segoe UI", sans-serif')
389 .attr('pointer-events', 'none')
390 .text(d.name);
391 });
392
393 // ── Simulation ────────────────────────────────────────────────────────────
394
395 const simulation = forceSimulation<NodeDatum>(nodes)
396 .force(
397 'link',
398 forceLink<NodeDatum, LinkDatum>(links)
399 .id(d => d.id)
400 .distance(170)
401 .strength(0.5)
402 )
403 .force('charge', forceManyBody<NodeDatum>().strength(-500))
404 .force('center', forceCenter(480, 360).strength(0.08))
405 .force('collision', forceCollide<NodeDatum>(d => nodeRadius(d) + 24))
406 .on('tick', ticked);
407
408 function ticked() {
409 edgeGs.each(function(d) {
410 const g = select(this);
411 const x1 = d.source.x, y1 = d.source.y;
412 const x2 = d.target.x, y2 = d.target.y;
413 g.select('.pm-edge-bg').attr('x1', x1).attr('y1', y1).attr('x2', x2).attr('y2', y2);
414 g.select('.pm-edge-line').attr('x1', x1).attr('y1', y1).attr('x2', x2).attr('y2', y2);
415 g.select('.pm-edge-label').attr('transform', `translate(${(x1 + x2) / 2},${(y1 + y2) / 2})`);
416 });
417 nodeGs.attr('transform', d => `translate(${d.x},${d.y})`);
418 }
419
420 // ── Drag ─────────────────────────────────────────────────────────────────
421
422 // If a main node is configured, start with it pinned at center
423 let pinnedNode: NodeDatum | null = mainNodeId ? (nodeById.get(mainNodeId) ?? null) : null;
424
425 const dragBehavior = d3drag<SVGGElement, NodeDatum>()
426 .on('start', (event, d) => {
427 // Unpin any previously pinned node before pinning the new one
428 if (pinnedNode && pinnedNode !== d) {
429 pinnedNode.fx = null;
430 pinnedNode.fy = null;
431 }
432 if (!event.active) simulation.alphaTarget(0.3).restart();
433 d.fx = d.x;
434 d.fy = d.y;
435 pinnedNode = d;
436 })
437 .on('drag', (event, d) => {
438 d.fx = event.x;
439 d.fy = event.y;
440 })
441 .on('end', (event) => {
442 if (!event.active) simulation.alphaTarget(0);
443 // Node stays pinned until next drag or double-click
444 });
445
446 nodeGs.call(dragBehavior);
447
448 // Double-click unpins the node
449 nodeGs.on('dblclick', (event, d) => {
450 event.stopPropagation();
451 d.fx = null;
452 d.fy = null;
453 if (pinnedNode === d) pinnedNode = null;
454 simulation.alphaTarget(0.1).restart();
455 });
456
457 // ── Zoom ─────────────────────────────────────────────────────────────────
458
459 const zoomBehavior = d3zoom<SVGSVGElement, unknown>()
460 .scaleExtent([0.05, 8])
461 .filter(event => event.type !== 'dblclick')
462 .on('zoom', event => {
463 currentTransform = event.transform;
464 g.attr('transform', event.transform.toString());
465 });
466
467 svg.call(zoomBehavior);
468 svg.on('dblclick.zoom', null);
469
470 // Click on background dismisses info panel
471 svg.on('click', () => hideInfoPanel());
472
473 // Stop click from propagating through SVG background to nodes
474 nodeGs.on('click', (event, d) => {
475 event.stopPropagation();
476 showInfoPanel(d, event);
477 });
478
479 // ── Info panel ────────────────────────────────────────────────────────────
480
481 const panel = document.createElement('div');
482 panel.className = 'pm-info-panel hidden';
483 wrap.appendChild(panel);
484
485 function showInfoPanel(d: NodeDatum, event: MouseEvent) {
486 const color = nodeColor(d.id, d.color);
487 const c = getThemeColors(isDark);
488
489 panel.style.background = c.panelBg;
490 panel.style.borderColor = c.panelBorder;
491 panel.style.color = c.panelText;
492
493 const avatarHtml = d.photo
494 ? `<img src="${escHtml(d.photo)}" alt="${escHtml(d.name)}"/>`
495 : `<span style="width:100%;height:100%;display:flex;align-items:center;justify-content:center;background:${color}">${escHtml(initials(d.name))}</span>`;
496
497 const linksHtml = (d.links ?? []).length > 0
498 ? `<div class="pm-info-links">${(d.links ?? []).map(l =>
499 `<a class="pm-info-link" href="${escHtml(l.url)}" target="_blank" rel="noopener noreferrer"
500 style="color:${color};border-color:${color}22">${escHtml(l.label)}</a>`
501 ).join('')}</div>`
502 : '';
503
504 panel.innerHTML = `
505 <div class="pm-info-header">
506 <div class="pm-info-avatar" style="background:${color}">${avatarHtml}</div>
507 <div>
508 <div class="pm-info-name" style="color:${c.panelText}">${escHtml(d.name)}</div>
509 ${d.pronouns ? `<div class="pm-info-pronouns" style="color:${c.panelMuted}">${escHtml(d.pronouns)}</div>` : ''}
510 </div>
511 <button class="pm-info-close" style="color:${c.panelText}" aria-label="Close">×</button>
512 </div>
513 ${linksHtml}
514 `;
515
516 panel.querySelector('.pm-info-close')?.addEventListener('click', hideInfoPanel);
517
518 // Position near the node, clamped to viewport
519 const wRect = wrap.getBoundingClientRect();
520 const nx = currentTransform.applyX(d.x);
521 const ny = currentTransform.applyY(d.y);
522 const panelW = 240;
523 const panelH = 120;
524 let left = nx + 48;
525 let top = ny - 40;
526 if (left + panelW > wRect.width - 8) left = nx - panelW - 48;
527 if (top + panelH > wRect.height - 8) top = wRect.height - panelH - 8;
528 if (top < 8) top = 8;
529 if (left < 8) left = 8;
530
531 panel.style.left = `${left}px`;
532 panel.style.top = `${top}px`;
533 panel.classList.remove('hidden');
534 }
535
536 function hideInfoPanel() {
537 panel.classList.add('hidden');
538 }
539
540 // ── Controls ─────────────────────────────────────────────────────────────
541
542 const controls = document.createElement('div');
543 controls.className = 'pm-controls';
544 wrap.appendChild(controls);
545
546 const themeBtn = document.createElement('button');
547 themeBtn.className = 'pm-btn';
548 themeBtn.title = 'Toggle dark/light mode';
549 controls.appendChild(themeBtn);
550
551 const legendBtn = document.createElement('button');
552 legendBtn.className = 'pm-btn';
553 legendBtn.textContent = 'Legend';
554 legendBtn.title = 'Toggle legend';
555 controls.appendChild(legendBtn);
556
557 const labelsBtn = document.createElement('button');
558 labelsBtn.className = 'pm-btn';
559 labelsBtn.title = 'Toggle edge labels';
560 controls.appendChild(labelsBtn);
561
562 const namesBtn = document.createElement('button');
563 namesBtn.className = 'pm-btn';
564 namesBtn.title = 'Toggle node names';
565 controls.appendChild(namesBtn);
566
567 const fitBtn = document.createElement('button');
568 fitBtn.className = 'pm-btn';
569 fitBtn.textContent = '⊡ Fit';
570 fitBtn.title = 'Fit graph to view';
571 controls.appendChild(fitBtn);
572
573 fitBtn.addEventListener('click', () => {
574 const svgRect = svgEl.getBoundingClientRect();
575 if (!svgRect.width) return;
576 const xs = nodes.map(n => n.x);
577 const ys = nodes.map(n => n.y);
578 const minX = Math.min(...xs) - 80;
579 const minY = Math.min(...ys) - 80;
580 const maxX = Math.max(...xs) + 80;
581 const maxY = Math.max(...ys) + 80;
582 const w = maxX - minX;
583 const h = maxY - minY;
584 const scale = Math.min(0.9, Math.min(svgRect.width / w, svgRect.height / h));
585 const tx = svgRect.width / 2 - scale * (minX + w / 2);
586 const ty = svgRect.height / 2 - scale * (minY + h / 2);
587 svg.transition().duration(500).call(
588 zoomBehavior.transform,
589 zoomIdentity.translate(tx, ty).scale(scale)
590 );
591 });
592
593 // ── Legend ────────────────────────────────────────────────────────────────
594
595 const legend = document.createElement('div');
596 legend.className = 'pm-legend';
597 wrap.appendChild(legend);
598
599 // Build legend from relationship types present in this data
600 const usedTypes = [...new Set(data.relationships.map(r => r.type))];
601
602 function buildLegend(c: ReturnType<typeof getThemeColors>) {
603 legend.style.background = c.legendBg;
604 legend.style.borderColor = c.panelBorder;
605 legend.style.color = c.panelText;
606
607 const svgNS = 'http://www.w3.org/2000/svg';
608 legend.innerHTML = `<div class="pm-legend-title" style="color:${c.panelMuted}">Relationships</div>` +
609 usedTypes.map(type => {
610 const s = RELATIONSHIP_STYLES[type];
611 const dash = s.dashArray ? `stroke-dasharray="${s.dashArray}"` : '';
612 const lineSvg = `<svg class="pm-legend-line" width="32" height="12" viewBox="0 0 32 12">
613 ${s.double
614 ? `<line x1="0" y1="6" x2="32" y2="6" stroke="${c.panelBg}" stroke-width="${s.width * 2.6}" stroke-linecap="round"/>
615 <line x1="0" y1="6" x2="32" y2="6" stroke="${s.color}" stroke-width="${s.width}" stroke-linecap="round" ${dash}/>`
616 : `<line x1="0" y1="6" x2="32" y2="6" stroke="${s.color}" stroke-width="${s.width}" stroke-linecap="round" ${dash}/>`
617 }
618 </svg>`;
619 return `<div class="pm-legend-item" style="color:${c.panelText}">${lineSvg}<span>${s.label}</span></div>`;
620 }).join('');
621 }
622
623 legendBtn.addEventListener('click', () => {
624 legendVisible = !legendVisible;
625 legend.classList.toggle('hidden', !legendVisible);
626 });
627
628 labelsBtn.addEventListener('click', () => {
629 labelsVisible = !labelsVisible;
630 edgeGs.selectAll<SVGGElement, LinkDatum>('.pm-edge-label')
631 .style('display', labelsVisible ? null : 'none');
632 applyTheme();
633 });
634
635 namesBtn.addEventListener('click', () => {
636 namesVisible = !namesVisible;
637 nodeGs.selectAll<SVGElement, NodeDatum>('.pm-label-bg, .pm-label-text')
638 .style('display', namesVisible ? null : 'none');
639 applyTheme();
640 });
641
642 // ── Theme application ─────────────────────────────────────────────────────
643
644 function applyTheme() {
645 const c = getThemeColors(isDark);
646
647 // SVG background
648 bgRect.attr('fill', c.bg);
649 svg.selectAll<SVGPathElement, unknown>('.pm-grid-path').attr('stroke', c.grid);
650
651 // Edge label backgrounds
652 edgeGs.each(function(d) {
653 const s = RELATIONSHIP_STYLES[d.relationship.type];
654 select(this).select<SVGRectElement>('.pm-edge-label rect').attr('fill', c.edgeLabelBg);
655 if (s.double) {
656 select(this).select<SVGLineElement>('.pm-edge-bg').attr('stroke', c.bg);
657 }
658 });
659
660 // Node label backgrounds + text
661 nodeGs.each(function() {
662 select(this).select<SVGRectElement>('.pm-label-bg').attr('fill', c.nodeLabelBg);
663 select(this).select<SVGTextElement>('.pm-label-text').attr('fill', c.text);
664 });
665
666 // Controls + legend
667 [themeBtn, legendBtn, labelsBtn, namesBtn, fitBtn].forEach(b => {
668 b.style.background = c.btnBg;
669 b.style.borderColor = c.btnBorder;
670 b.style.color = c.btnText;
671 });
672 themeBtn.textContent = isDark ? '☀ Light' : '☾ Dark';
673 labelsBtn.textContent = labelsVisible ? 'Labels On' : 'Labels Off';
674 namesBtn.textContent = namesVisible ? 'Names On' : 'Names Off';
675
676 buildLegend(c);
677 }
678
679 themeBtn.addEventListener('click', () => {
680 isDark = !isDark;
681 applyTheme();
682 });
683
684 // Initial theme pass
685 applyTheme();
686
687 // Initial fit after simulation settles
688 setTimeout(() => fitBtn.click(), 600);
689}
690
691// ─── Escape helper ────────────────────────────────────────────────────────────
692
693function escHtml(s: string): string {
694 return s.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
695}
696
697// ─── Auto-init ────────────────────────────────────────────────────────────────
698
699if (typeof window !== 'undefined') {
700 function tryInit() {
701 const data = (window as Record<string, unknown>)['__POLYMAP_DATA__'] as PolyculeData | undefined;
702 const container = document.getElementById('polymap-root');
703 if (data && container) init(container, data);
704 }
705 if (document.readyState === 'loading') {
706 document.addEventListener('DOMContentLoaded', tryInit);
707 } else {
708 tryInit();
709 }
710}