/** * 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 ` `; } // ── 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 | 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`
🔮 Holon Explorer
`; 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 = 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 = {}; 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`
H3 Hex Explorer
Enter an H3 cell ID to explore its hierarchy
`; 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) => `
${Math.round(child.weight)}
`).join(''); const sum = this.#children.reduce((s, c) => s + c.weight, 0); this.#contentEl!.innerHTML = html`
${svgContent}
${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(); }); }); } #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( `` ); 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)} `); } const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?'); const centerSvg = ` ${centerLabel} `; const overflowSvg = overflow > 0 ? `+${overflow} more` : ''; 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( `` ); 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(` ${label} `); } const centerLabel = esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?'); const centerSvg = ` ${centerLabel} `; const overflowSvg = overflow > 0 ? `+${overflow} more` : ''; 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): 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): 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, '"'); }