/** * 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, '>'); }