From 843c8ad68219f4b7730463cc40691edf26502b75 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Sat, 11 Apr 2026 13:07:32 -0400 Subject: [PATCH] feat(holons): dual view toggle for Holon Explorer (holon + graph) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add switchable Holon/Graph views within the same shape instance. Holon view retains the orbital 220° arc layout; Graph view renders children as hexagons in a full 360° ring with radial labels. View preference persists via serialization. Co-Authored-By: Claude Opus 4.6 --- lib/folk-holon-explorer.ts | 199 +++++++++++++++++++++++++++---------- 1 file changed, 146 insertions(+), 53 deletions(-) diff --git a/lib/folk-holon-explorer.ts b/lib/folk-holon-explorer.ts index 82a2c780..e6e4c6c3 100644 --- a/lib/folk-holon-explorer.ts +++ b/lib/folk-holon-explorer.ts @@ -27,6 +27,7 @@ interface ExplorerNode { } type NavMode = 'h3' | 'space'; +type ViewMode = 'holon' | 'graph'; // ── Appreciation normalization (from Holons project) ── @@ -41,6 +42,17 @@ function normalize(values: number[], changedIndex: number): number[] { ); } +// ── 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 { @@ -103,9 +115,18 @@ const styles = css` .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% - 66px); /* header + breadcrumb */ + height: calc(100% - 94px); /* header + breadcrumb + view-strip */ } .svg-wrap { flex: 1; min-height: 0; position: relative; } .svg-wrap svg { width: 100%; height: 100%; } @@ -159,6 +180,7 @@ export class FolkHolonExplorer extends FolkShape { // ── State ── #mode: NavMode = 'space'; + #view: ViewMode = 'holon'; #rootId = ''; #spaceSlug = ''; #breadcrumb: { id: string; label: string }[] = []; @@ -171,6 +193,7 @@ export class FolkHolonExplorer extends FolkShape { 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 || ''; @@ -189,6 +212,10 @@ export class FolkHolonExplorer extends FolkShape { +
+ + +
`; @@ -214,6 +241,20 @@ export class FolkHolonExplorer extends FolkShape { }); }); + // 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')); @@ -422,57 +463,15 @@ export class FolkHolonExplorer extends FolkShape { } #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; + const overflow = this.#children.length > MAX_VISIBLE ? this.#children.length - MAX_VISIBLE : 0; - // SVG dimensions (use fixed viewBox, scales with container) + // Build SVG based on active view const W = 400, H = 300; - const cx = W / 2, cy = H / 2; - let orbit = Math.min(W, H) * 0.35; - if (N > 8) orbit *= 0.85; - - // MetatronGrid - const gridSvg = metatronGrid(Math.min(W, H)); - - // Build node positions - const nodesSvg: string[] = []; - const linesSvg: string[] = []; - - for (let i = 0; i < visible.length; i++) { - const child = visible[i]; - const angle = -110 + (220 / (visible.length + 1)) * (i + 1); - const rad = (angle - 90) * Math.PI / 180; - const nx = cx + orbit * Math.cos(rad); - const ny = cy + orbit * Math.sin(rad); - const r = Math.min(36, Math.max(16, 16 + child.weight * 0.3)); - - // Connection line - linesSvg.push( - `` - ); - - // Node circle - const fill = `rgba(16,185,129,${(0.1 + child.weight * 0.005).toFixed(3)})`; - nodesSvg.push(` - - - ${esc(child.label.length > 14 ? child.label.slice(0, 12) + '…' : child.label)} - - `); - } - - // Center node - const centerSvg = ` - - ${esc(this.#breadcrumb[this.#breadcrumb.length - 1]?.label.slice(0, 8) || '?')} - `; - - // Overflow indicator - const overflowSvg = overflow > 0 - ? `+${overflow} more` - : ''; + 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) => ` @@ -488,11 +487,7 @@ export class FolkHolonExplorer extends FolkShape { this.#contentEl!.innerHTML = html`
- ${gridSvg} - ${linesSvg.join('')} - ${centerSvg} - ${nodesSvg.join('')} - ${overflowSvg} + ${svgContent}
@@ -540,6 +535,101 @@ export class FolkHolonExplorer extends FolkShape { }); } + #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() { @@ -547,6 +637,7 @@ export class FolkHolonExplorer extends FolkShape { ...super.toJSON(), type: 'folk-holon-explorer', mode: this.#mode, + view: this.#view, rootId: this.#rootId, spaceSlug: this.#spaceSlug, breadcrumb: this.#breadcrumb, @@ -556,6 +647,7 @@ export class FolkHolonExplorer extends FolkShape { 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; @@ -568,6 +660,7 @@ export class FolkHolonExplorer extends FolkShape { 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;