rspace-online/lib/folk-holon-explorer.ts

686 lines
24 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* folk-holon-explorer — Zoomable hexagonal hierarchy navigator.
*
* Dual navigation:
* - H3 Mode: Navigate H3 resolution levels via h3-js hierarchy
* - Space Mode: Navigate nested rSpace spaces via /api/spaces/:slug/nest
*
* Features:
* - Circular SVG layout (Holons/Zircle arc placement)
* - MetatronGrid sacred geometry background
* - Appreciation weight normalization (sum ≤ 100%)
* - Endorsement logging to trust engine on weight save
*/
import * as h3 from 'h3-js';
import { FolkShape } from './folk-shape';
import { css, html } from './tags';
import { getHolonHierarchy, getResolutionName } from './holon-service';
// ── Types ──
interface ExplorerNode {
id: string;
label: string;
weight: number;
ownerDID?: string;
}
type NavMode = 'h3' | 'space';
type ViewMode = 'holon' | 'graph';
// ── Appreciation normalization (from Holons project) ──
function normalize(values: number[], changedIndex: number): number[] {
const total = values.reduce((s, v) => s + v, 0);
if (total <= 100) return values;
const excess = total - 100;
const othersSum = total - values[changedIndex];
if (othersSum === 0) return values.map((v, i) => i === changedIndex ? 100 : 0);
return values.map((v, i) =>
i === changedIndex ? v : Math.max(0, v - excess * v / othersSum)
);
}
// ── Hex polygon helper ──
function hexPoly(cx: number, cy: number, r: number): string {
const pts: string[] = [];
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i - Math.PI / 2;
pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`);
}
return pts.join(' ');
}
// ── MetatronGrid SVG ──
function metatronGrid(size: number): string {
const cx = size / 2, cy = size / 2, r = size * 0.42, ri = r * 0.5;
const hex = (radius: number) => {
const pts: string[] = [];
for (let i = 0; i < 6; i++) {
const a = (Math.PI / 3) * i - Math.PI / 2;
pts.push(`${cx + radius * Math.cos(a)},${cy + radius * Math.sin(a)}`);
}
return pts.join(' ');
};
// Star of David: two overlapping triangles
const tri = (offset: number) => {
const pts: string[] = [];
for (let i = 0; i < 3; i++) {
const a = (2 * Math.PI / 3) * i + offset - Math.PI / 2;
pts.push(`${cx + r * Math.cos(a)},${cy + r * Math.sin(a)}`);
}
return pts.join(' ');
};
return `<polygon points="${hex(r)}" fill="none" stroke="currentColor" opacity="0.12"/>
<polygon points="${hex(ri)}" fill="none" stroke="currentColor" opacity="0.08"/>
<polygon points="${tri(0)}" fill="none" stroke="currentColor" opacity="0.1"/>
<polygon points="${tri(Math.PI / 3)}" fill="none" stroke="currentColor" opacity="0.1"/>`;
}
// ── Styles ──
const styles = css`
:host {
background: #0f172a;
color: #e2e8f0;
border-radius: 8px;
box-shadow: 0 2px 12px rgba(0,0,0,0.3);
min-width: 420px;
min-height: 400px;
}
.header {
display: flex; align-items: center; justify-content: space-between;
padding: 8px 12px; background: #0e7490; color: white;
border-radius: 8px 8px 0 0; font-size: 12px; font-weight: 600; cursor: move;
}
.header-left { display: flex; align-items: center; gap: 6px; min-width: 0; }
.header-left span:last-child { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.header-right { display: flex; gap: 4px; flex-shrink: 0; }
.header-right button, .mode-toggle {
background: transparent; border: none; color: white; cursor: pointer;
padding: 2px 6px; border-radius: 4px; font-size: 12px;
}
.header-right button:hover, .mode-toggle:hover { background: rgba(255,255,255,0.2); }
.mode-toggle.active { background: rgba(255,255,255,0.25); font-weight: 700; }
.breadcrumb {
display: flex; gap: 4px; padding: 6px 12px; font-size: 11px;
color: #94a3b8; background: #1e293b; flex-wrap: wrap; align-items: center;
}
.breadcrumb span { cursor: pointer; }
.breadcrumb span:hover { color: #06b6d4; text-decoration: underline; }
.breadcrumb .sep { color: #475569; cursor: default; }
.breadcrumb .sep:hover { color: #475569; text-decoration: none; }
.breadcrumb .current { color: #e2e8f0; font-weight: 600; cursor: default; }
.breadcrumb .current:hover { color: #e2e8f0; text-decoration: none; }
.view-strip {
display: flex; background: #1e293b; border-bottom: 1px solid rgba(255,255,255,0.08);
}
.view-btn {
flex: 1; font-size: 11px; color: #64748b; background: transparent; border: none;
padding: 6px 0; cursor: pointer; border-bottom: 2px solid transparent; transition: all 0.15s;
}
.view-btn:hover { color: #94a3b8; }
.view-btn.active { color: #10b981; border-bottom-color: #10b981; background: rgba(16,185,129,0.07); }
.content {
display: flex; flex-direction: column; overflow: hidden;
height: calc(100% - 94px); /* header + breadcrumb + view-strip */
}
.svg-wrap { flex: 1; min-height: 0; position: relative; }
.svg-wrap svg { width: 100%; height: 100%; }
.slider-panel {
padding: 8px 12px; max-height: 140px; overflow-y: auto;
border-top: 1px solid rgba(255,255,255,0.08);
}
.slider-row {
display: flex; align-items: center; gap: 8px; font-size: 11px; margin-bottom: 4px;
}
.slider-row label { width: 90px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: #94a3b8; }
.slider-row input[type=range] { flex: 1; accent-color: #10b981; }
.slider-row .val { width: 30px; text-align: right; color: #10b981; font-weight: 600; }
.sum-row { font-size: 11px; color: #64748b; text-align: right; padding: 2px 0; }
.connect-form {
display: flex; flex-direction: column; align-items: center;
justify-content: center; height: 100%; gap: 12px; padding: 16px; text-align: center;
}
.connect-form input {
padding: 8px 12px; border: 1px solid rgba(255,255,255,0.1);
border-radius: 6px; font-size: 13px; font-family: monospace;
background: #1e293b; color: #e2e8f0; outline: none; width: 100%; max-width: 320px;
}
.connect-form input:focus { border-color: #06b6d4; box-shadow: 0 0 0 2px rgba(6,182,212,0.2); }
.connect-form button {
padding: 8px 20px; background: #0e7490; color: white; border: none;
border-radius: 6px; cursor: pointer; font-size: 13px; font-weight: 600;
}
.connect-form button:hover { background: #0891b2; }
.hint { font-size: 12px; color: #64748b; }
.empty { display: flex; align-items: center; justify-content: center; height: 100%; color: #64748b; font-size: 13px; }
.more-label { font-size: 10px; fill: #64748b; }
`;
declare global {
interface HTMLElementTagNameMap {
'folk-holon-explorer': FolkHolonExplorer;
}
}
export class FolkHolonExplorer extends FolkShape {
static override tagName = 'folk-holon-explorer';
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;
}
// ── State ──
#mode: NavMode = 'space';
#view: ViewMode = 'holon';
#rootId = '';
#spaceSlug = '';
#breadcrumb: { id: string; label: string }[] = [];
#children: ExplorerNode[] = [];
#loading = false;
#contentEl: HTMLElement | null = null;
#saveTimer: ReturnType<typeof setTimeout> | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
this.#mode = (this.getAttribute('mode') as NavMode) || 'space';
this.#view = (this.getAttribute('view') as ViewMode) || 'holon';
this.#rootId = this.getAttribute('root-id') || '';
this.#spaceSlug = this.getAttribute('space-slug') || (window as any).__rspaceSpace || '';
const wrapper = document.createElement('div');
wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
wrapper.innerHTML = html`
<div class="header">
<div class="header-left">
<span>🔮</span>
<span>Holon Explorer</span>
</div>
<div class="header-right">
<button class="mode-toggle ${this.#mode === 'space' ? 'active' : ''}" data-mode="space" title="Space Mode">Spaces</button>
<button class="mode-toggle ${this.#mode === 'h3' ? 'active' : ''}" data-mode="h3" title="H3 Hex Mode">H3</button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="breadcrumb"></div>
<div class="view-strip">
<button class="view-btn ${this.#view === 'holon' ? 'active' : ''}" data-view="holon">⬤ Holon</button>
<button class="view-btn ${this.#view === 'graph' ? 'active' : ''}" data-view="graph">⬡ Graph</button>
</div>
<div class="content"></div>
`;
const slot = root.querySelector('slot');
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#contentEl = wrapper.querySelector('.content');
// Wire mode toggles
wrapper.querySelectorAll('.mode-toggle').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const m = (btn as HTMLElement).dataset.mode as NavMode;
if (m !== this.#mode) {
this.#mode = m;
this.#breadcrumb = [];
this.#children = [];
wrapper.querySelectorAll('.mode-toggle').forEach((b) =>
b.classList.toggle('active', (b as HTMLElement).dataset.mode === m));
this.#render();
}
});
});
// Wire view toggles
wrapper.querySelectorAll('.view-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
const v = (btn as HTMLElement).dataset.view as ViewMode;
if (v !== this.#view) {
this.#view = v;
wrapper.querySelectorAll('.view-btn').forEach((b) =>
b.classList.toggle('active', (b as HTMLElement).dataset.view === v));
this.#render();
}
});
});
wrapper.querySelector('.close-btn')!.addEventListener('click', (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('close'));
});
// Auto-load
if (this.#mode === 'space' && this.#spaceSlug) {
this.#breadcrumb = [{ id: this.#spaceSlug, label: this.#spaceSlug }];
this.#loadSpaceChildren(this.#spaceSlug);
} else if (this.#mode === 'h3' && this.#rootId) {
this.#breadcrumb = [{ id: this.#rootId, label: getResolutionName(h3.getResolution(this.#rootId)) }];
this.#loadH3Children(this.#rootId);
}
this.#render();
return root;
}
disconnectedCallback() {
if (this.#saveTimer) clearTimeout(this.#saveTimer);
}
// ── Data loading ──
async #loadSpaceChildren(slug: string) {
this.#loading = true;
this.#render();
try {
const res = await fetch(`/api/spaces/${encodeURIComponent(slug)}/nest`, { signal: AbortSignal.timeout(5000) });
if (!res.ok) { this.#children = []; this.#loading = false; this.#render(); return; }
const data = await res.json();
const nested: any[] = data.nestedSpaces || [];
this.#children = nested.map((s: any) => ({
id: s.slug || s.id,
label: s.name || s.slug || s.id,
weight: 100 / Math.max(nested.length, 1),
ownerDID: s.ownerDID,
}));
this.#loadSavedWeights();
} catch { this.#children = []; }
this.#loading = false;
this.#render();
}
#loadH3Children(cellId: string) {
this.#loading = true;
this.#render();
try {
const { children } = getHolonHierarchy(cellId);
// H3 can produce many children — show first 12
const shown = children.slice(0, 12);
this.#children = shown.map((c) => ({
id: c,
label: `${getResolutionName(h3.getResolution(c))} ${c.slice(-6)}`,
weight: 100 / Math.max(shown.length, 1),
}));
this.#loadSavedWeights();
} catch { this.#children = []; }
this.#loading = false;
this.#render();
}
#drillInto(node: ExplorerNode) {
this.#breadcrumb.push({ id: node.id, label: node.label });
this.#children = [];
if (this.#mode === 'space') {
this.#loadSpaceChildren(node.id);
} else {
this.#loadH3Children(node.id);
}
}
#navigateTo(index: number) {
if (index >= this.#breadcrumb.length - 1) return;
const target = this.#breadcrumb[index];
this.#breadcrumb = this.#breadcrumb.slice(0, index + 1);
this.#children = [];
if (this.#mode === 'space') {
this.#loadSpaceChildren(target.id);
} else {
this.#loadH3Children(target.id);
}
}
// ── Appreciation persistence ──
#weightKey(): string {
const current = this.#breadcrumb[this.#breadcrumb.length - 1];
return current ? `holon-explorer:weights:${current.id}` : '';
}
#loadSavedWeights() {
const key = this.#weightKey();
if (!key) return;
try {
const raw = localStorage.getItem(key);
if (!raw) return;
const saved: Record<string, number> = JSON.parse(raw);
for (const child of this.#children) {
if (saved[child.id] != null) child.weight = saved[child.id];
}
} catch { /* ignore */ }
}
#saveWeights() {
const key = this.#weightKey();
if (!key) return;
const map: Record<string, number> = {};
for (const c of this.#children) map[c.id] = c.weight;
try { localStorage.setItem(key, JSON.stringify(map)); } catch { /* ignore */ }
}
#scheduleEndorse() {
if (this.#saveTimer) clearTimeout(this.#saveTimer);
this.#saveTimer = setTimeout(() => this.#fireEndorsements(), 1500);
}
async #fireEndorsements() {
const space = this.#spaceSlug || (window as any).__rspaceSpace;
const token = (window as any).__rspaceAuthToken;
if (!space || !token) return;
for (const child of this.#children) {
if (!child.ownerDID) continue;
try {
await fetch(`/${encodeURIComponent(space)}/rnetwork/api/endorse`, {
method: 'POST',
headers: { 'Content-Type': 'application/json', 'Authorization': `Bearer ${token}` },
body: JSON.stringify({
targetDid: child.ownerDID,
authority: 'gov-ops',
weight: child.weight / 100,
space,
}),
});
} catch { /* silent */ }
}
}
// ── Rendering ──
#render() {
if (!this.#contentEl) return;
// Breadcrumb
const bc = this.renderRoot.querySelector('.breadcrumb') as HTMLElement;
if (bc) {
if (this.#breadcrumb.length === 0) {
bc.innerHTML = '';
} else {
bc.innerHTML = this.#breadcrumb.map((b, i) => {
const isLast = i === this.#breadcrumb.length - 1;
const sep = i > 0 ? '<span class="sep"></span>' : '';
return isLast
? `${sep}<span class="current">${esc(b.label)}</span>`
: `${sep}<span data-idx="${i}">${esc(b.label)}</span>`;
}).join('');
bc.querySelectorAll('span[data-idx]').forEach((el) => {
el.addEventListener('click', (e) => {
e.stopPropagation();
this.#navigateTo(parseInt((el as HTMLElement).dataset.idx!, 10));
});
});
}
}
// Content
if (this.#mode === 'h3' && this.#breadcrumb.length === 0) {
this.#renderH3Form();
} else if (this.#loading) {
this.#contentEl.innerHTML = '<div class="empty">Loading...</div>';
} else if (this.#children.length === 0 && this.#breadcrumb.length > 0) {
this.#contentEl.innerHTML = '<div class="empty">No children found</div>';
} else if (this.#children.length === 0) {
this.#contentEl.innerHTML = '<div class="empty">Enter a space or H3 cell to explore</div>';
} else {
this.#renderExplorer();
}
}
#renderH3Form() {
this.#contentEl!.innerHTML = html`
<div class="connect-form">
<div style="font-size:36px">⬡</div>
<div style="font-weight:600">H3 Hex Explorer</div>
<div class="hint">Enter an H3 cell ID to explore its hierarchy</div>
<input type="text" class="h3-input" placeholder="e.g. 872a1070bffffff" />
<button class="go-btn">Explore</button>
</div>
`;
const input = this.#contentEl!.querySelector('.h3-input') as HTMLInputElement;
const btn = this.#contentEl!.querySelector('.go-btn') as HTMLButtonElement;
const go = () => {
const val = input.value.trim();
if (!val) return;
try {
if (!h3.isValidCell(val)) return;
this.#rootId = val;
this.#breadcrumb = [{ id: val, label: getResolutionName(h3.getResolution(val)) }];
this.#loadH3Children(val);
} catch { /* invalid */ }
};
input.addEventListener('pointerdown', (e) => e.stopPropagation());
input.addEventListener('keydown', (e) => { e.stopPropagation(); if (e.key === 'Enter') go(); });
btn.addEventListener('click', (e) => { e.stopPropagation(); go(); });
}
#renderExplorer() {
const MAX_VISIBLE = 12;
const visible = this.#children.slice(0, MAX_VISIBLE);
const overflow = this.#children.length > MAX_VISIBLE ? this.#children.length - MAX_VISIBLE : 0;
// Build SVG based on active view
const W = 400, H = 300;
const svgContent = this.#view === 'graph'
? this.#buildGraphSvg(visible, overflow, W, H)
: this.#buildHolonSvg(visible, overflow, W, H);
// Slider panel
const sliders = visible.map((child, i) => `
<div class="slider-row">
<label title="${esc(child.label)}">${esc(child.label)}</label>
<input type="range" min="0" max="100" value="${Math.round(child.weight)}" data-idx="${i}" />
<span class="val">${Math.round(child.weight)}</span>
</div>
`).join('');
const sum = this.#children.reduce((s, c) => s + c.weight, 0);
this.#contentEl!.innerHTML = html`
<div class="svg-wrap">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
${svgContent}
</svg>
</div>
<div class="slider-panel">
${sliders}
<div class="sum-row">Total: ${Math.round(sum)}%</div>
</div>
`;
// Wire node clicks
this.#contentEl!.querySelectorAll('.node').forEach((g) => {
g.addEventListener('click', (e) => {
e.stopPropagation();
const idx = parseInt((g as HTMLElement).dataset.idx!, 10);
if (idx >= 0 && idx < visible.length) this.#drillInto(visible[idx]);
});
});
// Wire sliders
this.#contentEl!.querySelectorAll('input[type=range]').forEach((el) => {
el.addEventListener('pointerdown', (e) => e.stopPropagation());
el.addEventListener('input', (e) => {
e.stopPropagation();
const idx = parseInt((el as HTMLElement).dataset.idx!, 10);
const val = parseFloat((el as HTMLInputElement).value);
const weights = this.#children.map((c) => c.weight);
weights[idx] = val;
const normalized = normalize(weights, idx);
for (let i = 0; i < this.#children.length; i++) this.#children[i].weight = normalized[i];
// Update slider UI without full re-render
this.#contentEl!.querySelectorAll('.slider-row').forEach((row, ri) => {
const inp = row.querySelector('input') as HTMLInputElement;
const span = row.querySelector('.val') as HTMLElement;
if (inp && span && ri < this.#children.length) {
inp.value = String(Math.round(this.#children[ri].weight));
span.textContent = String(Math.round(this.#children[ri].weight));
}
});
const sumEl = this.#contentEl!.querySelector('.sum-row');
if (sumEl) sumEl.textContent = `Total: ${Math.round(this.#children.reduce((s, c) => s + c.weight, 0))}%`;
this.#saveWeights();
this.#scheduleEndorse();
});
});
}
#buildHolonSvg(visible: ExplorerNode[], overflow: number, W: number, H: number): string {
const cx = W / 2, cy = H / 2;
let orbit = Math.min(W, H) * 0.35;
if (this.#children.length > 8) orbit *= 0.85;
const gridSvg = metatronGrid(Math.min(W, H));
const linesSvg: string[] = [];
const nodesSvg: string[] = [];
for (let i = 0; i < visible.length; i++) {
const child = visible[i];
const angle = -110 + (220 / (visible.length + 1)) * (i + 1);
const rad = (angle - 90) * Math.PI / 180;
const nx = cx + orbit * Math.cos(rad);
const ny = cy + orbit * Math.sin(rad);
const r = Math.min(36, Math.max(16, 16 + child.weight * 0.3));
linesSvg.push(
`<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="#10b981" stroke-opacity="0.3" stroke-width="${1 + child.weight * 0.03}"/>`
);
const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
nodesSvg.push(`
<g class="node" data-idx="${i}" style="cursor:pointer">
<circle cx="${nx}" cy="${ny}" r="${r}" fill="${fill}" stroke="#10b981" stroke-width="1.5"/>
<text x="${nx}" y="${ny + r + 12}" text-anchor="middle" fill="#94a3b8" font-size="9">${esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label)}</text>
</g>
`);
}
const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?');
const centerSvg = `
<circle cx="${cx}" cy="${cy}" r="24" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" stroke-width="2"/>
<text x="${cx}" y="${cy + 4}" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">${centerLabel}</text>
`;
const overflowSvg = overflow > 0
? `<text x="${cx}" y="${cy + orbit + 30}" text-anchor="middle" class="more-label">+${overflow} more</text>`
: '';
return `${gridSvg}${linesSvg.join('')}${centerSvg}${nodesSvg.join('')}${overflowSvg}`;
}
#buildGraphSvg(visible: ExplorerNode[], overflow: number, W: number, H: number): string {
const cx = W / 2, cy = H / 2;
const orbit = Math.min(W, H) * 0.32;
const gridSvg = metatronGrid(Math.min(W, H));
const linesSvg: string[] = [];
const nodesSvg: string[] = [];
const N = visible.length;
for (let i = 0; i < N; i++) {
const child = visible[i];
const angleDeg = (360 / N) * i - 90;
const rad = angleDeg * Math.PI / 180;
const nx = cx + orbit * Math.cos(rad);
const ny = cy + orbit * Math.sin(rad);
const hr = Math.min(24, Math.max(12, 12 + child.weight * 0.2));
linesSvg.push(
`<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="#10b981" stroke-opacity="0.5" stroke-width="${1 + child.weight * 0.03}"/>`
);
const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
const label = esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label);
// Radial label placement
const labelDist = hr + 12;
const lx = nx + labelDist * Math.cos(rad);
const ly = ny + labelDist * Math.sin(rad);
const normDeg = ((angleDeg % 360) + 360) % 360;
const anchor = normDeg > 90 && normDeg < 270 ? 'end' : normDeg === 90 || normDeg === 270 ? 'middle' : 'start';
nodesSvg.push(`
<g class="node" data-idx="${i}" style="cursor:pointer">
<polygon points="${hexPoly(nx, ny, hr)}" fill="${fill}" stroke="#10b981" stroke-width="1.5"/>
<text x="${lx}" y="${ly + 3}" text-anchor="${anchor}" fill="#94a3b8" font-size="9">${label}</text>
</g>
`);
}
const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?');
const centerSvg = `
<circle cx="${cx}" cy="${cy}" r="24" fill="rgba(6,182,212,0.2)" stroke="#06b6d4" stroke-width="2"/>
<text x="${cx}" y="${cy + 4}" text-anchor="middle" fill="#e2e8f0" font-size="11" font-weight="600">${centerLabel}</text>
`;
const overflowSvg = overflow > 0
? `<text x="${cx}" y="${H - 10}" text-anchor="middle" class="more-label">+${overflow} more</text>`
: '';
return `${gridSvg}${linesSvg.join('')}${centerSvg}${nodesSvg.join('')}${overflowSvg}`;
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: 'folk-holon-explorer',
mode: this.#mode,
view: this.#view,
rootId: this.#rootId,
spaceSlug: this.#spaceSlug,
breadcrumb: this.#breadcrumb,
};
}
static override fromData(data: Record<string, any>): FolkHolonExplorer {
const shape = FolkShape.fromData(data) as FolkHolonExplorer;
if (data.mode) shape.setAttribute('mode', data.mode);
if (data.view) shape.setAttribute('view', data.view);
if (data.rootId) shape.setAttribute('root-id', data.rootId);
if (data.spaceSlug) shape.setAttribute('space-slug', data.spaceSlug);
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
const modeChanged = data.mode && data.mode !== this.#mode;
const rootChanged = data.rootId && data.rootId !== this.#rootId;
const spaceChanged = data.spaceSlug && data.spaceSlug !== this.#spaceSlug;
if (data.mode) this.#mode = data.mode;
if (data.view) this.#view = data.view;
if (data.rootId) this.#rootId = data.rootId;
if (data.spaceSlug) this.#spaceSlug = data.spaceSlug;
if (data.breadcrumb) this.#breadcrumb = data.breadcrumb;
if (modeChanged || rootChanged || spaceChanged) {
if (this.#mode === 'space' && this.#spaceSlug) {
if (this.#breadcrumb.length === 0)
this.#breadcrumb = [{ id: this.#spaceSlug, label: this.#spaceSlug }];
this.#loadSpaceChildren(this.#breadcrumb[this.#breadcrumb.length - 1].id);
} else if (this.#mode === 'h3' && this.#rootId) {
if (this.#breadcrumb.length === 0)
this.#breadcrumb = [{ id: this.#rootId, label: getResolutionName(h3.getResolution(this.#rootId)) }];
this.#loadH3Children(this.#breadcrumb[this.#breadcrumb.length - 1].id);
}
}
this.#render();
}
}
function esc(s: string): string {
return s.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}