= {};
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 ? '›' : '';
return isLast
? `${sep}${esc(b.label)}`
: `${sep}${esc(b.label)}`;
}).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 = 'Loading...
';
} else if (this.#children.length === 0 && this.#breadcrumb.length > 0) {
this.#contentEl.innerHTML = 'No children found
';
} else if (this.#children.length === 0) {
this.#contentEl.innerHTML = 'Enter a space or H3 cell to explore
';
} else {
this.#renderExplorer();
}
}
#renderH3Form() {
this.#contentEl!.innerHTML = html`
`;
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 N = this.#children.length;
const MAX_VISIBLE = 12;
const visible = this.#children.slice(0, MAX_VISIBLE);
const overflow = N > MAX_VISIBLE ? N - MAX_VISIBLE : 0;
// SVG dimensions (use fixed viewBox, scales with container)
const W = 400, H = 300;
const cx = W / 2, cy = H / 2;
let orbit = Math.min(W, H) * 0.35;
if (N > 8) orbit *= 0.85;
// MetatronGrid
const gridSvg = metatronGrid(Math.min(W, H));
// Build node positions
const nodesSvg: string[] = [];
const linesSvg: 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));
// Connection line
linesSvg.push(
``
);
// Node circle
const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`;
nodesSvg.push(`
${esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label)}
`);
}
// Center node
const centerSvg = `
${esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?')}
`;
// Overflow indicator
const overflowSvg = overflow > 0
? `+${overflow} more`
: '';
// Slider panel
const sliders = visible.map((child, i) => `
${Math.round(child.weight)}
`).join('');
const sum = this.#children.reduce((s, c) => s + c.weight, 0);
this.#contentEl!.innerHTML = html`
${sliders}
Total: ${Math.round(sum)}%
`;
// 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();
});
});
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: 'folk-holon-explorer',
mode: this.#mode,
rootId: this.#rootId,
spaceSlug: this.#spaceSlug,
breadcrumb: this.#breadcrumb,
};
}
static override fromData(data: Record): FolkHolonExplorer {
const shape = FolkShape.fromData(data) as FolkHolonExplorer;
if (data.mode) shape.setAttribute('mode', data.mode);
if (data.rootId) shape.setAttribute('root-id', data.rootId);
if (data.spaceSlug) shape.setAttribute('space-slug', data.spaceSlug);
return shape;
}
override applyData(data: Record): 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.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, '&').replace(//g, '>').replace(/"/g, '"');
}