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