Move field component lifecycle into browser adapter

This commit is contained in:
Meta Vens 2026-07-10 15:19:11 +01:00
commit af0e44cfc3
6 changed files with 428 additions and 313 deletions

View file

@ -115,6 +115,9 @@ advertisements and typed command/receipt documents are the boundary.
`gamecult.fields.*` documents and advertises `fields.surface` on graphical
surfaces that use them. Hermodr carries decoded MessagePack values without
interpreting field tuples; the browser fields adapter owns that conversion.
- The browser fields adapter owns field component matching, canvas lifecycle,
provider-document polling, tuple conversion, and capability-gap reporting.
The general CultUI lowerer supplies one temporary native drawing port.
- The TeX owner sidecar serves its Eve ABI over CultNet RUDP operation
envelopes; its stdio protocol is retained only as a local debug adapter.
- Aetheria owns its provider advertisement, world surface, scenario, asset
@ -159,10 +162,10 @@ advertisements and typed command/receipt documents are the boundary.
is its semantic owner. Sai already owns VN/Ink execution and its live witness.
- Keep nested plugin availability explicit and non-transitive: Sai does not own
Norn or TeX.
- Move the remaining browser WebGL field projection body from the general
component lowerer into the `fields.surface` runtime adapter. The browser now
resolves required adapter capabilities from provider advertisements and owns
field tuple conversion outside Hermodr.
- Move the remaining browser Canvas/WebGL drawing body through the adapter's
native drawing port, then delete that port from the general component lowerer.
The browser already resolves required adapter capabilities from provider
advertisements and owns field tuple conversion outside Hermodr.
### Conformance

View file

@ -1,8 +1,50 @@
export interface EveBrowserPluginAdapter {
pluginId: string;
capabilities: readonly string[];
componentKinds: readonly string[];
schemas: readonly string[];
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
renderComponent?: (component: FieldsComponent, props: Record<string, unknown>, layout: Record<string, unknown>, style: Record<string, unknown>, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement;
}
export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter;
export interface FieldsComponent {
id?: string;
kind?: string;
text?: string;
commandId?: string;
props?: Record<string, unknown>;
layout?: Record<string, unknown>;
style?: Record<string, unknown>;
children?: FieldsComponent[];
embeddedDocuments?: Array<Record<string, unknown>>;
}
export interface FieldsResolvedDocument {
document?: unknown;
schemaId?: string;
}
export interface FieldsLoweringOptions {
documentResolver?: (request: FieldsDocumentRequest, component: FieldsComponent) => Promise<unknown>;
provider?: {
providerId?: string;
};
}
export interface FieldsDocumentRequest {
documentId: string;
presentationKind?: string;
schemaId?: string;
slotId?: string;
}
export interface FieldsSurfaceDataState {
renderSplats?: Record<string, unknown>;
gravity?: Record<string, unknown>;
objects?: Record<string, unknown>;
loading?: boolean;
lastError?: string;
lastLoadedAt?: number;
}
export interface FieldsRuntimeContext {
applyGeneratedLayout(element: HTMLElement, layout: Record<string, unknown>, style: Record<string, unknown>): void;
drawSurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state: FieldsSurfaceDataState): void;
providerId?: string;
}
export declare function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown;

View file

