feat: add folk-holon + folk-holon-browser canvas shapes with Automerge data layer
Port HolonShapeUtil from canvas-website to rSpace web components. Replaces dead HoloSphere/GunDB stub with local-first CRDT storage via window.__rspaceOfflineRuntime. H3 geospatial hierarchy via pure h3-js. Data model designed for future AD4M Perspective bridging. - lib/holon-service.ts: Automerge-backed holon registry + lens docs - lib/folk-holon.ts: Main holon shape (ID entry → connected view with 16 lenses) - lib/folk-holon-browser.ts: Search/browse shape with open-holon event - Registered in canvas.html: imports, define, registry, CSS, toolbar, sizes Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
parent
47b9c56da9
commit
21b31c43c7
|
|
@ -0,0 +1,436 @@
|
||||||
|
/**
|
||||||
|
* 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, '"');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,718 @@
|
||||||
|
/**
|
||||||
|
* 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, '>');
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,263 @@
|
||||||
|
/**
|
||||||
|
* 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 ?? {});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -81,6 +81,11 @@ export * from "./folk-choice-conviction";
|
||||||
// 3D Spider Plot (governance visualization)
|
// 3D Spider Plot (governance visualization)
|
||||||
export * from "./folk-spider-3d";
|
export * from "./folk-spider-3d";
|
||||||
|
|
||||||
|
// Holon Shapes (H3 geospatial)
|
||||||
|
export * from "./folk-holon";
|
||||||
|
export * from "./folk-holon-browser";
|
||||||
|
export * from "./holon-service";
|
||||||
|
|
||||||
// Nested Space Shape
|
// Nested Space Shape
|
||||||
export * from "./folk-canvas";
|
export * from "./folk-canvas";
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1464,6 +1464,8 @@
|
||||||
#canvas.feed-mode folk-budget,
|
#canvas.feed-mode folk-budget,
|
||||||
#canvas.feed-mode folk-packing-list,
|
#canvas.feed-mode folk-packing-list,
|
||||||
#canvas.feed-mode folk-booking,
|
#canvas.feed-mode folk-booking,
|
||||||
|
#canvas.feed-mode folk-holon,
|
||||||
|
#canvas.feed-mode folk-holon-browser,
|
||||||
#canvas.feed-mode folk-feed {
|
#canvas.feed-mode folk-feed {
|
||||||
position: relative !important;
|
position: relative !important;
|
||||||
transform: none !important;
|
transform: none !important;
|
||||||
|
|
@ -1739,6 +1741,8 @@
|
||||||
folk-kicad,
|
folk-kicad,
|
||||||
folk-zine-gen,
|
folk-zine-gen,
|
||||||
folk-rapp,
|
folk-rapp,
|
||||||
|
folk-holon,
|
||||||
|
folk-holon-browser,
|
||||||
folk-multisig-email {
|
folk-multisig-email {
|
||||||
position: absolute;
|
position: absolute;
|
||||||
}
|
}
|
||||||
|
|
@ -1760,7 +1764,7 @@
|
||||||
folk-booking, folk-token-mint, folk-token-ledger,
|
folk-booking, folk-token-mint, folk-token-ledger,
|
||||||
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
||||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email) {
|
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email) {
|
||||||
cursor: crosshair;
|
cursor: crosshair;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1772,7 +1776,7 @@
|
||||||
folk-booking, folk-token-mint, folk-token-ledger,
|
folk-booking, folk-token-mint, folk-token-ledger,
|
||||||
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
folk-choice-vote, folk-choice-rank, folk-choice-spider, folk-spider-3d, folk-choice-conviction,
|
||||||
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
folk-social-post, folk-splat, folk-blender, folk-drawfast,
|
||||||
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-multisig-email):hover {
|
folk-freecad, folk-kicad, folk-zine-gen, folk-rapp, folk-holon, folk-holon-browser, folk-multisig-email):hover {
|
||||||
outline: 2px dashed #3b82f6;
|
outline: 2px dashed #3b82f6;
|
||||||
outline-offset: 4px;
|
outline-offset: 4px;
|
||||||
}
|
}
|
||||||
|
|
@ -2227,6 +2231,8 @@
|
||||||
<button id="new-calendar" title="Calendar">📅 rCal</button>
|
<button id="new-calendar" title="Calendar">📅 rCal</button>
|
||||||
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
|
<button id="embed-inbox" title="Embed rInbox">📧 rInbox</button>
|
||||||
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
|
<button id="embed-network" title="Embed rNetwork">🌍 rNetwork</button>
|
||||||
|
<button id="new-holon" title="Holon">🌐 Holon</button>
|
||||||
|
<button id="new-holon-browser" title="Holon Browser">🔍 Holon Browser</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -2524,6 +2530,8 @@
|
||||||
FolkCanvas,
|
FolkCanvas,
|
||||||
FolkRApp,
|
FolkRApp,
|
||||||
FolkFeed,
|
FolkFeed,
|
||||||
|
FolkHolon,
|
||||||
|
FolkHolonBrowser,
|
||||||
CommunitySync,
|
CommunitySync,
|
||||||
PresenceManager,
|
PresenceManager,
|
||||||
generatePeerId,
|
generatePeerId,
|
||||||
|
|
@ -2655,6 +2663,8 @@
|
||||||
FolkCanvas.define();
|
FolkCanvas.define();
|
||||||
FolkRApp.define();
|
FolkRApp.define();
|
||||||
FolkFeed.define();
|
FolkFeed.define();
|
||||||
|
FolkHolon.define();
|
||||||
|
FolkHolonBrowser.define();
|
||||||
|
|
||||||
// Register all shapes with the shape registry
|
// Register all shapes with the shape registry
|
||||||
shapeRegistry.register("folk-shape", FolkShape);
|
shapeRegistry.register("folk-shape", FolkShape);
|
||||||
|
|
@ -2699,6 +2709,8 @@
|
||||||
shapeRegistry.register("folk-canvas", FolkCanvas);
|
shapeRegistry.register("folk-canvas", FolkCanvas);
|
||||||
shapeRegistry.register("folk-rapp", FolkRApp);
|
shapeRegistry.register("folk-rapp", FolkRApp);
|
||||||
shapeRegistry.register("folk-feed", FolkFeed);
|
shapeRegistry.register("folk-feed", FolkFeed);
|
||||||
|
shapeRegistry.register("folk-holon", FolkHolon);
|
||||||
|
shapeRegistry.register("folk-holon-browser", FolkHolonBrowser);
|
||||||
|
|
||||||
// Zoom and pan state — declared early to avoid TDZ errors
|
// Zoom and pan state — declared early to avoid TDZ errors
|
||||||
// (event handlers reference these before awaits yield execution)
|
// (event handlers reference these before awaits yield execution)
|
||||||
|
|
@ -2972,6 +2984,7 @@
|
||||||
"folk-splat", "folk-blender", "folk-drawfast",
|
"folk-splat", "folk-blender", "folk-drawfast",
|
||||||
"folk-freecad", "folk-kicad",
|
"folk-freecad", "folk-kicad",
|
||||||
"folk-rapp",
|
"folk-rapp",
|
||||||
|
"folk-holon", "folk-holon-browser",
|
||||||
"folk-multisig-email",
|
"folk-multisig-email",
|
||||||
"folk-feed"
|
"folk-feed"
|
||||||
].join(", ");
|
].join(", ");
|
||||||
|
|
@ -3661,6 +3674,8 @@
|
||||||
"folk-canvas": { width: 600, height: 400 },
|
"folk-canvas": { width: 600, height: 400 },
|
||||||
"folk-rapp": { width: 500, height: 400 },
|
"folk-rapp": { width: 500, height: 400 },
|
||||||
"folk-feed": { width: 280, height: 360 },
|
"folk-feed": { width: 280, height: 360 },
|
||||||
|
"folk-holon": { width: 500, height: 400 },
|
||||||
|
"folk-holon-browser": { width: 400, height: 450 },
|
||||||
"folk-transaction-builder": { width: 420, height: 520 },
|
"folk-transaction-builder": { width: 420, height: 520 },
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
@ -4099,6 +4114,8 @@
|
||||||
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
|
document.getElementById("new-bookmark").addEventListener("click", () => setPendingTool("folk-bookmark"));
|
||||||
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
|
document.getElementById("new-calendar").addEventListener("click", () => setPendingTool("folk-calendar"));
|
||||||
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
|
document.getElementById("new-map").addEventListener("click", () => setPendingTool("folk-map"));
|
||||||
|
document.getElementById("new-holon").addEventListener("click", () => setPendingTool("folk-holon"));
|
||||||
|
document.getElementById("new-holon-browser").addEventListener("click", () => setPendingTool("folk-holon-browser"));
|
||||||
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
|
document.getElementById("new-image-gen").addEventListener("click", () => setPendingTool("folk-image-gen"));
|
||||||
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
|
document.getElementById("new-video-gen").addEventListener("click", () => setPendingTool("folk-video-gen"));
|
||||||
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
|
document.getElementById("new-zine-gen").addEventListener("click", () => setPendingTool("folk-zine-gen"));
|
||||||
|
|
@ -5242,7 +5259,8 @@
|
||||||
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
|
"folk-choice-spider": "🕸", "folk-spider-3d": "📊", "folk-choice-conviction": "⏳", "folk-social-post": "📱",
|
||||||
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
|
"folk-splat": "🔮", "folk-blender": "🧊", "folk-drawfast": "✏️",
|
||||||
"folk-freecad": "📐", "folk-kicad": "🔌",
|
"folk-freecad": "📐", "folk-kicad": "🔌",
|
||||||
"folk-rapp": "📱", "folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
|
"folk-rapp": "📱", "folk-holon": "🌐", "folk-holon-browser": "🔍",
|
||||||
|
"folk-multisig-email": "✉️", "folk-feed": "🔄", "folk-arrow": "↗️",
|
||||||
};
|
};
|
||||||
|
|
||||||
function getShapeLabel(data) {
|
function getShapeLabel(data) {
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue