667 lines
17 KiB
TypeScript
667 lines
17 KiB
TypeScript
/**
|
|
* 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, ">").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<string>();
|
|
#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`
|
|
<div class="header">
|
|
<span class="header-title">
|
|
<span>🕸</span>
|
|
<span class="title-text">${escapeHtml(this.#title)}</span>
|
|
</span>
|
|
<div class="header-actions">
|
|
<div class="mode-toggle">
|
|
<button class="mode-btn mode-datasets active">Datasets</button>
|
|
<button class="mode-btn mode-membrane">Membrane</button>
|
|
</div>
|
|
<button class="tilt-reset" title="Reset tilt">↺</button>
|
|
<button class="close-btn" title="Close">×</button>
|
|
</div>
|
|
</div>
|
|
<div class="body">
|
|
<div class="scene">
|
|
<div class="stage"></div>
|
|
</div>
|
|
<div class="legend"></div>
|
|
<div class="info-bar">
|
|
<span class="info-text"></span>
|
|
<span class="overlap-count"></span>
|
|
</div>
|
|
<div class="tooltip"></div>
|
|
</div>
|
|
`;
|
|
|
|
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 = `<svg viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;">`;
|
|
|
|
// 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 += `<polygon points="${pts}" fill="none" stroke="rgba(148,163,184,0.2)" stroke-width="${ring === RINGS ? 1.5 : 0.5}"/>`;
|
|
}
|
|
|
|
// 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 += `<line x1="${CX}" y1="${CY}" x2="${ex}" y2="${ey}" stroke="rgba(148,163,184,0.15)" stroke-width="0.5"/>`;
|
|
gridSvg += `<text x="${lx}" y="${ly}" text-anchor="middle" dominant-baseline="central" fill="#64748b" font-size="9" font-family="system-ui">${escapeHtml(this.#axes[i].label)}</text>`;
|
|
}
|
|
|
|
gridSvg += `</svg>`;
|
|
layersHtml += `<div class="layer" style="transform:translateZ(0px)">${gridSvg}</div>`;
|
|
|
|
// ── 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 viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;">`;
|
|
svg += `<polygon points="${pts}" fill="${layer.color}" fill-opacity="0.3" stroke="${layer.color}" stroke-width="1.5" stroke-opacity="0.7"/>`;
|
|
|
|
// Vertex dots
|
|
for (const v of layer.vertices) {
|
|
svg += `<circle cx="${v.x}" cy="${v.y}" r="2.5" fill="${layer.color}" opacity="0.9"/>`;
|
|
}
|
|
|
|
svg += `</svg>`;
|
|
layersHtml += `<div class="layer" style="transform:translateZ(${z}px)">${svg}</div>`;
|
|
}
|
|
|
|
// ── 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 viewBox="0 0 280 280" xmlns="http://www.w3.org/2000/svg" style="width:100%;height:100%;">`;
|
|
svg += `<polygon points="${pts}" fill="${blended}" fill-opacity="${opacity}" stroke="rgba(255,255,255,0.3)" stroke-width="0.5"/>`;
|
|
svg += `</svg>`;
|
|
layersHtml += `<div class="layer overlap" style="transform:translateZ(${z}px)">${svg}</div>`;
|
|
}
|
|
}
|
|
}
|
|
|
|
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 `<span class="legend-item ${hidden ? "hidden" : ""}" data-ds="${escapeHtml(ds.id)}">
|
|
<span class="legend-dot" style="background:${ds.color}"></span>
|
|
${escapeHtml(ds.label)}
|
|
</span>`;
|
|
})
|
|
.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,
|
|
};
|
|
}
|
|
}
|