// ─── Resource map ─────────────────────────────────────────────────────────────── // A force-directed "super map" of the user's CONFIRMED resources, in the same // visual language as the LifeGraph ("Your relationships") on the wizard hub: // "You" in the centre, coloured circles, name labels, edge lines. Four layers, // nodes deduped so the tree becomes a web: // // You ── Family ── Org ── Resource // // An org appearing in two families links to both — that's where the web forms. // Layout uses d3-force (loaded as window.d3 from a CDN; the frontend has no // build step). d3 owns the SVG contents imperatively; React just hosts the node. // resource_family (DB) → existing CSS family colour token. Cosmetic only; // anything unmapped falls back to neutral grey. const _FAM_COLOR = { financial: "var(--fam-financial)", investment: "var(--fam-financial)", insurance: "var(--fam-security)", security: "var(--fam-security)", device: "var(--fam-digital)", digital: "var(--fam-digital)", subscription:"var(--fam-subscription)", membership: "var(--fam-membership)", asset: "var(--fam-asset)", property: "var(--fam-asset)", document: "var(--fam-document)", government: "var(--fam-document)", legal: "var(--fam-document)", unknown: "var(--fam-unknown)", }; const _famColor = (f) => _FAM_COLOR[f] || "var(--fam-unknown)"; const _famLabel = (f) => (f ? f.charAt(0).toUpperCase() + f.slice(1) : "Other"); const _trunc = (s, n) => { s = s || ""; return s.length > n ? s.slice(0, n - 1) + "…" : s; }; // Circle radius: a per-layer minimum, grown to fit the node's label so the text // sits inside the circle. Capped so a long name can't blow up the layout. // Greedy word-wrap into lines no longer than maxChars. Long single words are hard-split. function _wrapLines(text, maxChars) { const words = String(text || "").split(/\s+/).filter(Boolean); const lines = []; let cur = ""; for (let w of words) { while (w.length > maxChars) { // a single word longer than the line if (cur) { lines.push(cur); cur = ""; } lines.push(w.slice(0, maxChars)); w = w.slice(maxChars); } if (!cur) cur = w; else if ((cur + " " + w).length <= maxChars) cur += " " + w; else { lines.push(cur); cur = w; } } if (cur) lines.push(cur); return lines.length ? lines : [""]; } const _CW = 0.66; // approx glyph width as a fraction of font size (Geist runs wide) const _MIN_R = { you: 70, family: 46, org: 42, resource: 38 }; // Wrap each node's label and size its circle to hold the wrapped text block. // Stores nameLines / font / lineH / blockH / r on the node for the renderer. function _layout(d) { const font = d.kind === "you" ? 16 : 11; const maxChars = d.kind === "you" ? 14 : 13; d.nameLines = _wrapLines(d.label, maxChars); const subFont = 8.5; const longest = d.nameLines.reduce((m, l) => Math.max(m, l.length), 0); const wName = longest * font * _CW; const wSub = d.sublabel ? Math.min(String(d.sublabel).length, 16) * subFont * _CW : 0; d.font = font; d.lineH = font * 1.2; d.blockH = d.nameLines.length * d.lineH + (d.sublabel ? subFont * 1.5 : 0); const blockW = Math.max(wName, wSub); const fit = Math.sqrt((blockW / 2) ** 2 + (d.blockH / 2) ** 2) + 13; // circle around the text rect d.r = Math.max(_MIN_R[d.kind] || 26, fit); } const _radius = (d) => d.r; // Turn the flat resource list into deduped nodes + links. function _buildGraph(resources, youLabel) { const youId = "you"; const nodes = [{ id: youId, kind: "you", label: youLabel || "You" }]; const links = []; const famNodes = new Map(); // family -> node id const orgNodes = new Map(); // org key -> node id const orgFamLinks = new Set();// "orgId|famId" dedupe (org can span families) for (const it of resources) { const fam = it.resource_family || "unknown"; const org = (it.organisation || "Unknown").trim() || "Unknown"; const orgKey = org.toLowerCase(); if (!famNodes.has(fam)) { const id = "fam:" + fam; famNodes.set(fam, id); nodes.push({ id, kind: "family", family: fam, label: _famLabel(fam) }); links.push({ source: youId, target: id }); } const famId = famNodes.get(fam); if (!orgNodes.has(orgKey)) { const id = "org:" + orgKey; orgNodes.set(orgKey, id); nodes.push({ id, kind: "org", family: fam, label: org }); } const orgId = orgNodes.get(orgKey); const fl = orgId + "|" + famId; if (!orgFamLinks.has(fl)) { orgFamLinks.add(fl); links.push({ source: famId, target: orgId }); } nodes.push({ id: "res:" + it.id, kind: "resource", family: fam, label: it.resource_name || it.resource_type || "Resource", sublabel: it.resource_type || "", confirmed: it.source !== "AI_INGESTION", // AI guesses render faded }); links.push({ source: orgId, target: "res:" + it.id }); } return { nodes, links }; } function ResourceMap({ items, name }) { const svgRef = React.useRef(null); // Show every resource; AI-ingested ones are drawn faded, confirmed ones solid. const resources = (items || []).filter(Boolean); React.useEffect(() => { const d3 = window.d3; if (!svgRef.current || !d3) return; // render even with 0 resources: shows the lone "you" node const W = 1400, H = 1000, cx = W / 2, cy = H / 2; const { nodes, links } = _buildGraph(resources, name); nodes.forEach(_layout); // wrap labels + size circles before laying out // Pin "You" dead-centre; everything else orbits it in concentric rings. const you = nodes.find((n) => n.kind === "you"); you.fx = cx; you.fy = cy; const svg = d3.select(svgRef.current); svg.selectAll("*").remove(); const g = svg.append("g"); // Concentric tiers keep the layers clearly separated and You centred even // when one family dominates: family ring → org ring → resource ring. const _ring = { family: 340, org: 700, resource: 1060 }; // Seed families as evenly-spaced spokes around You so the branches start // out symmetrically, but leave them UNPINNED (initial x/y, not fx/fy) so the // radial + charge forces spread them out flexibly instead of locking them. const fams = nodes.filter((n) => n.kind === "family"); fams.forEach((f, i) => { const ang = (2 * Math.PI * i) / fams.length - Math.PI / 2; f.x = cx + _ring.family * Math.cos(ang); f.y = cy + _ring.family * Math.sin(ang); }); const sim = d3.forceSimulation(nodes) .force("link", d3.forceLink(links).id((d) => d.id).distance(90).strength(0.1)) .force("charge", d3.forceManyBody().strength(-900).distanceMax(1200)) .force("radial", d3.forceRadial((d) => _ring[d.kind] || 0, cx, cy) .strength((d) => d.kind === "you" ? 0 : 0.3)) .force("collide", d3.forceCollide().radius((d) => d.r + 28).strength(1)); const link = g.append("g").attr("class", "resourcemap__edges") .selectAll("line").data(links).join("line") .attr("class", "resourcemap__edge"); const node = g.append("g") .selectAll("g").data(nodes).join("g") .attr("class", (d) => `resourcemap__node resourcemap__node--${d.kind}` + (d.kind === "resource" && !d.confirmed ? " resourcemap__node--ai" : "")) .call(drag(sim)); node.append("title").text((d) => d.sublabel ? `${d.label} · ${d.sublabel}` : d.label); node.append("circle") .attr("class", "resourcemap__circle") .attr("r", _radius) // You = dark ink. Org/family hubs = base family colour. Resource leaves get // a darker tone of the same family colour so they read distinctly from the // structural nodes while still grouping by family. White labels stay legible. .attr("fill", (d) => { if (d.kind === "you") return "var(--ink, #2a2622)"; const c = _famColor(d.family); return d.kind === "resource" ? `color-mix(in oklch, ${c}, #000 24%)` : c; }); // Wrapped label (white, inside the circle — mirrors LifeGraph) + optional // type sub-label, both centred vertically on the node. node.each(function (d) { const top = -d.blockH / 2; const t = d3.select(this).append("text") .attr("class", "resourcemap__name").attr("text-anchor", "middle"); d.nameLines.forEach((ln, i) => { t.append("tspan").attr("x", 0).attr("y", top + d.lineH * (i + 0.78)).text(ln); }); if (d.sublabel) { d3.select(this).append("text") .attr("class", "resourcemap__role").attr("text-anchor", "middle") .attr("x", 0).attr("y", top + d.lineH * d.nameLines.length + 7) .text(_trunc(d.sublabel, 16)); } }); sim.on("tick", () => { link .attr("x1", (d) => d.source.x).attr("y1", (d) => d.source.y) .attr("x2", (d) => d.target.x).attr("y2", (d) => d.target.y); node.attr("transform", (d) => `translate(${d.x},${d.y})`); }); // Pan + zoom; allow zooming further out since the radial layout is large. const zoom = d3.zoom().scaleExtent([0.1, 2.5]).on("zoom", (e) => g.attr("transform", e.transform)); svg.call(zoom); // Once the layout settles, frame the whole graph in the viewport (once). let fitted = false; function fitToView() { // Keep You (pinned at cx,cy) in the viewport centre; scale so the farthest node fits. const maxDist = Math.max(...nodes.map((n) => Math.hypot(n.x - cx, n.y - cy) + n.r)) + 30; const scale = Math.min((Math.min(W, H) / 2) / maxDist, 1.4); const tx = W / 2 - scale * cx, ty = H / 2 - scale * cy; svg.transition().duration(500).call(zoom.transform, d3.zoomIdentity.translate(tx, ty).scale(scale)); } sim.on("end", () => { if (!fitted) { fitted = true; fitToView(); } }); function drag(sim) { function started(event, d) { if (!event.active) sim.alphaTarget(0.3).restart(); d.fx = d.x; d.fy = d.y; } function dragged(event, d) { d.fx = event.x; d.fy = event.y; } function ended(event, d) { if (!event.active) sim.alphaTarget(0); if (d.kind !== "you") { d.fx = null; d.fy = null; } // keep You pinned } return d3.drag().on("start", started).on("drag", dragged).on("end", ended); } return () => sim.stop(); }, [items]); return (

Your life map

Everything you use, and who provides it. Solid = confirmed. Faded = AI-found. Drag and zoom to explore.

{resources.length === 0 && (

Empty for now. Add resources or run AI to begin.

)}
); } if (typeof window !== "undefined") window.ResourceMap = ResourceMap;