diff --git a/docs/eve-migration-roadmap.md b/docs/eve-migration-roadmap.md index 4d80619..ea07f3f 100644 --- a/docs/eve-migration-roadmap.md +++ b/docs/eve-migration-roadmap.md @@ -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 diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts index e131a7e..5d102da 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.d.ts @@ -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, layout: Record, style: Record, options: FieldsLoweringOptions, context: FieldsRuntimeContext) => HTMLElement; } export declare const fieldsBrowserAdapter: EveBrowserPluginAdapter; +export interface FieldsComponent { + id?: string; + kind?: string; + text?: string; + commandId?: string; + props?: Record; + layout?: Record; + style?: Record; + children?: FieldsComponent[]; + embeddedDocuments?: Array>; +} +export interface FieldsResolvedDocument { + document?: unknown; + schemaId?: string; +} +export interface FieldsLoweringOptions { + documentResolver?: (request: FieldsDocumentRequest, component: FieldsComponent) => Promise; + provider?: { + providerId?: string; + }; +} +export interface FieldsDocumentRequest { + documentId: string; + presentationKind?: string; + schemaId?: string; + slotId?: string; +} +export interface FieldsSurfaceDataState { + renderSplats?: Record; + gravity?: Record; + objects?: Record; + loading?: boolean; + lastError?: string; + lastLoadedAt?: number; +} +export interface FieldsRuntimeContext { + applyGeneratedLayout(element: HTMLElement, layout: Record, style: Record): void; + drawSurface(canvas: HTMLCanvasElement, props: Record, state: FieldsSurfaceDataState): void; + providerId?: string; +} export declare function normalizeFieldsDocument(schemaId: string | undefined, value: unknown): unknown; diff --git a/packages/eve-browser-lowering/dist/fields-browser-adapter.js b/packages/eve-browser-lowering/dist/fields-browser-adapter.js index dc21b0b..7c4d136 100644 --- a/packages/eve-browser-lowering/dist/fields-browser-adapter.js +++ b/packages/eve-browser-lowering/dist/fields-browser-adapter.js @@ -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; +} diff --git a/packages/eve-browser-lowering/dist/index.js b/packages/eve-browser-lowering/dist/index.js index 8dbef25..5a55cc1 100644 --- a/packages/eve-browser-lowering/dist/index.js +++ b/packages/eve-browser-lowering/dist/index.js @@ -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)); diff --git a/packages/eve-browser-lowering/src/fields-browser-adapter.ts b/packages/eve-browser-lowering/src/fields-browser-adapter.ts index 2e18eaf..f14e451 100644 --- a/packages/eve-browser-lowering/src/fields-browser-adapter.ts +++ b/packages/eve-browser-lowering/src/fields-browser-adapter.ts @@ -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, + layout: Record, + style: Record, + 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; + layout?: Record; + style?: Record; + children?: FieldsComponent[]; + embeddedDocuments?: Array>; +} + +export interface FieldsResolvedDocument { + document?: unknown; + schemaId?: string; +} + +export interface FieldsLoweringOptions { + documentResolver?: ( + request: FieldsDocumentRequest, + component: FieldsComponent, + ) => Promise; + provider?: { providerId?: string }; +} + +export interface FieldsDocumentRequest { + documentId: string; + presentationKind?: string; + schemaId?: string; + slotId?: string; +} + +export interface FieldsSurfaceDataState { + renderSplats?: Record; + gravity?: Record; + objects?: Record; + loading?: boolean; + lastError?: string; + lastLoadedAt?: number; +} + +export interface FieldsRuntimeContext { + applyGeneratedLayout(element: HTMLElement, layout: Record, style: Record): void; + drawSurface(canvas: HTMLCanvasElement, props: Record, state: FieldsSurfaceDataState): void; + providerId?: string; +} + +function renderFieldsComponent( + component: FieldsComponent, + props: Record, + layout: Record, + style: Record, + 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, + options: FieldsLoweringOptions, + providerId: string | undefined, + state: FieldsSurfaceDataState, +): Promise { + 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 { + 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, + 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 { + return value && typeof value === "object" && !Array.isArray(value) ? value as Record : {}; +} + +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; +} diff --git a/packages/eve-browser-lowering/src/index.ts b/packages/eve-browser-lowering/src/index.ts index 342371f..d2568fa 100644 --- a/packages/eve-browser-lowering/src/index.ts +++ b/packages/eve-browser-lowering/src/index.ts @@ -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; - gravity?: Record; - objects?: Record; - loading?: boolean; - lastError?: string; - lastLoadedAt?: number; -}; - -const gravitySurfaceDataStates = new WeakMap(); - -function renderGravitySurface( - node: EveSurfaceComponent, - props: Record, - layout: Record, - style: Record, - 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, - options: EveBrowserLoweringOptions, - state: GravitySurfaceDataState, -): Promise { - 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 | 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 { - 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, - 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, state?: GravitySurfaceDataState): void { +function drawGravitySurface(canvas: HTMLCanvasElement, props: Record, 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));