rspace-online/lib/folk-holon-browser.ts

437 lines
10 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-browser — Canvas shape for searching and browsing holons.
*
* Ported from canvas-website HolonBrowser.tsx (React) to FolkShape
* web component. Searches H3 cell IDs or coordinates, probes standard
* lenses, and dispatches "open-holon" CustomEvent for canvas to create
* a folk-holon shape.
*/
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,
getAvailableLenses,
getLensData,
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: 300px;
}
.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;
}
.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);
display: flex;
flex-direction: column;
gap: 16px;
}
.search-row {
display: flex;
gap: 8px;
}
.search-input {
flex: 1;
padding: 8px 12px;
border: 1px solid #d1d5db;
border-radius: 6px;
font-size: 13px;
font-family: monospace;
outline: none;
}
.search-input:focus {
border-color: #22c55e;
box-shadow: 0 0 0 2px rgba(34, 197, 94, 0.2);
}
.search-btn {
padding: 8px 16px;
background: #3b82f6;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.search-btn:hover { background: #2563eb; }
.search-btn:disabled { background: #9ca3af; cursor: not-allowed; }
.error {
color: #dc2626;
font-size: 12px;
}
.result-card {
border: 1px solid #e5e7eb;
border-radius: 8px;
padding: 14px;
}
.result-name {
font-size: 15px;
font-weight: 600;
color: #111827;
margin-bottom: 8px;
}
.result-grid {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 6px;
margin-bottom: 12px;
font-size: 12px;
}
.result-label {
color: #6b7280;
font-size: 11px;
}
.result-value {
color: #1e293b;
font-family: monospace;
font-size: 12px;
word-break: break-all;
}
.lens-badges {
display: flex;
flex-wrap: wrap;
gap: 4px;
margin-bottom: 12px;
}
.lens-badge {
padding: 3px 8px;
border-radius: 10px;
font-size: 11px;
background: #dbeafe;
color: #1e40af;
}
.lens-badge.empty {
background: #f3f4f6;
color: #9ca3af;
}
.action-row {
display: flex;
gap: 8px;
padding-top: 12px;
border-top: 1px solid #e5e7eb;
}
.open-btn {
padding: 8px 16px;
background: #22c55e;
color: white;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 600;
}
.open-btn:hover { background: #16a34a; }
.reset-btn {
padding: 8px 16px;
background: #f3f4f6;
color: #374151;
border: none;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
}
.reset-btn:hover { background: #e5e7eb; }
.loading {
color: #6b7280;
font-size: 13px;
padding: 8px 0;
}
.hint {
font-size: 12px;
color: #6b7280;
}
`;
declare global {
interface HTMLElementTagNameMap {
'folk-holon-browser': FolkHolonBrowser;
}
}
export class FolkHolonBrowser extends FolkShape {
static override tagName = 'folk-holon-browser';
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 ──
#searchId = '';
#result: HolonData | null = null;
#availableLenses: string[] = [];
#loading = false;
#error = '';
#contentEl: HTMLElement | null = null;
override createRenderRoot() {
const root = super.createRenderRoot();
const wrapper = document.createElement('div');
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>Holon Browser</span>
</span>
<div class="header-actions">
<button class="close-btn" title="Close">×</button>
</div>
</div>
<div class="content"></div>
`;
const slot = root.querySelector('slot');
const containerDiv = slot?.parentElement as HTMLElement;
if (containerDiv) containerDiv.replaceWith(wrapper);
this.#contentEl = wrapper.querySelector('.content');
wrapper.querySelector('.close-btn')!.addEventListener('click', (e) => {
e.stopPropagation();
this.dispatchEvent(new CustomEvent('close'));
});
this.#render();
return root;
}
// ── Search ──
async #handleSearch() {
const id = this.#searchId.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.#error = '';
this.#loading = true;
this.#render();
try {
let lat = 0, lng = 0, resolution = -1, name = `Holon ${id.slice(-8)}`;
if (isH3CellId(id)) {
[lat, lng] = h3.cellToLatLng(id);
resolution = h3.getResolution(id);
name = `${getResolutionName(resolution)} Holon`;
}
this.#result = {
id,
name,
description: resolution >= 0 ? getResolutionDescription(resolution) : '',
latitude: lat,
longitude: lng,
resolution,
timestamp: Date.now(),
};
// Probe lenses
this.#availableLenses = await getAvailableLenses(id);
if (this.#availableLenses.length === 0) {
this.#availableLenses = ['general'];
}
} catch (e) {
this.#error = `Search failed: ${e instanceof Error ? e.message : 'Unknown error'}`;
this.#result = null;
} finally {
this.#loading = false;
this.#render();
}
}
#handleOpenHolon() {
if (!this.#result) return;
this.dispatchEvent(new CustomEvent('open-holon', {
bubbles: true,
detail: { holon: this.#result, lenses: this.#availableLenses },
}));
}
#handleReset() {
this.#searchId = '';
this.#result = null;
this.#availableLenses = [];
this.#error = '';
this.#render();
}
// ── Rendering ──
#render() {
if (!this.#contentEl) return;
const result = this.#result;
const isGeo = result && result.resolution >= 0;
let resultHtml = '';
if (result) {
const infoGrid = isGeo
? html`
<div class="result-grid">
<div>
<div class="result-label">Coordinates</div>
<div class="result-value">${result.latitude.toFixed(6)}, ${result.longitude.toFixed(6)}</div>
</div>
<div>
<div class="result-label">Resolution</div>
<div class="result-value">${getResolutionName(result.resolution)} (Level ${result.resolution})</div>
</div>
<div>
<div class="result-label">Holon ID</div>
<div class="result-value">${result.id}</div>
</div>
<div>
<div class="result-label">Description</div>
<div class="result-value">${getResolutionDescription(result.resolution)}</div>
</div>
</div>`
: html`
<div class="result-grid">
<div>
<div class="result-label">Type</div>
<div class="result-value" style="color:#22c55e;font-weight:600">Workspace / Group</div>
</div>
<div>
<div class="result-label">Holon ID</div>
<div class="result-value">${result.id}</div>
</div>
</div>`;
const lensBadges = this.#availableLenses.map((lens) => {
const icon = LENS_ICONS[lens] ?? '📁';
return `<span class="lens-badge">${icon} ${lens}</span>`;
}).join('');
resultHtml = html`
<div class="result-card">
<div class="result-name">📍 ${escapeHtml(result.name)}</div>
${infoGrid}
<div class="result-label" style="margin-bottom:4px">Available Data</div>
<div class="lens-badges">${lensBadges || '<span class="lens-badge empty">No data yet</span>'}</div>
<div class="action-row">
<button class="open-btn">Open Holon</button>
<button class="reset-btn">Search Another</button>
</div>
</div>`;
}
this.#contentEl.innerHTML = html`
<div class="hint">Search by H3 cell ID (e.g. <code>872a1070bffffff</code>) or numeric Holon ID</div>
<div class="search-row">
<input class="search-input" type="text" placeholder="Holon ID..." value="${escapeHtml(this.#searchId)}" />
<button class="search-btn" ${this.#loading ? 'disabled' : ''}>${this.#loading ? 'Searching...' : 'Search'}</button>
</div>
${this.#error ? `<div class="error">${escapeHtml(this.#error)}</div>` : ''}
${resultHtml}
`;
// Wire events
const input = this.#contentEl.querySelector('.search-input') as HTMLInputElement;
const searchBtn = this.#contentEl.querySelector('.search-btn') as HTMLButtonElement;
input?.addEventListener('input', (e) => { this.#searchId = (e.target as HTMLInputElement).value; });
input?.addEventListener('pointerdown', (e) => e.stopPropagation());
input?.addEventListener('keydown', (e) => {
e.stopPropagation();
if (e.key === 'Enter') this.#handleSearch();
});
searchBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleSearch(); });
const openBtn = this.#contentEl.querySelector('.open-btn');
const resetBtn = this.#contentEl.querySelector('.reset-btn');
openBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleOpenHolon(); });
resetBtn?.addEventListener('click', (e) => { e.stopPropagation(); this.#handleReset(); });
}
// ── Serialization ──
override toJSON() {
return { ...super.toJSON(), type: 'folk-holon-browser' };
}
static override fromData(data: Record<string, any>): FolkHolonBrowser {
return FolkShape.fromData(data) as FolkHolonBrowser;
}
}
function escapeHtml(str: string): string {
return str.replace(/&/g, '&amp;').replace(/</g, '&lt;').replace(/>/g, '&gt;').replace(/"/g, '&quot;');
}