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:
parent
9e4f24ecd2
commit
843c8ad682
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue