From 21b31c43c75ef7b83b1344efa5ade6cd05bc37e2 Mon Sep 17 00:00:00 2001 From: Jeff Emmett Date: Wed, 11 Mar 2026 22:53:48 -0700 Subject: [PATCH] feat: add folk-holon + folk-holon-browser canvas shapes with Automerge data layer MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- lib/folk-holon-browser.ts | 436 +++++++++++++++++++++++ lib/folk-holon.ts | 718 ++++++++++++++++++++++++++++++++++++++ lib/holon-service.ts | 263 ++++++++++++++ lib/index.ts | 5 + website/canvas.html | 24 +- 5 files changed, 1443 insertions(+), 3 deletions(-) create mode 100644 lib/folk-holon-browser.ts create mode 100644 lib/folk-holon.ts create mode 100644 lib/holon-service.ts diff --git a/lib/folk-holon-browser.ts b/lib/folk-holon-browser.ts new file mode 100644 index 0000000..2f049f1 --- /dev/null +++ b/lib/folk-holon-browser.ts @@ -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` +
+ + 🔍 + Holon Browser + +
+ +
+
+
+ `; + + 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` +
+
+
Coordinates
+
${result.latitude.toFixed(6)}, ${result.longitude.toFixed(6)}
+
+
+
Resolution
+
${getResolutionName(result.resolution)} (Level ${result.resolution})
+
+
+
Holon ID
+
${result.id}
+
+
+
Description
+
${getResolutionDescription(result.resolution)}
+
+
` + : html` +
+
+
Type
+
Workspace / Group
+
+
+
Holon ID
+
${result.id}
+
+
`; + + const lensBadges = this.#availableLenses.map((lens) => { + const icon = LENS_ICONS[lens] ?? '📁'; + return `${icon} ${lens}`; + }).join(''); + + resultHtml = html` +
+
📍 ${escapeHtml(result.name)}
+ ${infoGrid} +
Available Data
+
${lensBadges || 'No data yet'}
+
+ + +
+
`; + } + + this.#contentEl.innerHTML = html` +
Search by H3 cell ID (e.g. 872a1070bffffff) or numeric Holon ID
+
+ + +
+ ${this.#error ? `
${escapeHtml(this.#error)}
` : ''} + ${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): FolkHolonBrowser { + return FolkShape.fromData(data) as FolkHolonBrowser; + } +} + +function escapeHtml(str: string): string { + return str.replace(/&/g, '&').replace(//g, '>').replace(/"/g, '"'); +} diff --git a/lib/folk-holon.ts b/lib/folk-holon.ts new file mode 100644 index 0000000..8484ceb --- /dev/null +++ b/lib/folk-holon.ts @@ -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> = {}; + #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` +
+ + 🌐 + Holon + +
+ + +
+
+
+ `; + + // 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 = '
Loading holon data...
'; + } else { + this.#renderConnected(); + } + } + + #renderDisconnected() { + this.#contentEl!.innerHTML = html` +
+
🌐
+
Connect to a Holon
+
+ Enter an H3 cell ID (e.g. 872a1070bffffff) or numeric Holon ID +
+
+ + +
+ ${this.#error ? `
${this.#error}
` : ''} +
+ `; + + 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` +
+
+
Coordinates
+
${this.#latitude.toFixed(6)}, ${this.#longitude.toFixed(6)}
+
+
+
Resolution
+
${resName} (Level ${this.#resolution})
+
+
+
Holon ID
+
${this.#holonId}
+
+
+
Area Info
+
${resDesc}
+
+
` + : html` +
+
+
Type
+
${resName}
+
+
+
Holon ID
+
${this.#holonId}
+
+
`; + + // 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 ``; + }).join(''); + + // Current lens data + const currentData = this.#lensData[this.#selectedLens]; + let dataHtml: string; + if (currentData && Object.keys(currentData).length > 0) { + dataHtml = `
${escapeHtml(JSON.stringify(currentData, null, 2))}
`; + } else { + dataHtml = `
No data in "${this.#selectedLens}" lens
`; + } + + this.#contentEl!.innerHTML = html` + ${infoHtml} +
${lensButtons}
+ ${dataHtml} +
+ + +
+ `; + + // 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): 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): 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, '&').replace(//g, '>'); +} diff --git a/lib/holon-service.ts b/lib/holon-service.ts new file mode 100644 index 0000000..2a16b3c --- /dev/null +++ b/lib/holon-service.ts @@ -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; +} + +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 = { + 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>; +} + +const registrySchema: DocSchema = { + module: 'holons', + collection: 'registry', + version: 1, + init: () => ({ + holon: { id: '', name: '', description: '', latitude: 0, longitude: 0, resolution: 0, timestamp: 0 }, + }), +}; + +const lensesSchema: DocSchema = { + 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 { + 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 { + 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 { + const runtime = getRuntime(); + if (!runtime) return; + const docId = await ensureRegistryDoc(data.id); + runtime.change(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 { + const runtime = getRuntime(); + if (!runtime) return null; + const docId = await ensureRegistryDoc(holonId); + const doc = runtime.get(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): Promise { + const runtime = getRuntime(); + if (!runtime) return; + const docId = await ensureLensesDoc(holonId); + runtime.change(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 | null> { + const runtime = getRuntime(); + if (!runtime) return null; + const docId = await ensureLensesDoc(holonId); + const doc = runtime.get(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 { + const runtime = getRuntime(); + if (!runtime) return []; + const docId = await ensureLensesDoc(holonId); + const doc = runtime.get(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>) => void): () => void { + const runtime = getRuntime(); + if (!runtime) return () => {}; + const docId = `${getSpace()}:holons:lenses:${holonId}` as DocumentId; + return runtime.onChange(docId, (doc) => { + cb(doc.lenses ?? {}); + }); +} diff --git a/lib/index.ts b/lib/index.ts index a9cecfa..f5f6644 100644 --- a/lib/index.ts +++ b/lib/index.ts @@ -81,6 +81,11 @@ export * from "./folk-choice-conviction"; // 3D Spider Plot (governance visualization) 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 export * from "./folk-canvas"; diff --git a/website/canvas.html b/website/canvas.html index d9c301b..deeacda 100644 --- a/website/canvas.html +++ b/website/canvas.html @@ -1464,6 +1464,8 @@ #canvas.feed-mode folk-budget, #canvas.feed-mode folk-packing-list, #canvas.feed-mode folk-booking, + #canvas.feed-mode folk-holon, + #canvas.feed-mode folk-holon-browser, #canvas.feed-mode folk-feed { position: relative !important; transform: none !important; @@ -1739,6 +1741,8 @@ folk-kicad, folk-zine-gen, folk-rapp, + folk-holon, + folk-holon-browser, folk-multisig-email { position: absolute; } @@ -1760,7 +1764,7 @@ folk-booking, folk-token-mint, folk-token-ledger, 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-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; } @@ -1772,7 +1776,7 @@ folk-booking, folk-token-mint, folk-token-ledger, 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-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-offset: 4px; } @@ -2227,6 +2231,8 @@ + + @@ -2524,6 +2530,8 @@ FolkCanvas, FolkRApp, FolkFeed, + FolkHolon, + FolkHolonBrowser, CommunitySync, PresenceManager, generatePeerId, @@ -2655,6 +2663,8 @@ FolkCanvas.define(); FolkRApp.define(); FolkFeed.define(); + FolkHolon.define(); + FolkHolonBrowser.define(); // Register all shapes with the shape registry shapeRegistry.register("folk-shape", FolkShape); @@ -2699,6 +2709,8 @@ shapeRegistry.register("folk-canvas", FolkCanvas); shapeRegistry.register("folk-rapp", FolkRApp); 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 // (event handlers reference these before awaits yield execution) @@ -2972,6 +2984,7 @@ "folk-splat", "folk-blender", "folk-drawfast", "folk-freecad", "folk-kicad", "folk-rapp", + "folk-holon", "folk-holon-browser", "folk-multisig-email", "folk-feed" ].join(", "); @@ -3661,6 +3674,8 @@ "folk-canvas": { width: 600, height: 400 }, "folk-rapp": { width: 500, height: 400 }, "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 }, }; @@ -4099,6 +4114,8 @@ document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark")); document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar")); 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-video-gen").addEventListener("click", () => setPendingTool("folk-video-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-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️", "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) {