Merge branch 'dev'
CI/CD / deploy (push) Failing after 3m5s
Details
CI/CD / deploy (push) Failing after 3m5s
Details
This commit is contained in:
commit
9b05134ae5
|
|
@ -0,0 +1,592 @@
|
|||
/**
|
||||
* 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, '&').replace(/</g, '<').replace(/>/g, '>').replace(/"/g, '"');
|
||||
}
|
||||
|
|
@ -109,6 +109,7 @@ export * from "./folk-spider-3d";
|
|||
// Holon Shapes (H3 geospatial)
|
||||
export * from "./folk-holon";
|
||||
export * from "./folk-holon-browser";
|
||||
export * from "./folk-holon-explorer";
|
||||
export * from "./holon-service";
|
||||
|
||||
// Nested Space Shape
|
||||
|
|
|
|||
|
|
@ -191,6 +191,24 @@ routes.get("/api/delegations", async (c) => {
|
|||
}
|
||||
});
|
||||
|
||||
// ── API: Endorse — proxy to EncryptID trust engine ──
|
||||
routes.post("/api/endorse", async (c) => {
|
||||
const auth = c.req.header("Authorization");
|
||||
if (!auth) return c.json({ error: "Unauthorized" }, 401);
|
||||
try {
|
||||
const body = await c.req.json();
|
||||
const res = await fetch(`${ENCRYPTID_URL}/api/trust/endorse`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json", "Authorization": auth },
|
||||
body: JSON.stringify(body),
|
||||
signal: AbortSignal.timeout(5000),
|
||||
});
|
||||
return c.json(await res.json(), res.status as any);
|
||||
} catch {
|
||||
return c.json({ error: "EncryptID unreachable" }, 502);
|
||||
}
|
||||
});
|
||||
|
||||
// ── API: Graph — transform entities to node/edge format ──
|
||||
routes.get("/api/graph", async (c) => {
|
||||
const space = c.req.param("space") || "demo";
|
||||
|
|
@ -770,6 +788,7 @@ export const networkModule: RSpaceModule = {
|
|||
icon: "🌐",
|
||||
description: "Community relationship graph visualization with CRM sync",
|
||||
scoping: { defaultScope: 'global', userConfigurable: false },
|
||||
canvasShapes: ["folk-holon-explorer"],
|
||||
routes,
|
||||
landingPage: renderLanding,
|
||||
standaloneDomain: "rnetwork.online",
|
||||
|
|
|
|||
|
|
@ -9174,6 +9174,36 @@ app.get('/api/delegations/space', async (c) => {
|
|||
});
|
||||
});
|
||||
|
||||
// POST /api/trust/endorse — log an endorsement trust event
|
||||
app.post('/api/trust/endorse', async (c) => {
|
||||
const claims = await verifyTokenFromRequest(c.req.header('Authorization'));
|
||||
if (!claims) return c.json({ error: 'Unauthorized' }, 401);
|
||||
|
||||
const body = await c.req.json().catch(() => null);
|
||||
if (!body) return c.json({ error: 'Invalid JSON body' }, 400);
|
||||
|
||||
const { targetDid, authority, weight, space } = body;
|
||||
if (!targetDid || typeof targetDid !== 'string')
|
||||
return c.json({ error: 'targetDid required (string)' }, 400);
|
||||
if (!space || typeof space !== 'string')
|
||||
return c.json({ error: 'space required (string)' }, 400);
|
||||
const w = typeof weight === 'number' ? Math.max(0, Math.min(1, weight)) : 0.5;
|
||||
const auth = typeof authority === 'string' ? authority : 'gov-ops';
|
||||
|
||||
const event = {
|
||||
id: crypto.randomUUID(),
|
||||
sourceDid: claims.did || `did:key:${claims.sub.slice(0, 32)}`,
|
||||
targetDid,
|
||||
eventType: 'endorsement' as const,
|
||||
authority: auth,
|
||||
weightDelta: w,
|
||||
spaceSlug: space,
|
||||
};
|
||||
|
||||
await logTrustEvent(event);
|
||||
return c.json({ success: true, event });
|
||||
});
|
||||
|
||||
// GET /api/trust/scores — aggregated trust scores for visualization
|
||||
app.get('/api/trust/scores', async (c) => {
|
||||
const authority = c.req.query('authority') || 'gov-ops';
|
||||
|
|
|
|||
|
|
@ -1195,6 +1195,7 @@
|
|||
#canvas.feed-mode folk-booking,
|
||||
#canvas.feed-mode folk-holon,
|
||||
#canvas.feed-mode folk-holon-browser,
|
||||
#canvas.feed-mode folk-holon-explorer,
|
||||
#canvas.feed-mode folk-feed {
|
||||
position: relative !important;
|
||||
transform: none !important;
|
||||
|
|
@ -1601,6 +1602,7 @@
|
|||
folk-rapp,
|
||||
folk-holon,
|
||||
folk-holon-browser,
|
||||
folk-holon-explorer,
|
||||
folk-multisig-email {
|
||||
position: absolute;
|
||||
}
|
||||
|
|
@ -2134,6 +2136,7 @@
|
|||
<button id="new-piano" title="Piano">🎹 Piano</button>
|
||||
<button id="new-holon" title="Holon">🌐 Holon</button>
|
||||
<button id="new-holon-browser" title="Holon Browser">🔍 Holon Browser</button>
|
||||
<button id="new-holon-explorer" title="Holon Explorer">🔮 Explorer</button>
|
||||
<button id="new-record" title="Record" class="toolbar-disabled">🔴 Record</button>
|
||||
<button id="new-stream" title="Stream" class="toolbar-disabled">📡 Stream</button>
|
||||
</div>
|
||||
|
|
@ -2497,6 +2500,7 @@
|
|||
FolkFeed,
|
||||
FolkHolon,
|
||||
FolkHolonBrowser,
|
||||
FolkHolonExplorer,
|
||||
CommunitySync,
|
||||
PresenceManager,
|
||||
generatePeerId,
|
||||
|
|
@ -2780,6 +2784,7 @@
|
|||
FolkFeed.define();
|
||||
FolkHolon.define();
|
||||
FolkHolonBrowser.define();
|
||||
FolkHolonExplorer.define();
|
||||
|
||||
// Register all shapes with the shape registry
|
||||
shapeRegistry.register("folk-shape", FolkShape);
|
||||
|
|
@ -2841,6 +2846,7 @@
|
|||
shapeRegistry.register("folk-feed", FolkFeed);
|
||||
shapeRegistry.register("folk-holon", FolkHolon);
|
||||
shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
|
||||
shapeRegistry.register("folk-holon-explorer", FolkHolonExplorer);
|
||||
|
||||
// Wire shape→module affiliations from module declarations
|
||||
for (const mod of window.__rspaceAllModules || []) {
|
||||
|
|
@ -3212,7 +3218,7 @@
|
|||
"folk-splat", "folk-blender", "folk-drawfast", "folk-makereal",
|
||||
"folk-freecad", "folk-kicad",
|
||||
"folk-rapp",
|
||||
"folk-holon", "folk-holon-browser",
|
||||
"folk-holon", "folk-holon-browser", "folk-holon-explorer",
|
||||
"folk-multisig-email",
|
||||
"folk-feed"
|
||||
].join(", ");
|
||||
|
|
@ -4088,6 +4094,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
"folk-feed": { width: 280, height: 360 },
|
||||
"folk-holon": { width: 500, height: 400 },
|
||||
"folk-holon-browser": { width: 400, height: 450 },
|
||||
"folk-holon-explorer": { width: 580, height: 540 },
|
||||
"folk-transaction-builder": { width: 420, height: 520 },
|
||||
};
|
||||
|
||||
|
|
@ -4809,6 +4816,7 @@ Use real coordinates, YYYY-MM-DD dates, ISO currency codes. Ask clarifying quest
|
|||
});
|
||||
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
|
||||
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
|
||||
document.getElementById("new-holon-explorer").addEventListener("click", () => setPendingTool("folk-holon-explorer"));
|
||||
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
|
||||
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
|
||||
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
|
||||
|
|
|
|||
Loading…
Reference in New Issue