423 lines
10 KiB
TypeScript
423 lines
10 KiB
TypeScript
/**
|
|
* 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<string, number>; // 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<string, Spider3DSample[]>();
|
|
|
|
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<string, SpaceConnection[]>();
|
|
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<string, number> = {};
|
|
|
|
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,
|
|
},
|
|
},
|
|
],
|
|
};
|