Merge branch 'dev'
This commit is contained in:
commit
82456933f1
|
|
@ -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, ">").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,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
@ -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";
|
||||
|
||||
|
|
|
|||
|
|
@ -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"] },
|
||||
];
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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<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,
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
|
|
@ -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 @@
|
|||
<button id="new-choice-vote" title="New Poll">☑ Poll</button>
|
||||
<button id="new-choice-rank" title="New Ranking">📊 Ranking</button>
|
||||
<button id="new-choice-spider" title="New Scoring">🕸 Scoring</button>
|
||||
<button id="new-spider-3d" title="3D Spider Plot">📊 3D Spider</button>
|
||||
<button id="new-conviction" title="Conviction Ranking">⏳ Conviction</button>
|
||||
<button id="new-token" title="New Token">🪙 Token</button>
|
||||
<button id="embed-funds" title="Embed rFunds">🌊 rFunds</button>
|
||||
|
|
@ -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) => {
|
||||
|
|
|
|||
Loading…
Reference in New Issue