How do I have so many partners??
0
fork

Configure Feed

Select the types of activity you want to include in your feed.

at main 710 lines 28 kB view raw
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, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;'); 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}