686 lines
24 KiB
TypeScript
686 lines
24 KiB
TypeScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||
}
|