719 lines
17 KiB
TypeScript
719 lines
17 KiB
TypeScript
/**
|
||
* 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, '&').replace(/</g, '<').replace(/>/g, '>');
|
||
}
|