/** * spider-3d.ts — Pure computation for 3D stacked spider/radar plots. * * No DOM dependencies. Generates polar mesh data, z-aggregation layers, * overlap detection, and color mapping for any renderer. */ import type { FlowKind } from "./layer-types"; import { FLOW_COLORS } from "./layer-types"; import type { SpaceConnection } from "./connection-types"; // ── Types ── export interface Spider3DAxis { id: string; label: string; max?: number; // axis maximum (default: 1) } export interface Spider3DDataset { id: string; label: string; color: string; values: Record; // axisId → value (0 to axis.max) } export interface Spider3DConfig { axes: Spider3DAxis[]; datasets: Spider3DDataset[]; resolution?: number; // radial subdivisions for overlap mesh (default: 36) } export interface Spider3DSample { angle: number; // radians radius: number; // 0-1 normalized x: number; y: number; // cartesian (for SVG) height: number; // aggregate z value (sum of datasets covering this point) contributors: string[]; // dataset IDs that reach this point } export interface Spider3DLayer { datasetId: string; color: string; label: string; zIndex: number; // stack order (0 = bottom) vertices: { x: number; y: number }[]; } export interface Spider3DOverlapRegion { contributorIds: string[]; vertices: { x: number; y: number }[]; height: number; } export interface Spider3DResult { layers: Spider3DLayer[]; samples: Spider3DSample[]; maxHeight: number; overlapRegions: Spider3DOverlapRegion[]; } // ── Helpers ── /** Interpolate the radar radius at a given angle for a dataset */ function datasetRadiusAtAngle( dataset: Spider3DDataset, axes: Spider3DAxis[], angle: number, ): number { const n = axes.length; if (n === 0) return 0; const step = (2 * Math.PI) / n; // Normalize angle to [0, 2π) let a = ((angle % (2 * Math.PI)) + 2 * Math.PI) % (2 * Math.PI); // Find which two axes this angle falls between const idx = a / step; const i0 = Math.floor(idx) % n; const i1 = (i0 + 1) % n; const t = idx - Math.floor(idx); const max0 = axes[i0].max ?? 1; const max1 = axes[i1].max ?? 1; const v0 = (dataset.values[axes[i0].id] ?? 0) / max0; const v1 = (dataset.values[axes[i1].id] ?? 0) / max1; return v0 * (1 - t) + v1 * t; // linear interpolation, 0-1 normalized } /** Point-in-polygon test (ray casting) */ function pointInPolygon( px: number, py: number, polygon: { x: number; y: number }[], ): boolean { let inside = false; const n = polygon.length; for (let i = 0, j = n - 1; i < n; j = i++) { const xi = polygon[i].x, yi = polygon[i].y; const xj = polygon[j].x, yj = polygon[j].y; if ( yi > py !== yj > py && px < ((xj - xi) * (py - yi)) / (yj - yi) + xi ) { inside = !inside; } } return inside; } // ── Core functions ── /** * Compute the radar polygon vertices for a single dataset. */ export function computeRadarPolygon( dataset: Spider3DDataset, axes: Spider3DAxis[], cx: number, cy: number, radius: number, ): { x: number; y: number }[] { const n = axes.length; if (n === 0) return []; const angleStep = (2 * Math.PI) / n; return axes.map((axis, i) => { const max = axis.max ?? 1; const val = Math.min((dataset.values[axis.id] ?? 0) / max, 1); const angle = i * angleStep - Math.PI / 2; return { x: cx + radius * val * Math.cos(angle), y: cy + radius * val * Math.sin(angle), }; }); } /** * Fine-grained sampling for height computation. * At each sample point: count how many datasets' polygons contain it. */ export function computeOverlapMesh( datasets: Spider3DDataset[], axes: Spider3DAxis[], cx: number, cy: number, radius: number, resolution: number = 36, ): Spider3DSample[] { const samples: Spider3DSample[] = []; const radialSteps = Math.max(4, Math.floor(resolution / 3)); // Pre-compute all dataset polygons const polygons = datasets.map((ds) => computeRadarPolygon(ds, axes, cx, cy, radius), ); for (let ai = 0; ai < resolution; ai++) { const angle = (ai / resolution) * 2 * Math.PI; for (let ri = 1; ri <= radialSteps; ri++) { const r = ri / radialSteps; const x = cx + radius * r * Math.cos(angle - Math.PI / 2); const y = cy + radius * r * Math.sin(angle - Math.PI / 2); const contributors: string[] = []; for (let di = 0; di < datasets.length; di++) { if (polygons[di].length >= 3 && pointInPolygon(x, y, polygons[di])) { contributors.push(datasets[di].id); } } samples.push({ angle, radius: r, x, y, height: contributors.length, contributors, }); } } return samples; } /** * Main computation: produces layers, samples, and overlap regions. */ export function computeSpider3D( config: Spider3DConfig, cx: number, cy: number, radius: number, ): Spider3DResult { const { axes, datasets, resolution = 36 } = config; // Build one layer per dataset const layers: Spider3DLayer[] = datasets.map((ds, i) => ({ datasetId: ds.id, color: ds.color, label: ds.label, zIndex: i, vertices: computeRadarPolygon(ds, axes, cx, cy, radius), })); // Compute overlap mesh const samples = computeOverlapMesh( datasets, axes, cx, cy, radius, resolution, ); const maxHeight = samples.reduce((m, s) => Math.max(m, s.height), 0); // Build overlap regions by grouping contiguous samples with height >= 2 const overlapRegions = buildOverlapRegions(samples, datasets.length); return { layers, samples, maxHeight, overlapRegions }; } /** * Build overlap region summaries from sample data. * Groups samples by their exact contributor set, then creates a convex hull * of the sample points for each group. */ function buildOverlapRegions( samples: Spider3DSample[], _datasetCount: number, ): Spider3DOverlapRegion[] { // Group by contributor set (sorted key) const groups = new Map(); for (const s of samples) { if (s.contributors.length < 2) continue; const key = [...s.contributors].sort().join("|"); if (!groups.has(key)) groups.set(key, []); groups.get(key)!.push(s); } const regions: Spider3DOverlapRegion[] = []; for (const [key, points] of groups) { if (points.length < 3) continue; const contributorIds = key.split("|"); const vertices = convexHull(points.map((p) => ({ x: p.x, y: p.y }))); const height = Math.max(...points.map((p) => p.height)); regions.push({ contributorIds, vertices, height }); } return regions; } /** Simple convex hull (Graham scan) for overlap region outlines */ function convexHull(points: { x: number; y: number }[]): { x: number; y: number }[] { if (points.length < 3) return points; // Find lowest-y (then leftmost) point let pivot = points[0]; for (const p of points) { if (p.y < pivot.y || (p.y === pivot.y && p.x < pivot.x)) pivot = p; } const sorted = points .filter((p) => p !== pivot) .sort((a, b) => { const angleA = Math.atan2(a.y - pivot.y, a.x - pivot.x); const angleB = Math.atan2(b.y - pivot.y, b.x - pivot.x); if (angleA !== angleB) return angleA - angleB; const distA = (a.x - pivot.x) ** 2 + (a.y - pivot.y) ** 2; const distB = (b.x - pivot.x) ** 2 + (b.y - pivot.y) ** 2; return distA - distB; }); const stack: { x: number; y: number }[] = [pivot]; for (const p of sorted) { while (stack.length >= 2) { const a = stack[stack.length - 2]; const b = stack[stack.length - 1]; const cross = (b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x); if (cross <= 0) stack.pop(); else break; } stack.push(p); } return stack; } // ── Membrane preset ── /** All standard FlowKinds (excluding "custom") for axis defaults */ const ALL_FLOW_KINDS: FlowKind[] = [ "economic", "trust", "data", "governance", "resource", "attention", ]; /** Per-space color tinting: shift the base FlowKind color per space index */ const SPACE_TINTS = [ "#4ade80", // green "#c084fc", // violet "#60a5fa", // blue "#f59e0b", // amber "#ec4899", // pink "#14b8a6", // teal "#f97316", // orange "#8b5cf6", // purple ]; /** * Map SpaceConnection[] + FlowKind[] into a Spider3DConfig. * * - Axes = one per FlowKind * - Datasets = one per connected remote space * - Values = aggregate connection strength per FlowKind for that space */ export function membranePreset( connections: SpaceConnection[], flowKinds: FlowKind[] = ALL_FLOW_KINDS, ): Spider3DConfig { const axes: Spider3DAxis[] = flowKinds.map((kind) => ({ id: kind, label: kind.charAt(0).toUpperCase() + kind.slice(1), max: 1, })); // Group connections by remote space const byRemote = new Map(); for (const conn of connections) { if (conn.state !== "active") continue; if (!byRemote.has(conn.remoteSlug)) byRemote.set(conn.remoteSlug, []); byRemote.get(conn.remoteSlug)!.push(conn); } const datasets: Spider3DDataset[] = []; let spaceIdx = 0; for (const [remoteSlug, conns] of byRemote) { const values: Record = {}; for (const kind of flowKinds) { // Average strength across all connections of this kind to this space const matching = conns.filter((c) => c.flowKinds.includes(kind)); if (matching.length > 0) { values[kind] = matching.reduce((sum, c) => sum + c.strength, 0) / matching.length; } else { values[kind] = 0; } } datasets.push({ id: remoteSlug, label: remoteSlug, color: SPACE_TINTS[spaceIdx % SPACE_TINTS.length], values, }); spaceIdx++; } return { axes, datasets }; } // ── Demo data ── export const DEMO_CONFIG: Spider3DConfig = { axes: [ { id: "economic", label: "Economic", max: 1 }, { id: "trust", label: "Trust", max: 1 }, { id: "data", label: "Data", max: 1 }, { id: "governance", label: "Governance", max: 1 }, { id: "resource", label: "Resource", max: 1 }, { id: "attention", label: "Attention", max: 1 }, ], datasets: [ { id: "commons-dao", label: "Commons DAO", color: "#4ade80", values: { economic: 0.8, trust: 0.6, data: 0.5, governance: 0.9, resource: 0.3, attention: 0.4, }, }, { id: "mycelial-lab", label: "Mycelial Lab", color: "#c084fc", values: { economic: 0.3, trust: 0.9, data: 0.7, governance: 0.4, resource: 0.8, attention: 0.6, }, }, { id: "regen-fund", label: "Regenerative Fund", color: "#60a5fa", values: { economic: 0.7, trust: 0.5, data: 0.4, governance: 0.6, resource: 0.5, attention: 0.9, }, }, ], };