feat(holons): dual view toggle for Holon Explorer (holon + graph)

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 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-04-11 13:07:32 -04:00
parent 9e4f24ecd2
commit 843c8ad682
1 changed files with 146 additions and 53 deletions

View File

@ -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 {
</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>
`;
@ -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(
`<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>`
: '';
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`
<div class="svg-wrap">
<svg viewBox="0 0 ${W} ${H}" preserveAspectRatio="xMidYMid meet">
${gridSvg}
${linesSvg.join('')}
${centerSvg}
${nodesSvg.join('')}
${overflowSvg}
${svgContent}
</svg>
</div>
<div class="slider-panel">
@ -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(
`<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() {
@ -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<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;
@ -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;