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

593 lines
20 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';
// ── 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)
);
}
// ── 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; }
.content {
display: flex; flex-direction: column; overflow: hidden;
height: calc(100% - 66px); /* header + breadcrumb */
}
.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';
#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.#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="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();
}
});
});
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 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(
`<line x1="${cx}" y1="${cy}" x2="${nx}" y2="${ny}" stroke="#10b981" stroke-opacity="0.3" stroke-width="${1 + child.weight * 0.03}"/>`
);
// Node circle
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>
`);
}
// Center node
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">${esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?')}</text>
`;
// Overflow indicator
const overflowSvg = overflow > 0
? `<text x="${cx}" y="${cy + orbit + 30}" text-anchor="middle" class="more-label">+${overflow} more</text>`
: '';
// 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">
${gridSvg}
${linesSvg.join('')}
${centerSvg}
${nodesSvg.join('')}
${overflowSvg}
</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();
});
});
}
// ── 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<string, any>): 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<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.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;');
}