Move field component lifecycle into browser adapter
This commit is contained in:
parent
fe71b8d2c5
commit
af0e44cfc3
6 changed files with 428 additions and 313 deletions
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
150
packages/eve-browser-lowering/dist/index.js
vendored
150
packages/eve-browser-lowering/dist/index.js
vendored
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
Loading…
Reference in a new issue