/** * 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 ?? {}); }); }