feat: add folk-holon + folk-holon-browser canvas shapes with Automerge data layer

Port HolonShapeUtil from canvas-website to rSpace web components.
Replaces dead HoloSphere/GunDB stub with local-first CRDT storage
via window.__rspaceOfflineRuntime. H3 geospatial hierarchy via pure
h3-js. Data model designed for future AD4M Perspective bridging.

- lib/holon-service.ts: Automerge-backed holon registry + lens docs
- lib/folk-holon.ts: Main holon shape (ID entry → connected view with 16 lenses)
- lib/folk-holon-browser.ts: Search/browse shape with open-holon event
- Registered in canvas.html: imports, define, registry, CSS, toolbar, sizes

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Jeff Emmett 2026-03-11 22:53:48 -07:00
parent 47b9c56da9
commit 21b31c43c7
5 changed files with 1443 additions and 3 deletions

436
lib/folk-holon-browser.ts Normal file
View File

@ -0,0 +1,436 @@
/**
* folk-holon-browser Canvas shape for searching and browsing holons.
*
* Ported from canvas-website HolonBrowser.tsx (React) to FolkShape
* web component. Searches H3 cell IDs or coordinates, probes standard
* lenses, and dispatches "open-holon" CustomEvent for canvas to create
* a folk-holon shape.
*/
import * as h3 from 'h3-js';
import { FolkShape } from './folk-shape';
import { css, html } from './tags';
import {
isValidHolonId,
isH3CellId,
getResolutionName,
getResolutionDescription,
STANDARD_LENSES,
LENS_ICONS,
getAvailableLenses,
getLensData,
type HolonData,
} from './holon-service';
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 360px;
min-height: 300px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #22c55e;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
padding: 16px;
overflow: auto;
height: calc(100% - 36px);
display: flex;
flex-direction: column;
gap: 16px;
}
.search-row {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
outline: none;
}
.search-input:focus {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.search-btn {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.search-btn:hover { background: #2563eb; }
.search-btn:disabled { background: #9ca3af; cursor: not-allowed; }
.error {
color: #dc2626;
font-size: 12px;
}
.result-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px;
}
.result-name {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.result-label {
color: #6b7280;
font-size: 11px;
}
.result-value {
color: #1e293b;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.lens-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
}
.lens-badge {
padding: 3px 8px;
border-radius: 10px;
font-size: 11px;
background: #dbeafe;
color: #1e40af;
}
.lens-badge.empty {
background: #f3f4f6;
color: #9ca3af;
}
.action-row {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.open-btn {
padding: 8px 16px;
background: #22c55e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.open-btn:hover { background: #16a34a; }
.reset-btn {
padding: 8px 16px;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.reset-btn:hover { background: #e5e7eb; }
.loading {
color: #6b7280;
font-size: 13px;
padding: 8px 0;
}
.hint {
font-size: 12px;
color: #6b7280;
}
`;
declare global {
interface HTMLElementTagNameMap {
'folk-holon-browser': FolkHolonBrowser;
}
}
export class FolkHolonBrowser extends FolkShape {
static override tagName = 'folk-holon-browser';
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 ──
#searchId = '';
#result: HolonData | null = null;
#availableLenses: string[] = [];
#loading = false;
#error = '';
#contentEl: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement('div');
wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🔍</span>
<span>Holon Browser</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</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');
wrapper.querySelector('.close-btn')!.addEventListener('click', (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('close'));
});
this.#render();
return root;
}
// ── Search ──
async #handleSearch() {
const id = this.#searchId.trim();
if (!id) { this.#error = 'Please enter a Holon ID'; this.#render(); return; }
if (!isValidHolonId(id)) {
this.#error = 'Invalid ID. Enter an H3 cell (e.g. 872a1070bffffff) or numeric ID.';
this.#render();
return;
}
this.#error = '';
this.#loading = true;
this.#render();
try {
let lat = 0, lng = 0, resolution = -1, name = `Holon ${id.slice(-8)}`;
if (isH3CellId(id)) {
[lat, lng] = h3.cellToLatLng(id);
resolution = h3.getResolution(id);
name = `${getResolutionName(resolution)} Holon`;
}
this.#result = {
id,
name,
description: resolution >= 0 ? getResolutionDescription(resolution) : '',
latitude: lat,
longitude: lng,
resolution,
timestamp: Date.now(),
};
// Probe lenses
this.#availableLenses = await getAvailableLenses(id);
if (this.#availableLenses.length === 0) {
this.#availableLenses = ['general'];
}
} catch (e) {
this.#error = `Search failed: ${e instanceof Error ? e.message : 'Unknown error'}`;
this.#result = null;
} finally {
this.#loading = false;
this.#render();
}
}
#handleOpenHolon() {
if (!this.#result) return;
this.dispatchEvent(new CustomEvent('open-holon', {
bubbles: true,
detail: { holon: this.#result, lenses: this.#availableLenses },
}));
}
#handleReset() {
this.#searchId = '';
this.#result = null;
this.#availableLenses = [];
this.#error = '';
this.#render();
}
// ── Rendering ──
#render() {
if (!this.#contentEl) return;
const result = this.#result;
const isGeo = result && result.resolution >= 0;
let resultHtml = '';
if (result) {
const infoGrid = isGeo
? html`
<div class="result-grid">
<div>
<div class="result-label">Coordinates</div>
<div class="result-value">${result.latitude.toFixed(6)}, ${result.longitude.toFixed(6)}</div>
</div>
<div>
<div class="result-label">Resolution</div>
<div class="result-value">${getResolutionName(result.resolution)} (Level ${result.resolution})</div>
</div>
<div>
<div class="result-label">Holon ID</div>
<div class="result-value">${result.id}</div>
</div>
<div>
<div class="result-label">Description</div>
<div class="result-value">${getResolutionDescription(result.resolution)}</div>
</div>
</div>`
: html`
<div class="result-grid">
<div>
<div class="result-label">Type</div>
<div class="result-value" style="color:#22c55e;font-weight:600">Workspace / Group</div>
</div>
<div>
<div class="result-label">Holon ID</div>
<div class="result-value">${result.id}</div>
</div>
</div>`;
const lensBadges = this.#availableLenses.map((lens) => {
const icon = LENS_ICONS[lens] ?? '📁';
return `<span class="lens-badge">${icon} ${lens}</span>`;
}).join('');
resultHtml = html`
<div class="result-card">
<div class="result-name">📍 ${escapeHtml(result.name)}</div>
${infoGrid}
<div class="result-label" style="margin-bottom:4px">Available Data</div>
<div class="lens-badges">${lensBadges || '<span class="lens-badge empty">No data yet</span>'}</div>
<div class="action-row">
<button class="open-btn">Open Holon</button>
<button class="reset-btn">Search Another</button>
</div>
</div>`;
}
this.#contentEl.innerHTML = html`
<div class="hint">Search by H3 cell ID (e.g. <code>872a1070bffffff</code>) or numeric Holon ID</div>
<div class="search-row">
<input class="search-input" type="text" placeholder="Holon ID..." value="${escapeHtml(this.#searchId)}" />
<button class="search-btn" ${this.#loading ? 'disabled' : ''}>${this.#loading ? 'Searching...' : 'Search'}</button>
</div>
${this.#error ? `<div class="error">${escapeHtml(this.#error)}</div>` : ''}
${resultHtml}
`;
// Wire events
const input = this.#contentEl.querySelector('.search-input') as HTMLInputElement;
const searchBtn = this.#contentEl.querySelector('.search-btn') as HTMLButtonElement;
input?.addEventListener('input', (e) => { this.#searchId = (e.target as HTMLInputElement).value; });
input?.addEventListener('pointerdown', (e) => e.stopPropagation());
input?.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') this.#handleSearch();
});
searchBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleSearch(); });
const openBtn = this.#contentEl.querySelector('.open-btn');
const resetBtn = this.#contentEl.querySelector('.reset-btn');
openBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleOpenHolon(); });
resetBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleReset(); });
}
// ── Serialization ──
override toJSON() {
return { ...super.toJSON(), type: 'folk-holon-browser' };
}
static override fromData(data: Record<string, any>): FolkHolonBrowser {
return FolkShape.fromData(data) as FolkHolonBrowser;
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}

718
lib/folk-holon.ts Normal file
View File

@ -0,0 +1,718 @@
/**
* folk-holon Canvas shape for H3 geospatial holons.
*
* Ported from canvas-website HolonShapeUtil.tsx (React/tldraw) to
* FolkShape web component with Automerge-backed local-first data.
*
* Two states:
* - Disconnected: ID entry form (H3 cell ID or numeric)
* - Connected: header with name/coords, lens selector, lens data grid
*/
import * as h3 from 'h3-js';
import { FolkShape } from './folk-shape';
import { css, html } from './tags';
import {
isValidHolonId,
isH3CellId,
getResolutionName,
getResolutionDescription,
STANDARD_LENSES,
LENS_ICONS,
saveHolon,
loadHolon,
putLensData,
getLensData,
getAvailableLenses,
subscribeLenses,
type HolonData,
} from './holon-service';
const styles = css`
:host {
background: var(--rs-bg-surface, #fff);
color: var(--rs-text-primary, #1e293b);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
min-width: 360px;
min-height: 280px;
}
.header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: #22c55e;
color: white;
border-radius: 8px 8px 0 0;
font-size: 12px;
font-weight: 600;
cursor: move;
}
.header-title {
display: flex;
align-items: center;
gap: 6px;
min-width: 0;
overflow: hidden;
}
.header-title span:last-child {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.header-actions {
display: flex;
gap: 4px;
flex-shrink: 0;
}
.header-actions button {
background: transparent;
border: none;
color: white;
cursor: pointer;
padding: 2px 6px;
border-radius: 4px;
font-size: 14px;
}
.header-actions button:hover {
background: rgba(255, 255, 255, 0.2);
}
.content {
padding: 16px;
overflow: auto;
height: calc(100% - 36px);
}
/* -- Disconnected state -- */
.connect-form {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
gap: 16px;
text-align: center;
}
.connect-icon {
font-size: 48px;
}
.connect-label {
font-size: 14px;
font-weight: 600;
color: #374151;
}
.connect-hint {
font-size: 12px;
color: #6b7280;
max-width: 320px;
}
.connect-row {
display: flex;
gap: 8px;
width: 100%;
max-width: 400px;
}
.connect-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
outline: none;
}
.connect-input:focus {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.connect-btn {
padding: 8px 16px;
background: #22c55e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
white-space: nowrap;
}
.connect-btn:hover {
background: #16a34a;
}
.connect-btn:disabled {
background: #9ca3af;
cursor: not-allowed;
}
.error {
color: #dc2626;
font-size: 12px;
margin-top: 4px;
}
/* -- Connected state -- */
.info-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 12px;
font-size: 12px;
}
.info-label {
color: #6b7280;
font-size: 11px;
}
.info-value {
color: #1e293b;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.lens-bar {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
padding-bottom: 8px;
border-bottom: 1px solid #e5e7eb;
}
.lens-btn {
padding: 4px 10px;
border: none;
border-radius: 12px;
font-size: 11px;
cursor: pointer;
background: #f3f4f6;
color: #374151;
white-space: nowrap;
}
.lens-btn:hover {
background: #e5e7eb;
}
.lens-btn.active {
background: #22c55e;
color: white;
}
.lens-data {
background: #f9fafb;
border-radius: 6px;
padding: 10px;
font-size: 12px;
max-height: 200px;
overflow: auto;
}
.lens-data pre {
margin: 0;
white-space: pre-wrap;
word-break: break-word;
font-family: monospace;
font-size: 11px;
color: #374151;
}
.lens-empty {
color: #9ca3af;
font-style: italic;
font-size: 12px;
padding: 8px;
}
.lens-add-row {
display: flex;
gap: 6px;
margin-top: 8px;
}
.lens-add-row input {
flex: 1;
padding: 6px 10px;
border: 1px solid #d1d5db;
border-radius: 4px;
font-size: 12px;
outline: none;
}
.lens-add-row input:focus {
border-color: #22c55e;
}
.lens-add-row button {
padding: 6px 12px;
background: #22c55e;
color: white;
border: none;
border-radius: 4px;
font-size: 12px;
cursor: pointer;
}
.lens-add-row button:hover {
background: #16a34a;
}
.loading {
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
color: #6b7280;
font-size: 13px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
margin-right: 6px;
}
.status-dot.connected { background: #22c55e; }
.status-dot.disconnected { background: #ef4444; }
`;
declare global {
interface HTMLElementTagNameMap {
'folk-holon': FolkHolon;
}
}
export class FolkHolon extends FolkShape {
static override tagName = 'folk-holon';
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 ──
#holonId = '';
#name = 'New Holon';
#description = '';
#latitude = 0;
#longitude = 0;
#resolution = -1;
#connected = false;
#selectedLens = 'users';
#lensData: Record<string, Record<string, any>> = {};
#loading = false;
#error = '';
#space = '';
#unsubLenses: (() => void) | null = null;
// ── DOM refs ──
#contentEl: HTMLElement | null = null;
// ── Accessors for Automerge sync ──
get holonId() { return this.#holonId; }
set holonId(v: string) { this.#holonId = v; this.#render(); }
get holonName() { return this.#name; }
set holonName(v: string) { this.#name = v; this.#render(); }
get selectedLens() { return this.#selectedLens; }
set selectedLens(v: string) { this.#selectedLens = v; this.#render(); }
override createRenderRoot() {
const root = super.createRenderRoot();
// Parse attributes
this.#holonId = this.getAttribute('holon-id') ?? '';
this.#name = this.getAttribute('name') ?? 'New Holon';
this.#description = this.getAttribute('description') ?? '';
this.#selectedLens = this.getAttribute('selected-lens') ?? 'users';
this.#space = this.getAttribute('space') ?? '';
const latAttr = this.getAttribute('latitude');
const lngAttr = this.getAttribute('longitude');
const resAttr = this.getAttribute('resolution');
if (latAttr) this.#latitude = parseFloat(latAttr) || 0;
if (lngAttr) this.#longitude = parseFloat(lngAttr) || 0;
if (resAttr) this.#resolution = parseInt(resAttr, 10);
// Build content wrapper
const wrapper = document.createElement('div');
wrapper.className = 'content-wrapper';
wrapper.style.cssText = 'width:100%;height:100%;display:flex;flex-direction:column;';
wrapper.innerHTML = html`
<div class="header">
<span class="header-title">
<span>🌐</span>
<span class="holon-title">Holon</span>
</span>
<div class="header-actions">
<button class="refresh-btn" title="Refresh"></button>
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content"></div>
`;
// Replace the slot container
const slot = root.querySelector('slot');
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#contentEl = wrapper.querySelector('.content');
// Wire header buttons
const refreshBtn = wrapper.querySelector('.refresh-btn') as HTMLButtonElement;
const closeBtn = wrapper.querySelector('.close-btn') as HTMLButtonElement;
refreshBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.#handleRefresh();
});
closeBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('close'));
});
// Auto-connect if holonId was set via attribute
if (this.#holonId) {
this.#connected = true;
this.#resolveHolonInfo(this.#holonId);
this.#loadData();
}
this.#render();
return root;
}
disconnectedCallback() {
this.#unsubLenses?.();
this.#unsubLenses = null;
}
// ── Connect flow ──
async #handleConnect() {
const id = this.#holonId.trim();
if (!id) {
this.#error = 'Please enter a Holon ID';
this.#render();
return;
}
if (!isValidHolonId(id)) {
this.#error = 'Invalid ID. Enter an H3 cell (e.g. 872a1070bffffff) or numeric ID.';
this.#render();
return;
}
this.#holonId = id;
this.#error = '';
this.#connected = true;
this.#resolveHolonInfo(id);
await this.#loadData();
// Persist metadata
await saveHolon({
id,
name: this.#name,
description: this.#description,
latitude: this.#latitude,
longitude: this.#longitude,
resolution: this.#resolution,
timestamp: Date.now(),
});
this.#render();
}
#resolveHolonInfo(id: string) {
if (isH3CellId(id)) {
try {
const [lat, lng] = h3.cellToLatLng(id);
this.#latitude = lat;
this.#longitude = lng;
this.#resolution = h3.getResolution(id);
this.#name = `${getResolutionName(this.#resolution)} Holon`;
} catch { /* keep defaults */ }
} else {
this.#resolution = -1;
this.#name = `Holon ${id.slice(-8)}`;
}
// Update header title
const titleEl = this.renderRoot.querySelector('.holon-title');
if (titleEl) titleEl.textContent = this.#name;
}
async #loadData() {
this.#loading = true;
this.#render();
try {
// Load existing holon metadata
const existing = await loadHolon(this.#holonId);
if (existing?.name) {
this.#name = existing.name;
this.#description = existing.description;
const titleEl = this.renderRoot.querySelector('.holon-title');
if (titleEl) titleEl.textContent = this.#name;
}
// Load lens data
for (const lens of STANDARD_LENSES) {
const data = await getLensData(this.#holonId, lens);
if (data && Object.keys(data).length > 0) {
this.#lensData[lens] = data;
}
}
// Subscribe for live updates
this.#unsubLenses?.();
this.#unsubLenses = subscribeLenses(this.#holonId, (lenses) => {
this.#lensData = { ...lenses };
this.#render();
});
} catch (e) {
console.error('[folk-holon] Load error:', e);
this.#error = 'Failed to load data';
} finally {
this.#loading = false;
this.#render();
}
}
async #handleRefresh() {
if (!this.#connected || !this.#holonId) return;
await this.#loadData();
}
async #handleAddData() {
if (!this.#holonId || !this.#connected) return;
const input = this.#contentEl?.querySelector('.add-data-input') as HTMLInputElement;
if (!input?.value.trim()) return;
const entryId = `entry-${Date.now()}`;
await putLensData(this.#holonId, this.#selectedLens, {
[entryId]: { content: input.value.trim(), timestamp: Date.now() },
});
input.value = '';
// Data will update via subscription
}
// ── Rendering ──
#render() {
if (!this.#contentEl) return;
if (!this.#connected) {
this.#renderDisconnected();
} else if (this.#loading) {
this.#contentEl.innerHTML = '<div class="loading">Loading holon data...</div>';
} else {
this.#renderConnected();
}
}
#renderDisconnected() {
this.#contentEl!.innerHTML = html`
<div class="connect-form">
<div class="connect-icon">🌐</div>
<div class="connect-label">Connect to a Holon</div>
<div class="connect-hint">
Enter an H3 cell ID (e.g. <code>872a1070bffffff</code>) or numeric Holon ID
</div>
<div class="connect-row">
<input class="connect-input" type="text" placeholder="Holon ID..." />
<button class="connect-btn">Connect</button>
</div>
${this.#error ? `<div class="error">${this.#error}</div>` : ''}
</div>
`;
const input = this.#contentEl!.querySelector('.connect-input') as HTMLInputElement;
const btn = this.#contentEl!.querySelector('.connect-btn') as HTMLButtonElement;
// Set value from state
if (this.#holonId) input.value = this.#holonId;
input.addEventListener('input', (e) => {
this.#holonId = (e.target as HTMLInputElement).value;
});
input.addEventListener('pointerdown', (e) => e.stopPropagation());
input.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') this.#handleConnect();
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.#handleConnect();
});
}
#renderConnected() {
const resName = getResolutionName(this.#resolution);
const resDesc = getResolutionDescription(this.#resolution);
const isGeo = this.#resolution >= 0;
// Available lenses (ones with data + all standard for selection)
const withData = new Set(Object.keys(this.#lensData));
// Build info section
const infoHtml = isGeo
? html`
<div class="info-grid">
<div>
<div class="info-label">Coordinates</div>
<div class="info-value">${this.#latitude.toFixed(6)}, ${this.#longitude.toFixed(6)}</div>
</div>
<div>
<div class="info-label">Resolution</div>
<div class="info-value">${resName} (Level ${this.#resolution})</div>
</div>
<div>
<div class="info-label">Holon ID</div>
<div class="info-value">${this.#holonId}</div>
</div>
<div>
<div class="info-label">Area Info</div>
<div class="info-value">${resDesc}</div>
</div>
</div>`
: html`
<div class="info-grid">
<div>
<div class="info-label">Type</div>
<div class="info-value" style="color:#22c55e;font-weight:600">${resName}</div>
</div>
<div>
<div class="info-label">Holon ID</div>
<div class="info-value">${this.#holonId}</div>
</div>
</div>`;
// Build lens buttons
const lensButtons = STANDARD_LENSES.map((lens) => {
const icon = LENS_ICONS[lens] ?? '📁';
const active = lens === this.#selectedLens ? ' active' : '';
const badge = withData.has(lens) ? ' *' : '';
return `<button class="lens-btn${active}" data-lens="${lens}">${icon} ${lens}${badge}</button>`;
}).join('');
// Current lens data
const currentData = this.#lensData[this.#selectedLens];
let dataHtml: string;
if (currentData && Object.keys(currentData).length > 0) {
dataHtml = `<div class="lens-data"><pre>${escapeHtml(JSON.stringify(currentData, null, 2))}</pre></div>`;
} else {
dataHtml = `<div class="lens-empty">No data in "${this.#selectedLens}" lens</div>`;
}
this.#contentEl!.innerHTML = html`
${infoHtml}
<div class="lens-bar">${lensButtons}</div>
${dataHtml}
<div class="lens-add-row">
<input class="add-data-input" type="text" placeholder="Add data to ${this.#selectedLens}..." />
<button class="add-data-btn">Add</button>
</div>
`;
// Wire lens buttons
this.#contentEl!.querySelectorAll('.lens-btn').forEach((btn) => {
btn.addEventListener('click', (e) => {
e.stopPropagation();
this.#selectedLens = (btn as HTMLElement).dataset.lens!;
this.#render();
});
});
// Wire add data
const addInput = this.#contentEl!.querySelector('.add-data-input') as HTMLInputElement;
const addBtn = this.#contentEl!.querySelector('.add-data-btn') as HTMLButtonElement;
addInput?.addEventListener('pointerdown', (e) => e.stopPropagation());
addInput?.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') this.#handleAddData();
});
addBtn?.addEventListener('click', (e) => {
e.stopPropagation();
this.#handleAddData();
});
}
// ── Serialization ──
override toJSON() {
return {
...super.toJSON(),
type: 'folk-holon',
holonId: this.#holonId,
name: this.#name,
description: this.#description,
latitude: this.#latitude,
longitude: this.#longitude,
resolution: this.#resolution,
connected: this.#connected,
selectedLens: this.#selectedLens,
};
}
static override fromData(data: Record<string, any>): FolkHolon {
const shape = FolkShape.fromData(data) as FolkHolon;
if (data.holonId) shape.setAttribute('holon-id', data.holonId);
if (data.name) shape.setAttribute('name', data.name);
if (data.description) shape.setAttribute('description', data.description);
if (data.latitude != null) shape.setAttribute('latitude', String(data.latitude));
if (data.longitude != null) shape.setAttribute('longitude', String(data.longitude));
if (data.resolution != null) shape.setAttribute('resolution', String(data.resolution));
if (data.selectedLens) shape.setAttribute('selected-lens', data.selectedLens);
return shape;
}
override applyData(data: Record<string, any>): void {
super.applyData(data);
if (data.holonId && data.holonId !== this.#holonId) {
this.#holonId = data.holonId;
this.#connected = !!data.connected;
if (this.#connected) {
this.#resolveHolonInfo(this.#holonId);
this.#loadData();
}
}
if (data.name) this.#name = data.name;
if (data.selectedLens && data.selectedLens !== this.#selectedLens) {
this.#selectedLens = data.selectedLens;
}
this.#render();
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;');
}

263
lib/holon-service.ts Normal file
View File

@ -0,0 +1,263 @@
/**
* Holon Service Local-first data layer for H3 geospatial holons.
*
* Replaces the dead HoloSphereService (GunDB stub) with Automerge-backed
* CRDT storage via window.__rspaceOfflineRuntime.
*
* Data model:
* Registry doc: `{space}:holons:registry:{holonId}` HolonData
* Lens doc: `{space}:holons:lenses:{holonId}` all lens data
*
* H3 hierarchy methods use pure h3-js (no external service).
* Designed so data could bridge to AD4M Perspectives later.
*/
import * as h3 from 'h3-js';
import type { DocSchema, DocumentId } from '../shared/local-first/document';
import type { RSpaceOfflineRuntime } from '../shared/local-first/runtime';
// ── Types ──
export interface HolonData {
id: string;
name: string;
description: string;
latitude: number;
longitude: number;
resolution: number;
timestamp: number;
}
export interface HolonLens {
name: string;
data: Record<string, any>;
}
export interface HolonConnection {
id: string;
name: string;
type: 'federation' | 'reference';
targetSpace: string;
status: 'connected' | 'disconnected' | 'error';
}
// ── Standard lenses ──
export const STANDARD_LENSES = [
'active_users', 'users', 'rankings', 'stats', 'tasks', 'progress',
'events', 'activities', 'items', 'shopping', 'active_items',
'proposals', 'offers', 'requests', 'checklists', 'roles',
] as const;
export type LensName = (typeof STANDARD_LENSES)[number] | string;
// ── Lens icons ──
export const LENS_ICONS: Record<string, string> = {
active_users: '👥', users: '👤', rankings: '🏆', stats: '📊',
tasks: '✅', progress: '📈', events: '📅', activities: '🔔',
items: '📦', shopping: '🛒', active_items: '⚡', proposals: '💡',
offers: '🤝', requests: '📋', checklists: '☑️', roles: '🎭',
};
// ── Resolution names ──
const RESOLUTION_NAMES = [
'Country', 'State/Province', 'Metropolitan Area', 'City', 'District',
'Neighborhood', 'Block', 'Building', 'Room', 'Desk', 'Chair', 'Point',
];
const RESOLUTION_DESCRIPTIONS = [
'Country level — covers entire countries',
'State/Province level — covers states and provinces',
'Metropolitan area level — covers large urban areas',
'City level — covers individual cities',
'District level — covers city districts',
'Neighborhood level — covers neighborhoods',
'Block level — covers city blocks',
'Building level — covers individual buildings',
'Room level — covers individual rooms',
'Desk level — covers individual desks',
'Chair level — covers individual chairs',
'Point level — covers individual points',
];
export function getResolutionName(resolution: number): string {
if (resolution < 0) return 'Workspace / Group';
return RESOLUTION_NAMES[resolution] ?? `Level ${resolution}`;
}
export function getResolutionDescription(resolution: number): string {
if (resolution < 0) return 'Non-geospatial workspace or group';
return RESOLUTION_DESCRIPTIONS[resolution] ?? `Geographic level ${resolution}`;
}
// ── Automerge schemas ──
interface HolonRegistryDoc {
holon: HolonData;
}
interface HolonLensesDoc {
lenses: Record<string, Record<string, any>>;
}
const registrySchema: DocSchema<HolonRegistryDoc> = {
module: 'holons',
collection: 'registry',
version: 1,
init: () => ({
holon: { id: '', name: '', description: '', latitude: 0, longitude: 0, resolution: 0, timestamp: 0 },
}),
};
const lensesSchema: DocSchema<HolonLensesDoc> = {
module: 'holons',
collection: 'lenses',
version: 1,
init: () => ({ lenses: {} }),
};
// ── Helper: get runtime ──
function getRuntime(): RSpaceOfflineRuntime | null {
return (window as any).__rspaceOfflineRuntime ?? null;
}
function getSpace(): string {
return getRuntime()?.space ?? 'demo';
}
// ── H3 validation ──
export function isValidHolonId(id: string): boolean {
if (!id || !id.trim()) return false;
const trimmed = id.trim();
try { if (h3.isValidCell(trimmed)) return true; } catch { /* not h3 */ }
if (/^\d{6,20}$/.test(trimmed)) return true;
if (/^[a-zA-Z0-9_-]{3,50}$/.test(trimmed)) return true;
return false;
}
export function isH3CellId(id: string): boolean {
if (!id || !id.trim()) return false;
try { return h3.isValidCell(id.trim()); } catch { return false; }
}
// ── H3 hierarchy (pure h3-js) ──
export function getHolonHierarchy(holonId: string): { parent?: string; children: string[] } {
try {
const resolution = h3.getResolution(holonId);
const parent = resolution > 0 ? h3.cellToParent(holonId, resolution - 1) : undefined;
const children = resolution < 15 ? h3.cellToChildren(holonId, resolution + 1) : [];
return { parent, children };
} catch {
return { children: [] };
}
}
export function getHolonScalespace(holonId: string): string[] {
try {
const resolution = h3.getResolution(holonId);
const scales: string[] = [holonId];
let current = holonId;
for (let r = resolution - 1; r >= 0; r--) {
current = h3.cellToParent(current, r);
scales.unshift(current);
}
return scales;
} catch {
return [];
}
}
// ── CRDT-backed data operations ──
/** Get or create the registry doc for a holon. */
async function ensureRegistryDoc(holonId: string): Promise<DocumentId> {
const runtime = getRuntime();
if (!runtime) throw new Error('Offline runtime not available');
const docId = `${getSpace()}:holons:registry:${holonId}` as DocumentId;
await runtime.subscribe(docId, registrySchema);
return docId;
}
/** Get or create the lenses doc for a holon. */
async function ensureLensesDoc(holonId: string): Promise<DocumentId> {
const runtime = getRuntime();
if (!runtime) throw new Error('Offline runtime not available');
const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
await runtime.subscribe(docId, lensesSchema);
return docId;
}
/** Save holon metadata to Automerge. */
export async function saveHolon(data: HolonData): Promise<void> {
const runtime = getRuntime();
if (!runtime) return;
const docId = await ensureRegistryDoc(data.id);
runtime.change<HolonRegistryDoc>(docId, 'Update holon metadata', (doc) => {
doc.holon.id = data.id;
doc.holon.name = data.name;
doc.holon.description = data.description;
doc.holon.latitude = data.latitude;
doc.holon.longitude = data.longitude;
doc.holon.resolution = data.resolution;
doc.holon.timestamp = data.timestamp;
});
}
/** Load holon metadata from Automerge. */
export async function loadHolon(holonId: string): Promise<HolonData | null> {
const runtime = getRuntime();
if (!runtime) return null;
const docId = await ensureRegistryDoc(holonId);
const doc = runtime.get<HolonRegistryDoc>(docId);
if (!doc || !doc.holon.id) return null;
return { ...doc.holon };
}
/** Put data into a lens. */
export async function putLensData(holonId: string, lens: string, data: Record<string, any>): Promise<void> {
const runtime = getRuntime();
if (!runtime) return;
const docId = await ensureLensesDoc(holonId);
runtime.change<HolonLensesDoc>(docId, `Update lens: ${lens}`, (doc) => {
if (!doc.lenses[lens]) doc.lenses[lens] = {};
Object.assign(doc.lenses[lens], data);
});
}
/** Get data from a lens. */
export async function getLensData(holonId: string, lens: string): Promise<Record<string, any> | null> {
const runtime = getRuntime();
if (!runtime) return null;
const docId = await ensureLensesDoc(holonId);
const doc = runtime.get<HolonLensesDoc>(docId);
if (!doc?.lenses?.[lens]) return null;
return { ...doc.lenses[lens] };
}
/** Get all lenses with data for a holon. */
export async function getAvailableLenses(holonId: string): Promise<string[]> {
const runtime = getRuntime();
if (!runtime) return [];
const docId = await ensureLensesDoc(holonId);
const doc = runtime.get<HolonLensesDoc>(docId);
if (!doc?.lenses) return [];
return Object.keys(doc.lenses).filter((k) => {
const v = doc.lenses[k];
return v && Object.keys(v).length > 0;
});
}
/** Subscribe to changes on a holon's lens data. Returns unsubscribe function. */
export function subscribeLenses(holonId: string, cb: (lenses: Record<string, Record<string, any>>) => void): () => void {
const runtime = getRuntime();
if (!runtime) return () => {};
const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId;
return runtime.onChange<HolonLensesDoc>(docId, (doc) => {
cb(doc.lenses ?? {});
});
}

View File

@ -81,6 +81,11 @@ export * from "./folk-choice-conviction";
// 3D Spider Plot (governance visualization) // 3D Spider Plot (governance visualization)
export * from "./folk-spider-3d"; export * from "./folk-spider-3d";
// Holon Shapes (H3 geospatial)
export * from "./folk-holon";
export * from "./folk-holon-browser";
export * from "./holon-service";
// Nested Space Shape // Nested Space Shape
export * from "./folk-canvas"; export * from "./folk-canvas";

View File

@ -1464,6 +1464,8 @@
#canvas.feed-mode folk-budget, #canvas.feed-mode folk-budget,
#canvas.feed-mode folk-packing-list, #canvas.feed-mode folk-packing-list,
#canvas.feed-mode folk-booking, #canvas.feed-mode folk-booking,
#canvas.feed-mode folk-holon,
#canvas.feed-mode folk-holon-browser,
#canvas.feed-mode folk-feed { #canvas.feed-mode folk-feed {
position: relative !important; position: relative !important;
transform: none !important; transform: none !important;
@ -1739,6 +1741,8 @@
folk-kicad, folk-kicad,
folk-zine-gen, folk-zine-gen,
folk-rapp, folk-rapp,
folk-holon,
folk-holon-browser,
folk-multisig-email { folk-multisig-email {
position: absolute; position: absolute;
} }
@ -1760,7 +1764,7 @@
folk-booking, folk-token-mint, folk-token-ledger, folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email) { folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email) {
cursor: crosshair; cursor: crosshair;
} }
@ -1772,7 +1776,7 @@
folk-booking, folk-token-mint, folk-token-ledger, folk-booking, folk-token-mint, folk-token-ledger,
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction, folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
folk-social-post, folk-splat, folk-blender, folk-drawfast, folk-social-post, folk-splat, folk-blender, folk-drawfast,
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email):hover { folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email):hover {
outline: 2px dashed #3b82f6; outline: 2px dashed #3b82f6;
outline-offset: 4px; outline-offset: 4px;
} }
@ -2227,6 +2231,8 @@
<button id="new-calendar" title="Calendar">📅 rCal</button> <button id="new-calendar" title="Calendar">📅 rCal</button>
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button> <button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button> <button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
<button id="new-holon" title="Holon">🌐 Holon</button>
<button id="new-holon-browser" title="Holon Browser">🔍 Holon Browser</button>
</div> </div>
</div> </div>
@ -2524,6 +2530,8 @@
FolkCanvas, FolkCanvas,
FolkRApp, FolkRApp,
FolkFeed, FolkFeed,
FolkHolon,
FolkHolonBrowser,
CommunitySync, CommunitySync,
PresenceManager, PresenceManager,
generatePeerId, generatePeerId,
@ -2655,6 +2663,8 @@
FolkCanvas.define(); FolkCanvas.define();
FolkRApp.define(); FolkRApp.define();
FolkFeed.define(); FolkFeed.define();
FolkHolon.define();
FolkHolonBrowser.define();
// Register all shapes with the shape registry // Register all shapes with the shape registry
shapeRegistry.register("folk-shape", FolkShape); shapeRegistry.register("folk-shape", FolkShape);
@ -2699,6 +2709,8 @@
shapeRegistry.register("folk-canvas", FolkCanvas); shapeRegistry.register("folk-canvas", FolkCanvas);
shapeRegistry.register("folk-rapp", FolkRApp); shapeRegistry.register("folk-rapp", FolkRApp);
shapeRegistry.register("folk-feed", FolkFeed); shapeRegistry.register("folk-feed", FolkFeed);
shapeRegistry.register("folk-holon", FolkHolon);
shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
// Zoom and pan state — declared early to avoid TDZ errors // Zoom and pan state — declared early to avoid TDZ errors
// (event handlers reference these before awaits yield execution) // (event handlers reference these before awaits yield execution)
@ -2972,6 +2984,7 @@
"folk-splat", "folk-blender", "folk-drawfast", "folk-splat", "folk-blender", "folk-drawfast",
"folk-freecad", "folk-kicad", "folk-freecad", "folk-kicad",
"folk-rapp", "folk-rapp",
"folk-holon", "folk-holon-browser",
"folk-multisig-email", "folk-multisig-email",
"folk-feed" "folk-feed"
].join(", "); ].join(", ");
@ -3661,6 +3674,8 @@
"folk-canvas": { width: 600, height: 400 }, "folk-canvas": { width: 600, height: 400 },
"folk-rapp": { width: 500, height: 400 }, "folk-rapp": { width: 500, height: 400 },
"folk-feed": { width: 280, height: 360 }, "folk-feed": { width: 280, height: 360 },
"folk-holon": { width: 500, height: 400 },
"folk-holon-browser": { width: 400, height: 450 },
"folk-transaction-builder": { width: 420, height: 520 }, "folk-transaction-builder": { width: 420, height: 520 },
}; };
@ -4099,6 +4114,8 @@
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark")); document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map")); document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen")); 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-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen")); document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
@ -5242,7 +5259,8 @@
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱", "folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️", "folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
"folk-freecad": "📐", "folk-kicad": "🔌", "folk-freecad": "📐", "folk-kicad": "🔌",
"folk-rapp": "📱", "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️", "folk-rapp": "📱", "folk-holon": "🌐", "folk-holon-browser": "🔍",
"folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
}; };
function getShapeLabel(data) { function getShapeLabel(data) {