264 lines
8.2 KiB
TypeScript
264 lines
8.2 KiB
TypeScript
/**
|
|
* 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 ?? {});
|
|
});
|
|
}
|