@ -6,9 +6,133 @@ const FIELD_SCHEMAS = new Set([
export const fieldsBrowserAdapter = {
pluginId: "fields.surface",
capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"],
componentKinds: ["field.surface2d", "gravity.surface"],
schemas: [...FIELD_SCHEMAS],
normalizeDocument: normalizeFieldsDocument,
renderComponent: renderFieldsComponent,
};
function renderFieldsComponent(component, props, layout, style, options, context) {
const canvas = document.createElement("canvas");
canvas.className = "cultui-gravity-surface";
if (component.id)
canvas.id = component.id;
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", stringValue(props.label, "gravity surface"));
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.pointerEvents = "none";
context.applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state = {};
const draw = () => context.drawSurface(canvas, props, state);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued)
return;
drawQueued = true;
requestAnimationFrame(() => {
drawQueued = false;
if (canvas.isConnected)
draw();
});
};
canvas.addEventListener("cultui:asset-loaded", scheduleDraw);
let refreshTimer = 0;
let observer;
const cleanup = () => {
if (refreshTimer)
window.clearInterval(refreshTimer);
observer?.disconnect();
canvas.removeEventListener("cultui:asset-loaded", scheduleDraw);
};
const refreshDocuments = () => {
if (!canvas.isConnected) {
cleanup();
return;
}
void resolveFieldsDocuments(component, props, options, context.providerId, state)
.then(changed => {
if (changed && canvas.isConnected)
scheduleDraw();
})
.catch(error => {
state.lastError = error instanceof Error ? error.message : String(error);
});
};
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(scheduleDraw);
observer.observe(canvas);
}
scheduleDraw();
const refreshMs = Math.max(33, Math.min(1000, positiveInteger(props.stateRefreshMs, 100)));
refreshTimer = window.setInterval(refreshDocuments, refreshMs);
queueMicrotask(refreshDocuments);
window.setTimeout(scheduleDraw, 300);
window.setTimeout(scheduleDraw, 1000);
return canvas;
}
async function resolveFieldsDocuments(component, props, options, providerId, state) {
if (state.loading)
return false;
const requests = [
fieldDocumentRequest(component, props, "renderSplats", "renderSplatsDocumentId", "renderSplatsSchemaId"),
fieldDocumentRequest(component, props, "gravity", "gravityDocumentId", "gravitySchemaId"),
fieldDocumentRequest(component, props, "objects", "objectsDocumentId", "objectsSchemaId"),
].filter((request) => Boolean(request?.documentId));
if (requests.length === 0)
return false;
state.loading = true;
try {
let changed = false;
for (const request of requests) {
const resolved = await options.documentResolver?.(request, component);
let document = record(normalizeFieldsDocument(resolved?.schemaId || request.schemaId, resolved?.document));
if (Object.keys(document).length === 0) {
document = record(normalizeFieldsDocument(request.schemaId, await fetchFieldsDocument(request, providerId || options.provider?.providerId)));
}
if (Object.keys(document).length === 0)
continue;
const key = request.slotId === "renderSplats" ? "renderSplats" : request.slotId === "gravity" ? "gravity" : "objects";
const previousFrame = numberValue(state[key]?.frameId, -1);
const nextFrame = numberValue(document.frameId, previousFrame);
state[key] = document;
changed ||= nextFrame !== previousFrame;
}
state.lastLoadedAt = Date.now();
return changed;
}
finally {
state.loading = false;
}
}
async function fetchFieldsDocument(request, providerId) {
if (typeof fetch !== "function" || typeof window === "undefined" || !providerId)
return undefined;
const params = new URLSearchParams({ documentId: request.documentId });
if (request.schemaId)
params.set("schemaId", request.schemaId);
if (request.slotId)
params.set("slotId", request.slotId);
const response = await fetch(`/eir/document/${encodeURIComponent(providerId)}?${params.toString()}`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok)
return undefined;
return record(await response.json()).document;
}
function fieldDocumentRequest(component, props, slotId, documentIdProp, schemaIdProp) {
const slot = (component.embeddedDocuments || []).find(candidate => String(candidate?.slotId || "") === slotId);
const documentId = firstString(props[documentIdProp], slot?.documentId);
if (!documentId)
return undefined;
return {
documentId,
schemaId: firstString(props[schemaIdProp], slot?.schemaId),
presentationKind: firstString(slot?.presentationKind) || "data",
slotId,
};
}
export function normalizeFieldsDocument(schemaId, value) {
const tuple = Array.isArray(value) ? value : undefined;
const documentSchema = tuple ? tuple[0] : value?.schema;
@ -122,3 +246,23 @@ function normalizeAsset(value) {
function array(value) {
return Array.isArray(value) ? value : [];
}
function record(value) {
return value && typeof value === "object" && !Array.isArray(value) ? value : {};
}
function firstString(...values) {
for (const value of values)
if (typeof value === "string" && value.trim())
return value.trim();
return "";
}
function stringValue(value, fallback) {
return typeof value === "string" && value ? value : fallback;
}
function positiveInteger(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
function numberValue(value, fallback) {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}

View file

@ -223,7 +223,16 @@ export function renderEveComponent(node, options = currentOptions) {
return renderInventoryItem(node, props, layout, style);
}
if (kind === "field.surface2d" || kind === "gravity.surface") {
return renderGravitySurface(node, props, layout, style, options);
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (!adapter?.renderComponent) {
throw new Error(`No browser plugin adapter can lower ${kind}.`);
}
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
drawSurface: drawGravitySurface,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
});
}
if (kind === "world.scene3d") {
return renderWorldScene(node, props, layout, style, options);
@ -631,69 +640,6 @@ function prefixedProps(props, prefix) {
}
return result;
}
const gravitySurfaceDataStates = new WeakMap();
function renderGravitySurface(node, props, layout, style, options) {
const canvas = el("canvas", "cultui-gravity-surface");
assignId(canvas, node);
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", stringProp(props.label, "gravity surface"));
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.pointerEvents = "none";
applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state = {};
gravitySurfaceDataStates.set(canvas, state);
const draw = () => drawGravitySurface(canvas, props, state);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued)
return;
drawQueued = true;
requestAnimationFrame(() => {
if (!canvas.isConnected) {
drawQueued = false;
return;
}
drawQueued = false;
draw();
});
};
canvas.addEventListener("cultui:asset-loaded", scheduleDraw);
let refreshTimer = 0;
let observer;
const cleanup = () => {
if (refreshTimer)
window.clearInterval(refreshTimer);
observer?.disconnect();
canvas.removeEventListener("cultui:asset-loaded", scheduleDraw);
};
const refreshDocuments = () => {
if (!canvas.isConnected) {
cleanup();
return;
}
void resolveGravitySurfaceDocuments(node, props, options, state)
.then((changed) => {
if (changed && canvas.isConnected)
scheduleDraw();
})
.catch((error) => {
state.lastError = error instanceof Error ? error.message : String(error);
});
};
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(scheduleDraw);
observer.observe(canvas);
}
scheduleDraw();
const refreshMs = Math.max(33, Math.min(1000, positiveInt(props.stateRefreshMs, 100)));
refreshTimer = window.setInterval(refreshDocuments, refreshMs);
queueMicrotask(refreshDocuments);
window.setTimeout(scheduleDraw, 300);
window.setTimeout(scheduleDraw, 1000);
return canvas;
}
export function projectWorldScene(node) {
const entities = (node.children || []).filter(child => child.kind === "world.entity3d");
const positions = entities.map(entity => vector3Prop(objectProps(entity.props).position, [0, 0, 0]));
@ -819,82 +765,6 @@ function worldEntityGlyph(kind) {
function cssIdentifier(value) {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
}
async function resolveGravitySurfaceDocuments(node, props, options, state) {
if (!options.documentResolver || state.loading)
return false;
const requests = [
gravityDocumentRequest(node, props, "renderSplats", "renderSplatsDocumentId", "renderSplatsSchemaId"),
gravityDocumentRequest(node, props, "gravity", "gravityDocumentId", "gravitySchemaId"),
gravityDocumentRequest(node, props, "objects", "objectsDocumentId", "objectsSchemaId"),
].filter((request) => Boolean(request?.documentId));
if (requests.length === 0)
return false;
state.loading = true;
try {
let changed = false;
for (const request of requests) {
const resolved = await options.documentResolver(request, node);
let document = objectProps(normalizePluginDocument(resolved?.schemaId || request.schemaId, resolved?.document, options));
if (Object.keys(document).length === 0) {
document = objectProps(normalizePluginDocument(request.schemaId, await fetchGravitySurfaceDocument(request, options), options));
}
if (!document || Object.keys(document).length === 0)
continue;
const key = request.slotId === "renderSplats"
? "renderSplats"
: request.slotId === "gravity"
? "gravity"
: "objects";
const previousFrame = numberProp(state[key]?.frameId, -1);
const nextFrame = numberProp(document.frameId, previousFrame);
state[key] = document;
changed ||= nextFrame !== previousFrame;
}
state.lastLoadedAt = Date.now();
return changed;
}
finally {
state.loading = false;
}
}
function normalizePluginDocument(schemaId, value, options) {
const adapters = options.pluginAdapters || defaultBrowserPluginAdapters;
const adapter = adapters.find(candidate => !schemaId || candidate.schemas.includes(schemaId));
return adapter ? adapter.normalizeDocument(schemaId, value) : value;
}
async function fetchGravitySurfaceDocument(request, options) {
if (typeof fetch !== "function" || typeof window === "undefined")
return undefined;
const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || "";
if (!providerId)
return undefined;
const params = new URLSearchParams();
params.set("documentId", request.documentId);
if (request.schemaId)
params.set("schemaId", request.schemaId);
if (request.slotId)
params.set("slotId", request.slotId);
const response = await fetch(`/eir/document/${encodeURIComponent(providerId)}?${params.toString()}`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok)
return undefined;
const payload = await response.json();
return objectProps(payload).document;
}
function gravityDocumentRequest(component, props, slotId, propDocumentId, propSchemaId) {
const slot = (component.embeddedDocuments || []).find((candidate) => String(candidate?.slotId || "") === slotId);
const documentId = firstString(props[propDocumentId], slot?.documentId, "");
if (!documentId)
return undefined;
return {
documentId,
schemaId: firstString(props[propSchemaId], slot?.schemaId, ""),
presentationKind: firstString(slot?.presentationKind, "data"),
slotId,
};
}
function drawGravitySurface(canvas, props, state) {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));

View file

@ -7,17 +7,207 @@ const FIELD_SCHEMAS = new Set([
export interface EveBrowserPluginAdapter {
pluginId: string;
capabilities: readonly string[];
componentKinds: readonly string[];
schemas: readonly string[];
normalizeDocument(schemaId: string | undefined, value: unknown): unknown;
renderComponent?: (
component: FieldsComponent,
props: Record<string, unknown>,
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: FieldsLoweringOptions,
context: FieldsRuntimeContext,
) => HTMLElement;
}
export const fieldsBrowserAdapter: EveBrowserPluginAdapter = {
pluginId: "fields.surface",
capabilities: ["field.surface2d", "gravity.surface", "field.scalar", "field.vector", "field.objects"],
componentKinds: ["field.surface2d", "gravity.surface"],
schemas: [...FIELD_SCHEMAS],
normalizeDocument: normalizeFieldsDocument,
renderComponent: renderFieldsComponent,
};
export interface FieldsComponent {
id?: string;
kind?: string;
text?: string;
commandId?: string;
props?: Record<string, unknown>;
layout?: Record<string, unknown>;
style?: Record<string, unknown>;
children?: FieldsComponent[];
embeddedDocuments?: Array<Record<string, unknown>>;
}
export interface FieldsResolvedDocument {
document?: unknown;
schemaId?: string;
}
export interface FieldsLoweringOptions {
documentResolver?: (
request: FieldsDocumentRequest,
component: FieldsComponent,
) => Promise<unknown>;
provider?: { providerId?: string };
}
export interface FieldsDocumentRequest {
documentId: string;
presentationKind?: string;
schemaId?: string;
slotId?: string;
}
export interface FieldsSurfaceDataState {
renderSplats?: Record<string, unknown>;
gravity?: Record<string, unknown>;
objects?: Record<string, unknown>;
loading?: boolean;
lastError?: string;
lastLoadedAt?: number;
}
export interface FieldsRuntimeContext {
applyGeneratedLayout(element: HTMLElement, layout: Record<string, unknown>, style: Record<string, unknown>): void;
drawSurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state: FieldsSurfaceDataState): void;
providerId?: string;
}
function renderFieldsComponent(
component: FieldsComponent,
props: Record<string, unknown>,
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: FieldsLoweringOptions,
context: FieldsRuntimeContext,
): HTMLElement {
const canvas = document.createElement("canvas");
canvas.className = "cultui-gravity-surface";
if (component.id) canvas.id = component.id;
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", stringValue(props.label, "gravity surface"));
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.pointerEvents = "none";
context.applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state: FieldsSurfaceDataState = {};
const draw = () => context.drawSurface(canvas, props, state);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued) return;
drawQueued = true;
requestAnimationFrame(() => {
drawQueued = false;
if (canvas.isConnected) draw();
});
};
canvas.addEventListener("cultui:asset-loaded", scheduleDraw);
let refreshTimer = 0;
let observer: ResizeObserver | undefined;
const cleanup = () => {
if (refreshTimer) window.clearInterval(refreshTimer);
observer?.disconnect();
canvas.removeEventListener("cultui:asset-loaded", scheduleDraw);
};
const refreshDocuments = () => {
if (!canvas.isConnected) {
cleanup();
return;
}
void resolveFieldsDocuments(component, props, options, context.providerId, state)
.then(changed => {
if (changed && canvas.isConnected) scheduleDraw();
})
.catch(error => {
state.lastError = error instanceof Error ? error.message : String(error);
});
};
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(scheduleDraw);
observer.observe(canvas);
}
scheduleDraw();
const refreshMs = Math.max(33, Math.min(1000, positiveInteger(props.stateRefreshMs, 100)));
refreshTimer = window.setInterval(refreshDocuments, refreshMs);
queueMicrotask(refreshDocuments);
window.setTimeout(scheduleDraw, 300);
window.setTimeout(scheduleDraw, 1000);
return canvas;
}
async function resolveFieldsDocuments(
component: FieldsComponent,
props: Record<string, unknown>,
options: FieldsLoweringOptions,
providerId: string | undefined,
state: FieldsSurfaceDataState,
): Promise<boolean> {
if (state.loading) return false;
const requests = [
fieldDocumentRequest(component, props, "renderSplats", "renderSplatsDocumentId", "renderSplatsSchemaId"),
fieldDocumentRequest(component, props, "gravity", "gravityDocumentId", "gravitySchemaId"),
fieldDocumentRequest(component, props, "objects", "objectsDocumentId", "objectsSchemaId"),
].filter((request): request is FieldsDocumentRequest => Boolean(request?.documentId));
if (requests.length === 0) return false;
state.loading = true;
try {
let changed = false;
for (const request of requests) {
const resolved = await options.documentResolver?.(request, component) as FieldsResolvedDocument | undefined;
let document = record(normalizeFieldsDocument(resolved?.schemaId || request.schemaId, resolved?.document));
if (Object.keys(document).length === 0) {
document = record(normalizeFieldsDocument(request.schemaId, await fetchFieldsDocument(request, providerId || options.provider?.providerId)));
}
if (Object.keys(document).length === 0) continue;
const key = request.slotId === "renderSplats" ? "renderSplats" : request.slotId === "gravity" ? "gravity" : "objects";
const previousFrame = numberValue(state[key]?.frameId, -1);
const nextFrame = numberValue(document.frameId, previousFrame);
state[key] = document;
changed ||= nextFrame !== previousFrame;
}
state.lastLoadedAt = Date.now();
return changed;
} finally {
state.loading = false;
}
}
async function fetchFieldsDocument(request: FieldsDocumentRequest, providerId: string | undefined): Promise<unknown> {
if (typeof fetch !== "function" || typeof window === "undefined" || !providerId) return undefined;
const params = new URLSearchParams({ documentId: request.documentId });
if (request.schemaId) params.set("schemaId", request.schemaId);
if (request.slotId) params.set("slotId", request.slotId);
const response = await fetch(`/eir/document/${encodeURIComponent(providerId)}?${params.toString()}`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) return undefined;
return record(await response.json()).document;
}
function fieldDocumentRequest(
component: FieldsComponent,
props: Record<string, unknown>,
slotId: string,
documentIdProp: string,
schemaIdProp: string,
): FieldsDocumentRequest | undefined {
const slot = (component.embeddedDocuments || []).find(candidate => String(candidate?.slotId || "") === slotId);
const documentId = firstString(props[documentIdProp], slot?.documentId);
if (!documentId) return undefined;
return {
documentId,
schemaId: firstString(props[schemaIdProp], slot?.schemaId),
presentationKind: firstString(slot?.presentationKind) || "data",
slotId,
};
}
export function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown {
const tuple = Array.isArray(value) ? value : undefined;
const documentSchema = tuple ? tuple[0] : (value as { schema?: unknown } | undefined)?.schema;
@ -134,3 +324,26 @@ function normalizeAsset(value: unknown): unknown {
function array(value: unknown): unknown[] {
return Array.isArray(value) ? value : [];
}
function record(value: unknown): Record<string, unknown> {
return value && typeof value === "object" && !Array.isArray(value) ? value as Record<string, unknown> : {};
}
function firstString(...values: unknown[]): string {
for (const value of values) if (typeof value === "string" && value.trim()) return value.trim();
return "";
}
function stringValue(value: unknown, fallback: string): string {
return typeof value === "string" && value ? value : fallback;
}
function positiveInteger(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) && parsed > 0 ? Math.floor(parsed) : fallback;
}
function numberValue(value: unknown, fallback: number): number {
const parsed = Number(value);
return Number.isFinite(parsed) ? parsed : fallback;
}

View file

@ -1,4 +1,4 @@
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
import { fieldsBrowserAdapter, type EveBrowserPluginAdapter, type FieldsSurfaceDataState } from "./fields-browser-adapter.js";
export { fieldsBrowserAdapter, normalizeFieldsDocument, type EveBrowserPluginAdapter } from "./fields-browser-adapter.js";
@ -382,7 +382,16 @@ export function renderEveComponent(
}
if (kind === "field.surface2d" || kind === "gravity.surface") {
return renderGravitySurface(node, props, layout, style, options);
const adapter = (options.pluginAdapters || defaultBrowserPluginAdapters)
.find(candidate => candidate.componentKinds.includes(kind));
if (!adapter?.renderComponent) {
throw new Error(`No browser plugin adapter can lower ${kind}.`);
}
return adapter.renderComponent(node, props, layout, style, options, {
applyGeneratedLayout,
drawSurface: drawGravitySurface,
providerId: currentSurfaceDocument?.providerId || options.provider?.providerId,
});
}
if (kind === "world.scene3d") {
@ -797,84 +806,6 @@ type GravitySurfaceObject = {
icon: string;
};
type GravitySurfaceDataState = {
renderSplats?: Record<string, unknown>;
gravity?: Record<string, unknown>;
objects?: Record<string, unknown>;
loading?: boolean;
lastError?: string;
lastLoadedAt?: number;
};
const gravitySurfaceDataStates = new WeakMap<HTMLCanvasElement, GravitySurfaceDataState>();
function renderGravitySurface(
node: EveSurfaceComponent,
props: Record<string, unknown>,
layout: Record<string, unknown>,
style: Record<string, unknown>,
options: EveBrowserLoweringOptions,
): HTMLElement {
const canvas = el("canvas", "cultui-gravity-surface") as HTMLCanvasElement;
assignId(canvas, node);
canvas.setAttribute("role", "img");
canvas.setAttribute("aria-label", stringProp(props.label, "gravity surface"));
canvas.style.display = "block";
canvas.style.width = "100%";
canvas.style.height = "100%";
canvas.style.pointerEvents = "none";
applyGeneratedLayout(canvas, layout, style);
let drawQueued = false;
const state: GravitySurfaceDataState = {};
gravitySurfaceDataStates.set(canvas, state);
const draw = () => drawGravitySurface(canvas, props, state);
const scheduleDraw = () => {
if (!canvas.isConnected || drawQueued) return;
drawQueued = true;
requestAnimationFrame(() => {
if (!canvas.isConnected) {
drawQueued = false;
return;
}
drawQueued = false;
draw();
});
};
canvas.addEventListener("cultui:asset-loaded", scheduleDraw);
let refreshTimer = 0;
let observer: ResizeObserver | undefined;
const cleanup = () => {
if (refreshTimer) window.clearInterval(refreshTimer);
observer?.disconnect();
canvas.removeEventListener("cultui:asset-loaded", scheduleDraw);
};
const refreshDocuments = () => {
if (!canvas.isConnected) {
cleanup();
return;
}
void resolveGravitySurfaceDocuments(node, props, options, state)
.then((changed) => {
if (changed && canvas.isConnected) scheduleDraw();
})
.catch((error) => {
state.lastError = error instanceof Error ? error.message : String(error);
});
};
if (typeof ResizeObserver !== "undefined") {
observer = new ResizeObserver(scheduleDraw);
observer.observe(canvas);
}
scheduleDraw();
const refreshMs = Math.max(33, Math.min(1000, positiveInt(props.stateRefreshMs, 100)));
refreshTimer = window.setInterval(refreshDocuments, refreshMs);
queueMicrotask(refreshDocuments);
window.setTimeout(scheduleDraw, 300);
window.setTimeout(scheduleDraw, 1000);
return canvas;
}
export function projectWorldScene(node: EveSurfaceComponent): EveProjectedWorldEntity[] {
const entities = (node.children || []).filter(child => child.kind === "world.entity3d");
const positions = entities.map(entity => vector3Prop(objectProps(entity.props).position, [0, 0, 0]));
@ -1017,95 +948,7 @@ function cssIdentifier(value: string): string {
return value.toLowerCase().replace(/[^a-z0-9_-]+/g, "-");
}
async function resolveGravitySurfaceDocuments(
node: EveSurfaceComponent,
props: Record<string, unknown>,
options: EveBrowserLoweringOptions,
state: GravitySurfaceDataState,
): Promise<boolean> {
if (!options.documentResolver || state.loading) return false;
const requests = [
gravityDocumentRequest(node, props, "renderSplats", "renderSplatsDocumentId", "renderSplatsSchemaId"),
gravityDocumentRequest(node, props, "gravity", "gravityDocumentId", "gravitySchemaId"),
gravityDocumentRequest(node, props, "objects", "objectsDocumentId", "objectsSchemaId"),
].filter((request): request is EveEmbeddedDocumentRequest => Boolean(request?.documentId));
if (requests.length === 0) return false;
state.loading = true;
try {
let changed = false;
for (const request of requests) {
const resolved = await options.documentResolver(request, node) as EveResolvedDocument | undefined;
let document = objectProps(normalizePluginDocument(resolved?.schemaId || request.schemaId, resolved?.document, options));
if (Object.keys(document).length === 0) {
document = objectProps(normalizePluginDocument(request.schemaId, await fetchGravitySurfaceDocument(request, options), options));
}
if (!document || Object.keys(document).length === 0) continue;
const key = request.slotId === "renderSplats"
? "renderSplats"
: request.slotId === "gravity"
? "gravity"
: "objects";
const previousFrame = numberProp((state[key] as Record<string, unknown> | undefined)?.frameId, -1);
const nextFrame = numberProp(document.frameId, previousFrame);
state[key] = document;
changed ||= nextFrame !== previousFrame;
}
state.lastLoadedAt = Date.now();
return changed;
} finally {
state.loading = false;
}
}
function normalizePluginDocument(
schemaId: string | undefined,
value: unknown,
options: EveBrowserLoweringOptions,
): unknown {
const adapters = options.pluginAdapters || defaultBrowserPluginAdapters;
const adapter = adapters.find(candidate => !schemaId || candidate.schemas.includes(schemaId));
return adapter ? adapter.normalizeDocument(schemaId, value) : value;
}
async function fetchGravitySurfaceDocument(
request: EveEmbeddedDocumentRequest,
options: EveBrowserLoweringOptions,
): Promise<unknown> {
if (typeof fetch !== "function" || typeof window === "undefined") return undefined;
const providerId = currentSurfaceDocument?.providerId || options.provider?.providerId || "";
if (!providerId) return undefined;
const params = new URLSearchParams();
params.set("documentId", request.documentId);
if (request.schemaId) params.set("schemaId", request.schemaId);
if (request.slotId) params.set("slotId", request.slotId);
const response = await fetch(`/eir/document/${encodeURIComponent(providerId)}?${params.toString()}`, {
cache: "no-store",
headers: { Accept: "application/json" },
});
if (!response.ok) return undefined;
const payload = await response.json();
return objectProps(payload).document;
}
function gravityDocumentRequest(
component: EveSurfaceComponent,
props: Record<string, unknown>,
slotId: string,
propDocumentId: string,
propSchemaId: string,
): EveEmbeddedDocumentRequest | undefined {
const slot = (component.embeddedDocuments || []).find((candidate) => String(candidate?.slotId || "") === slotId);
const documentId = firstString(props[propDocumentId], slot?.documentId, "");
if (!documentId) return undefined;
return {
documentId,
schemaId: firstString(props[propSchemaId], slot?.schemaId, ""),
presentationKind: firstString(slot?.presentationKind, "data"),
slotId,
};
}
function drawGravitySurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state?: GravitySurfaceDataState): void {
function drawGravitySurface(canvas: HTMLCanvasElement, props: Record<string, unknown>, state?: FieldsSurfaceDataState): void {
const rect = canvas.getBoundingClientRect();
const width = Math.max(1, Math.floor(rect.width || canvas.clientWidth || 1));
const height = Math.max(1, Math.floor(rect.height || canvas.clientHeight || 1));