diff --git a/lib/folk-spider-3d.ts b/lib/folk-spider-3d.ts new file mode 100644 index 0000000..6ddc1b6 --- /dev/null +++ b/lib/folk-spider-3d.ts @@ -0,0 +1,666 @@ +/** + * folk-spider-3d.ts — FolkShape web component for 3D stacked spider plots. + * + * Uses CSS 3D transforms (perspective + rotateX/Y on stacked SVG layers) + * to render overlapping radar plots with aggregate z-dimension height. + */ + +import { FolkShape } from "./folk-shape"; +import { css, html } from "./tags"; +import { + computeSpider3D, + computeRadarPolygon, + membranePreset, + DEMO_CONFIG, + type Spider3DAxis, + type Spider3DConfig, + type Spider3DDataset, + type Spider3DResult, + type Spider3DLayer, + type Spider3DOverlapRegion, +} from "./spider-3d"; +import type { FlowKind } from "./layer-types"; + +const styles = css` + :host { + background: #0f172a; + border-radius: 8px; + box-shadow: 0 2px 12px rgba(0, 0, 0, 0.3); + min-width: 400px; + min-height: 420px; + overflow: hidden; + } + + .header { + display: flex; + align-items: center; + justify-content: space-between; + padding: 8px 12px; + background: #6d28d9; + color: white; + border-radius: 8px 8px 0 0; + font-size: 12px; + font-weight: 600; + cursor: move; + } + + .header-title { + display: flex; + align-items: center; + gap: 6px; + } + + .header-actions { + display: flex; + gap: 4px; + } + + .header-actions button { + background: transparent; + border: none; + color: white; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + font-size: 12px; + } + + .header-actions button:hover { + background: rgba(255, 255, 255, 0.2); + } + + .header-actions button.active { + background: rgba(255, 255, 255, 0.3); + } + + .mode-toggle { + display: flex; + gap: 2px; + background: rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 1px; + } + + .mode-btn { + background: transparent; + border: none; + color: rgba(255, 255, 255, 0.7); + cursor: pointer; + padding: 2px 8px; + border-radius: 3px; + font-size: 10px; + font-weight: 500; + } + + .mode-btn.active { + background: rgba(255, 255, 255, 0.3); + color: white; + } + + .body { + display: flex; + flex-direction: column; + height: calc(100% - 36px); + overflow: hidden; + position: relative; + } + + .scene { + flex: 1; + display: flex; + align-items: center; + justify-content: center; + perspective: 800px; + perspective-origin: 50% 50%; + cursor: grab; + min-height: 250px; + overflow: hidden; + } + + .scene:active { + cursor: grabbing; + } + + .stage { + position: relative; + transform-style: preserve-3d; + transition: transform 0.1s ease-out; + width: 280px; + height: 280px; + } + + .layer { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + transform-style: preserve-3d; + backface-visibility: hidden; + pointer-events: none; + } + + .layer.interactive { + pointer-events: auto; + } + + .layer.overlap polygon { + filter: drop-shadow(0 0 4px rgba(255, 255, 255, 0.3)); + } + + .legend { + display: flex; + flex-wrap: wrap; + gap: 6px; + padding: 6px 12px; + border-top: 1px solid rgba(255, 255, 255, 0.1); + } + + .legend-item { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + color: #94a3b8; + cursor: pointer; + padding: 2px 6px; + border-radius: 4px; + transition: all 0.15s; + user-select: none; + } + + .legend-item:hover { + background: rgba(255, 255, 255, 0.08); + } + + .legend-item.hidden { + opacity: 0.4; + } + + .legend-dot { + width: 8px; + height: 8px; + border-radius: 50%; + flex-shrink: 0; + } + + .tooltip { + display: none; + position: absolute; + bottom: 8px; + left: 50%; + transform: translateX(-50%); + background: rgba(15, 23, 42, 0.95); + color: #e2e8f0; + padding: 6px 10px; + border-radius: 6px; + font-size: 10px; + white-space: nowrap; + border: 1px solid rgba(255, 255, 255, 0.15); + pointer-events: none; + z-index: 10; + } + + .tooltip.visible { + display: block; + } + + .info-bar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 4px 12px; + font-size: 10px; + color: #64748b; + border-top: 1px solid rgba(255, 255, 255, 0.05); + } +`; + +declare global { + interface HTMLElementTagNameMap { + "folk-spider-3d": FolkSpider3D; + } +} + +function escapeHtml(text: string): string { + return text.replace(/&/g, "&").replace(//g, ">").replace(/"/g, """); +} + +/** Blend two hex colors by averaging RGB channels */ +function blendColors(colors: string[]): string { + if (colors.length === 0) return "#ffffff"; + if (colors.length === 1) return colors[0]; + + let r = 0, g = 0, b = 0; + for (const c of colors) { + const hex = c.replace("#", ""); + r += parseInt(hex.substring(0, 2), 16); + g += parseInt(hex.substring(2, 4), 16); + b += parseInt(hex.substring(4, 6), 16); + } + r = Math.round(r / colors.length); + g = Math.round(g / colors.length); + b = Math.round(b / colors.length); + return `#${r.toString(16).padStart(2, "0")}${g.toString(16).padStart(2, "0")}${b.toString(16).padStart(2, "0")}`; +} + +export class FolkSpider3D extends FolkShape { + static override tagName = "folk-spider-3d"; + + static { + const sheet = new CSSStyleSheet(); + const parentRules = Array.from(FolkShape.styles.cssRules).map((r) => r.cssText).join("\n"); + const childRules = Array.from(styles.cssRules).map((r) => r.cssText).join("\n"); + sheet.replaceSync(`${parentRules}\n${childRules}`); + this.styles = sheet; + } + + // ── Properties ── + + #title = "Spider 3D"; + #axes: Spider3DAxis[] = []; + #datasets: Spider3DDataset[] = []; + #tiltX = 35; + #tiltY = -15; + #layerSpacing = 8; + #showOverlapHeight = true; + #mode: "datasets" | "membrane" = "datasets"; + #space = ""; + + // ── Internal state ── + + #hiddenDatasets = new Set(); + #isDragging = false; + #lastPointerX = 0; + #lastPointerY = 0; + #result: Spider3DResult | null = null; + + // ── DOM refs ── + + #wrapperEl: HTMLElement | null = null; + #sceneEl: HTMLElement | null = null; + #stageEl: HTMLElement | null = null; + #legendEl: HTMLElement | null = null; + #tooltipEl: HTMLElement | null = null; + #infoEl: HTMLElement | null = null; + + // ── Accessors ── + + get title() { return this.#title; } + set title(v: string) { this.#title = v; this.requestUpdate("title"); } + + get axes() { return this.#axes; } + set axes(v: Spider3DAxis[]) { this.#axes = v; this.#recompute(); } + + get datasets() { return this.#datasets; } + set datasets(v: Spider3DDataset[]) { this.#datasets = v; this.#recompute(); } + + get tiltX() { return this.#tiltX; } + set tiltX(v: number) { this.#tiltX = v; this.#updateTilt(); } + + get tiltY() { return this.#tiltY; } + set tiltY(v: number) { this.#tiltY = v; this.#updateTilt(); } + + get layerSpacing() { return this.#layerSpacing; } + set layerSpacing(v: number) { this.#layerSpacing = v; this.#recompute(); } + + get showOverlapHeight() { return this.#showOverlapHeight; } + set showOverlapHeight(v: boolean) { this.#showOverlapHeight = v; this.#recompute(); } + + get mode() { return this.#mode; } + set mode(v: "datasets" | "membrane") { + this.#mode = v; + if (v === "membrane") this.#loadMembrane(); + this.#render(); + } + + get space() { return this.#space; } + set space(v: string) { + this.#space = v; + if (this.#mode === "membrane") this.#loadMembrane(); + } + + // ── Lifecycle ── + + override createRenderRoot() { + const root = super.createRenderRoot(); + + const wrapper = document.createElement("div"); + wrapper.style.cssText = "position:relative;height:100%;"; + wrapper.innerHTML = html` +
+ + 🕸 + ${escapeHtml(this.#title)} + +
+
+ + +
+ + +
+
+
+
+
+
+
+
+ + +
+
+
+ `; + + const slot = root.querySelector("slot"); + const containerDiv = slot?.parentElement as HTMLElement; + if (containerDiv) containerDiv.replaceWith(wrapper); + + this.#wrapperEl = wrapper; + this.#sceneEl = wrapper.querySelector(".scene") as HTMLElement; + this.#stageEl = wrapper.querySelector(".stage") as HTMLElement; + this.#legendEl = wrapper.querySelector(".legend") as HTMLElement; + this.#tooltipEl = wrapper.querySelector(".tooltip") as HTMLElement; + this.#infoEl = wrapper.querySelector(".info-bar") as HTMLElement; + + // Mode toggle + const modeDatasets = wrapper.querySelector(".mode-datasets") as HTMLButtonElement; + const modeMembrane = wrapper.querySelector(".mode-membrane") as HTMLButtonElement; + + modeDatasets.addEventListener("click", (e) => { + e.stopPropagation(); + this.#mode = "datasets"; + modeDatasets.classList.add("active"); + modeMembrane.classList.remove("active"); + this.#recompute(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + modeMembrane.addEventListener("click", (e) => { + e.stopPropagation(); + this.#mode = "membrane"; + modeMembrane.classList.add("active"); + modeDatasets.classList.remove("active"); + this.#loadMembrane(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + // Tilt reset + wrapper.querySelector(".tilt-reset")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.#tiltX = 35; + this.#tiltY = -15; + this.#updateTilt(); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + // Close + wrapper.querySelector(".close-btn")!.addEventListener("click", (e) => { + e.stopPropagation(); + this.dispatchEvent(new CustomEvent("close")); + }); + + // Interactive orbit (pointer drag on scene) + this.#sceneEl.addEventListener("pointerdown", (e) => { + // Only orbit with left button on the scene background + if (e.button !== 0) return; + const target = e.target as HTMLElement; + if (target !== this.#sceneEl && target !== this.#stageEl) return; + e.stopPropagation(); + this.#isDragging = true; + this.#lastPointerX = e.clientX; + this.#lastPointerY = e.clientY; + this.#sceneEl!.setPointerCapture(e.pointerId); + }); + + this.#sceneEl.addEventListener("pointermove", (e) => { + if (!this.#isDragging) return; + e.stopPropagation(); + const dx = e.clientX - this.#lastPointerX; + const dy = e.clientY - this.#lastPointerY; + this.#lastPointerX = e.clientX; + this.#lastPointerY = e.clientY; + + this.#tiltY += dx * 0.5; + this.#tiltX = Math.max(0, Math.min(80, this.#tiltX - dy * 0.5)); + this.#updateTilt(); + }); + + this.#sceneEl.addEventListener("pointerup", (e) => { + if (!this.#isDragging) return; + e.stopPropagation(); + this.#isDragging = false; + this.#sceneEl!.releasePointerCapture(e.pointerId); + this.dispatchEvent(new CustomEvent("content-change")); + }); + + this.#sceneEl.addEventListener("lostpointercapture", () => { + this.#isDragging = false; + }); + + // Load initial data + if (this.#axes.length === 0 && this.#datasets.length === 0) { + this.#axes = DEMO_CONFIG.axes; + this.#datasets = DEMO_CONFIG.datasets; + } + + this.#recompute(); + return root; + } + + // ── Computation ── + + #recompute() { + const visibleDatasets = this.#datasets.filter( + (ds) => !this.#hiddenDatasets.has(ds.id), + ); + + const config: Spider3DConfig = { + axes: this.#axes, + datasets: visibleDatasets, + resolution: 36, + }; + + this.#result = computeSpider3D(config, 140, 140, 110); + this.#render(); + } + + #updateTilt() { + if (!this.#stageEl) return; + this.#stageEl.style.transform = + `rotateX(${this.#tiltX}deg) rotateY(${this.#tiltY}deg)`; + } + + // ── Membrane mode ── + + async #loadMembrane() { + if (!this.#space) { + // Fall back to demo data + this.#axes = DEMO_CONFIG.axes; + this.#datasets = DEMO_CONFIG.datasets; + this.#recompute(); + return; + } + + try { + const token = localStorage.getItem("rspace-token") || ""; + const res = await fetch(`/${this.#space}/connections`, { + headers: token ? { Authorization: `Bearer ${token}` } : {}, + }); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + const connections = await res.json(); + const preset = membranePreset(connections); + this.#axes = preset.axes; + this.#datasets = preset.datasets; + this.#recompute(); + } catch { + // Fall back to demo data + this.#axes = DEMO_CONFIG.axes; + this.#datasets = DEMO_CONFIG.datasets; + this.#recompute(); + } + } + + // ── Rendering ── + + #render() { + this.#renderStage(); + this.#renderLegend(); + this.#renderInfo(); + } + + #renderStage() { + if (!this.#stageEl || !this.#result) return; + + const { layers, overlapRegions, maxHeight } = this.#result; + const CX = 140, CY = 140, R = 110; + const n = this.#axes.length; + if (n < 3) { + this.#stageEl.innerHTML = ""; + return; + } + + const angleStep = (2 * Math.PI) / n; + const RINGS = 5; + + let layersHtml = ""; + + // ── Grid layer (z = 0) ── + let gridSvg = ``; + + // Grid rings + for (let ring = 1; ring <= RINGS; ring++) { + const r = (R / RINGS) * ring; + const pts = Array.from({ length: n }, (_, i) => { + const angle = i * angleStep - Math.PI / 2; + return `${CX + r * Math.cos(angle)},${CY + r * Math.sin(angle)}`; + }).join(" "); + gridSvg += ``; + } + + // Axis spokes + labels + for (let i = 0; i < n; i++) { + const angle = i * angleStep - Math.PI / 2; + const ex = CX + R * Math.cos(angle); + const ey = CY + R * Math.sin(angle); + const lx = CX + (R + 18) * Math.cos(angle); + const ly = CY + (R + 18) * Math.sin(angle); + gridSvg += ``; + gridSvg += `${escapeHtml(this.#axes[i].label)}`; + } + + gridSvg += ``; + layersHtml += `
${gridSvg}
`; + + // ── Dataset layers ── + for (const layer of layers) { + if (layer.vertices.length < 3) continue; + const z = (layer.zIndex + 1) * this.#layerSpacing; + const pts = layer.vertices.map((v) => `${v.x},${v.y}`).join(" "); + + let svg = ``; + svg += ``; + + // Vertex dots + for (const v of layer.vertices) { + svg += ``; + } + + svg += ``; + layersHtml += `
${svg}
`; + } + + // ── Overlap layers ── + if (this.#showOverlapHeight && overlapRegions.length > 0) { + const baseZ = (layers.length + 1) * this.#layerSpacing; + + for (const region of overlapRegions) { + if (region.vertices.length < 3) continue; + + const colors = region.contributorIds.map((id) => { + const ds = this.#datasets.find((d) => d.id === id); + return ds?.color ?? "#ffffff"; + }); + const blended = blendColors(colors); + + // Stack multiple semi-transparent layers for taller overlap regions + const overlapLayers = Math.min(region.height, 4); + for (let ol = 0; ol < overlapLayers; ol++) { + const z = baseZ + ol * (this.#layerSpacing * 0.6); + const pts = region.vertices.map((v) => `${v.x},${v.y}`).join(" "); + const opacity = 0.15 + ol * 0.05; + + let svg = ``; + svg += ``; + svg += ``; + layersHtml += `
${svg}
`; + } + } + } + + this.#stageEl.innerHTML = layersHtml; + this.#updateTilt(); + } + + #renderLegend() { + if (!this.#legendEl) return; + + this.#legendEl.innerHTML = this.#datasets + .map((ds) => { + const hidden = this.#hiddenDatasets.has(ds.id); + return ` + + ${escapeHtml(ds.label)} + `; + }) + .join(""); + + this.#legendEl.querySelectorAll(".legend-item").forEach((el) => { + el.addEventListener("click", (e) => { + e.stopPropagation(); + const dsId = (el as HTMLElement).dataset.ds!; + if (this.#hiddenDatasets.has(dsId)) { + this.#hiddenDatasets.delete(dsId); + } else { + this.#hiddenDatasets.add(dsId); + } + this.#recompute(); + }); + }); + } + + #renderInfo() { + if (!this.#infoEl || !this.#result) return; + + const visibleCount = this.#datasets.length - this.#hiddenDatasets.size; + const overlapCount = this.#result.overlapRegions.length; + const infoText = this.#infoEl.querySelector(".info-text") as HTMLElement; + const overlapEl = this.#infoEl.querySelector(".overlap-count") as HTMLElement; + + if (infoText) infoText.textContent = `${visibleCount} dataset${visibleCount !== 1 ? "s" : ""}`; + if (overlapEl) overlapEl.textContent = overlapCount > 0 + ? `${overlapCount} overlap region${overlapCount !== 1 ? "s" : ""}` + : ""; + } + + // ── Serialization ── + + override toJSON() { + return { + ...super.toJSON(), + type: "folk-spider-3d", + title: this.#title, + axes: this.#axes, + datasets: this.#datasets, + tiltX: this.#tiltX, + tiltY: this.#tiltY, + layerSpacing: this.#layerSpacing, + showOverlapHeight: this.#showOverlapHeight, + mode: this.#mode, + space: this.#space, + }; + } +} diff --git a/lib/index.ts b/lib/index.ts index a9b5246..98a3133 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -72,6 +72,9 @@ export * from "./folk-choice-rank"; export * from "./folk-choice-spider"; export * from "./folk-choice-conviction"; +// 3D Spider Plot (governance visualization) +export * from "./folk-spider-3d"; + // Nested Space Shape export * from "./folk-canvas"; diff --git a/lib/mi-tool-schema.ts b/lib/mi-tool-schema.ts index 440da63..0d4034f 100644 --- a/lib/mi-tool-schema.ts +++ b/lib/mi-tool-schema.ts @@ -26,12 +26,13 @@ const TOOL_HINTS: ToolHint[] = [ { tagName: "folk-social-post", label: "Social Post", icon: "📣", keywords: ["social", "post", "twitter", "instagram", "campaign"] }, { tagName: "folk-splat", label: "3D Gaussian", icon: "💎", keywords: ["3d", "splat", "gaussian", "point cloud"] }, { tagName: "folk-drawfast", label: "Drawing", icon: "✏️", keywords: ["draw", "sketch", "whiteboard", "pencil"] }, - { tagName: "folk-rapp", label: "rApp Embed", icon: "📦", keywords: ["rapp", "module", "embed", "app"] }, + { tagName: "folk-rapp", label: "rApp Embed", icon: "📦", keywords: ["rapp", "module", "embed", "app", "crm", "contacts", "pipeline", "companies"] }, { tagName: "folk-feed", label: "Feed", icon: "📡", keywords: ["feed", "data", "stream", "flow"] }, { tagName: "folk-piano", label: "Piano", icon: "🎹", keywords: ["piano", "music", "instrument", "midi"] }, { tagName: "folk-choice-vote", label: "Vote", icon: "🗳️", keywords: ["vote", "poll", "election", "choice"] }, { tagName: "folk-choice-rank", label: "Ranking", icon: "📊", keywords: ["rank", "order", "priority", "sort"] }, { tagName: "folk-choice-spider", label: "Spider Chart", icon: "🕸️", keywords: ["spider", "radar", "criteria", "evaluate"] }, + { tagName: "folk-spider-3d", label: "3D Spider", icon: "📊", keywords: ["spider", "radar", "3d", "overlap", "membrane", "governance", "permeability"] }, ]; /** diff --git a/lib/spider-3d.ts b/lib/spider-3d.ts new file mode 100644 index 0000000..2088a06 --- /dev/null +++ b/lib/spider-3d.ts @@ -0,0 +1,422 @@ +/** + * 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, + }, + }, + ], +}; diff --git a/website/canvas.html b/website/canvas.html index 5fe1ccb..b0e8c20 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1293,6 +1293,7 @@ folk-choice-vote, folk-choice-rank, folk-choice-spider, + folk-spider-3d, folk-choice-conviction, folk-social-post, folk-splat, @@ -1311,7 +1312,7 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, + folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad, folk-zine-gen, folk-rapp) { cursor: crosshair; @@ -1323,7 +1324,7 @@ folk-video-chat, folk-obs-note, folk-workflow-block, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking, folk-token-mint, folk-token-ledger, - folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, + folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-freecad, folk-kicad, folk-zine-gen, folk-rapp):hover { outline: 2px dashed #3b82f6; @@ -1799,6 +1800,7 @@ + @@ -1971,6 +1973,7 @@ FolkChoiceVote, FolkChoiceRank, FolkChoiceSpider, + FolkSpider3D, FolkChoiceConviction, FolkSocialPost, FolkSplat, @@ -2090,6 +2093,7 @@ FolkChoiceVote.define(); FolkChoiceRank.define(); FolkChoiceSpider.define(); + FolkSpider3D.define(); FolkChoiceConviction.define(); FolkSocialPost.define(); FolkSplat.define(); @@ -2315,7 +2319,7 @@ "folk-budget", "folk-packing-list", "folk-booking", "folk-token-mint", "folk-token-ledger", "folk-choice-vote", "folk-choice-rank", "folk-choice-spider", - "folk-choice-conviction", "folk-social-post", + "folk-spider-3d", "folk-choice-conviction", "folk-social-post", "folk-splat", "folk-blender", "folk-drawfast", "folk-freecad", "folk-kicad", "folk-rapp", @@ -3021,6 +3025,18 @@ if (data.criteria) shape.criteria = data.criteria; if (data.scores) shape.scores = data.scores; break; + case "folk-spider-3d": + shape = document.createElement("folk-spider-3d"); + if (data.title) shape.title = data.title; + if (data.axes) shape.axes = data.axes; + if (data.datasets) shape.datasets = data.datasets; + if (data.tiltX != null) shape.tiltX = data.tiltX; + if (data.tiltY != null) shape.tiltY = data.tiltY; + if (data.layerSpacing != null) shape.layerSpacing = data.layerSpacing; + if (data.showOverlapHeight != null) shape.showOverlapHeight = data.showOverlapHeight; + if (data.mode) shape.mode = data.mode; + if (data.space) shape.space = data.space; + break; case "folk-choice-conviction": shape = document.createElement("folk-choice-conviction"); if (data.title) shape.title = data.title; @@ -3185,6 +3201,7 @@ "folk-choice-vote": { width: 360, height: 400 }, "folk-choice-rank": { width: 380, height: 480 }, "folk-choice-spider": { width: 440, height: 540 }, + "folk-spider-3d": { width: 440, height: 480 }, "folk-choice-conviction": { width: 380, height: 480 }, "folk-social-post": { width: 300, height: 380 }, "folk-splat": { width: 480, height: 420 }, @@ -3675,6 +3692,12 @@ }); }); + document.getElementById("new-spider-3d").addEventListener("click", () => { + setPendingTool("folk-spider-3d", { + title: "Spider 3D", + }); + }); + document.getElementById("new-conviction").addEventListener("click", () => { setPendingTool("folk-choice-conviction", { title: "Conviction Ranking", @@ -4367,7 +4390,7 @@ "folk-budget": "💰", "folk-packing-list": "🎒", "folk-booking": "✈️", "folk-token-mint": "🪙", "folk-token-ledger": "📒", "folk-choice-vote": "☑", "folk-choice-rank": "📊", - "folk-choice-spider": "🕸", "folk-choice-conviction": "⏳", "folk-social-post": "📱", + "folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱", "folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️", "folk-freecad": "📐", "folk-kicad": "🔌", "folk-rapp": "📱", "folk-feed": "🔄", "folk-arrow": "↗️", @@ -5082,7 +5105,7 @@ function sortFeedShapes(key) { const shapes = [...canvasContent.querySelectorAll( - 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-choice-conviction, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-zine-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking' + 'folk-shape, folk-markdown, folk-wrapper, folk-slide, folk-chat, folk-obs-note, folk-rapp, folk-embed, folk-drawfast, folk-prompt, folk-zine-gen, folk-workflow-block, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-token, folk-google-item, folk-social-post, folk-calendar, folk-map, folk-piano, folk-splat, folk-video-chat, folk-transcription, folk-image-gen, folk-video-gen, folk-zine-gen, folk-blender, folk-freecad, folk-kicad, folk-itinerary, folk-destination, folk-budget, folk-packing-list, folk-booking' )]; shapes.sort((a, b) => {