rspace-online/lib/folk-holon.ts

719 lines
17 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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