/** * 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, }; } }