@zixcel/dom-renderer 0.10.4 → 0.10.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +7 -1
- package/package.json +7 -5
- package/src/book-reader.d.mts +9 -1
- package/src/book-reader.mjs +54 -33
- package/src/dom-elements.d.mts +11 -0
- package/src/dom-elements.mjs +48 -0
- package/src/frame-layout.d.mts +9 -3
- package/src/frame-layout.mjs +22 -22
- package/src/frame.d.mts +22 -10
- package/src/frame.mjs +93 -45
- package/src/geometry-policy.d.mts +3 -0
- package/src/geometry-policy.mjs +17 -0
- package/src/graph-documents.d.mts +48 -0
- package/src/graph-documents.mjs +40 -0
- package/src/graphics-disposal.d.mts +2 -0
- package/src/graphics-disposal.mjs +14 -0
- package/src/index.d.mts +11 -57
- package/src/index.mjs +9 -205
- package/src/input-form.d.mts +5 -0
- package/src/input-form.mjs +103 -0
- package/src/inspection-overview.d.mts +27 -1
- package/src/inspection-overview.mjs +50 -33
- package/src/npc-motion.d.mts +1 -1
- package/src/npc-motion.mjs +5 -5
- package/src/object-presentation.d.mts +22 -26
- package/src/object-presentation.mjs +20 -17
- package/src/open-world-projection.d.mts +19 -10
- package/src/open-world-projection.mjs +31 -22
- package/src/reconcile.d.mts +1 -1
- package/src/reconcile.mjs +31 -19
- package/src/scene-items.d.mts +9 -0
- package/src/scene-items.mjs +29 -0
- package/src/scene-renderer.d.mts +37 -0
- package/src/scene-renderer.mjs +170 -0
- package/src/spatial-patterns.d.mts +22 -27
- package/src/spatial-patterns.mjs +63 -41
- package/src/spatial.d.mts +17 -6
- package/src/spatial.mjs +39 -25
- package/src/structure-graph.d.mts +5 -13
- package/src/structure-graph.mjs +167 -96
- package/src/symbol.d.mts +1 -1
- package/src/symbol.mjs +21 -16
- package/src/world-api.d.mts +56 -0
- package/src/world-api.mjs +8 -0
- package/src/world-camera.d.mts +14 -0
- package/src/world-camera.mjs +74 -0
- package/src/world-clock.d.mts +19 -0
- package/src/world-clock.mjs +30 -0
- package/src/world-commands.d.mts +39 -0
- package/src/world-commands.mjs +1 -0
- package/src/world-context.d.mts +132 -0
- package/src/world-context.mjs +1 -0
- package/src/world-controller.d.mts +43 -0
- package/src/world-controller.mjs +62 -0
- package/src/world-conversation.d.mts +3 -0
- package/src/world-conversation.mjs +46 -0
- package/src/world-events.d.mts +2 -0
- package/src/world-events.mjs +120 -0
- package/src/world-gpu.d.mts +3 -15
- package/src/world-gpu.mjs +159 -74
- package/src/world-input.d.mts +2 -2
- package/src/world-input.mjs +28 -18
- package/src/world-lifecycle.d.mts +12 -0
- package/src/world-lifecycle.mjs +172 -0
- package/src/world-model.d.mts +29 -40
- package/src/world-model.mjs +103 -74
- package/src/world-projection.d.mts +5 -0
- package/src/world-projection.mjs +169 -0
- package/src/world-render-loop.d.mts +9 -0
- package/src/world-render-loop.mjs +110 -0
- package/src/world-resources.d.mts +34 -0
- package/src/world-resources.mjs +8 -0
- package/src/world-runtime.d.mts +2 -36
- package/src/world-runtime.mjs +6 -311
- package/src/world-setup.d.mts +2 -0
- package/src/world-setup.mjs +179 -0
- package/src/world-surface.d.mts +2 -7
- package/src/world-surface.mjs +20 -12
- package/src/world-types.d.mts +74 -0
- package/src/world-types.mjs +1 -0
|
@@ -1,24 +1,27 @@
|
|
|
1
1
|
// Trusted presentation policy, not semantic classification or operation authority.
|
|
2
2
|
// Never infer meaning from a title, locator or a numeric magnitude.
|
|
3
|
-
export const spacingPresets=Object.freeze({compact:{person:5,group:3,member:1.3},balanced:{person:7,group:4,member:1.8},spacious:{person:10,group:6,member:2.6}})
|
|
4
|
-
export const objectForms=Object.freeze({person:'Person',organization:'Organization',system:'System',shelf:'Bookshelf',book:'Book'})
|
|
3
|
+
export const spacingPresets = Object.freeze({ compact: { person: 5, group: 3, member: 1.3 }, balanced: { person: 7, group: 4, member: 1.8 }, spacious: { person: 10, group: 6, member: 2.6 } });
|
|
4
|
+
export const objectForms = Object.freeze({ person: 'Person', organization: 'Organization', system: 'System', shelf: 'Bookshelf', book: 'Book' });
|
|
5
5
|
// Temporary placement policy. Furniture is derived from nonempty view groups,
|
|
6
6
|
// not persisted as semantic data. A page occupies six columns × four shelves.
|
|
7
|
-
export const libraryLayout=Object.freeze({columns:6,rows:4,slotWidth
|
|
8
|
-
export const objectBounds=Object.freeze({person:[1.5,2.5,1.1],organization:[2.8,3.2,2],system:[1.8,2,1],shelf:[3.4,3.2
|
|
7
|
+
export const libraryLayout = Object.freeze({ columns: 6, rows: 4, slotWidth: .46, rowHeight: .63, baseY: .32, frontZ: .32 });
|
|
8
|
+
export const objectBounds = Object.freeze({ person: [1.5, 2.5, 1.1], organization: [2.8, 3.2, 2], system: [1.8, 2, 1], shelf: [3.4, 3.2, .9], book: [.4, .62, .6] });
|
|
9
9
|
// Each part is [x,y,z,width,height,depth,tint]. Local coordinates and closed
|
|
10
10
|
// tint tokens keep assets declarative: no external scripts, images or model IO.
|
|
11
|
-
export const objectTemplates=Object.freeze({
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
})
|
|
18
|
-
export function objectPresentation(item){
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
11
|
+
export const objectTemplates = Object.freeze({
|
|
12
|
+
person: [[-.2, .48, 0, .28, .8, .34, 'dark'], [.2, .48, 0, .28, .8, .34, 'dark'], [0, 1.26, 0, .88, .86, .48, 'accent'], [-.59, 1.22, 0, .25, .9, .3, 'accent'], [.59, 1.22, 0, .25, .9, .3, 'accent'], [0, 2, 0, .62, .62, .58, 'face'], [0, 2.34, 0, .68, .15, .63, 'dark'], [-.15, 2.04, .302, .075, .08, .025, 'dark'], [.15, 2.04, .302, .075, .08, .025, 'dark']],
|
|
13
|
+
organization: [[0, 1.55, 0, 2.6, 3, 1.8, 'accent'], [0, 3.1, 0, 2.8, .2, 2, 'dark'], [-.6, 1.8, .92, .55, .65, .08, 'light'], [.6, 1.8, .92, .55, .65, .08, 'light'], [0, .5, .94, .7, .95, .08, 'dark']],
|
|
14
|
+
system: [[0, .25, 0, 1.5, .5, .9, 'dark'], [0, 1.2, 0, 1.4, 1.5, .3, 'accent'], [0, 1.35, .18, 1.1, 1, .05, 'light'], [0, .6, .45, 1.2, .1, .15, 'warning']],
|
|
15
|
+
shelf: [[-1.55, 1.5, 0, .15, 3, .65, 'dark'], [1.55, 1.5, 0, .15, 3, .65, 'dark'], [0, 1.5, -.32, 3.25, 3, .08, 'accent'], ...Array.from({ length: 5 }, (_, i) => [0, .25 + i * .63, 0, 3.25, .1, .7, 'dark'])],
|
|
16
|
+
book: [[-.15, .3, 0, .05, .58, .5, 'accent'], [.15, .3, 0, .05, .58, .5, 'accent'], [0, .3, -.22, .3, .58, .06, 'accent'], [0, .3, .025, .24, .5, .39, 'light']]
|
|
17
|
+
});
|
|
18
|
+
export function objectPresentation(item) {
|
|
19
|
+
if (item.form && Object.hasOwn(objectForms, item.form))
|
|
20
|
+
return { form: item.form };
|
|
21
|
+
if (item.kind === 'character')
|
|
22
|
+
return { form: 'person' };
|
|
23
|
+
if (item.kind === 'group')
|
|
24
|
+
return { form: 'shelf' };
|
|
25
|
+
const values = item.scalars ?? [], types = [...new Set(values.filter(v => v !== null).map(v => typeof v))];
|
|
26
|
+
return { form: 'book', dataType: types.length === 1 ? (types[0] === 'string' ? 'text' : types[0] === 'number' ? 'number' : types[0] === 'boolean' ? 'boolean' : 'record') : 'record', needsReview: Boolean(item.unresolved) };
|
|
24
27
|
}
|
|
@@ -1,20 +1,29 @@
|
|
|
1
|
-
export
|
|
1
|
+
export interface ProjectionPoint {
|
|
2
|
+
readonly id: string;
|
|
3
|
+
readonly x: number;
|
|
4
|
+
readonly z: number;
|
|
5
|
+
readonly title?: string;
|
|
6
|
+
readonly kind?: string;
|
|
7
|
+
}
|
|
8
|
+
export interface NearbyOptions {
|
|
2
9
|
x?: number;
|
|
3
10
|
z?: number;
|
|
4
11
|
radius?: number;
|
|
5
12
|
limit?: number;
|
|
6
|
-
marked?:
|
|
7
|
-
}
|
|
8
|
-
|
|
9
|
-
|
|
13
|
+
marked?: string | null;
|
|
14
|
+
}
|
|
15
|
+
export interface MapOptions {
|
|
16
|
+
selected?: string | null;
|
|
17
|
+
marked?: string | null;
|
|
18
|
+
limit?: number;
|
|
19
|
+
}
|
|
20
|
+
export declare function visibleProjection<T extends ProjectionPoint>(sources: readonly T[], { x, z, radius, limit, marked }?: NearbyOptions): {
|
|
21
|
+
items: T[];
|
|
22
|
+
markers: T[];
|
|
10
23
|
total: number;
|
|
11
24
|
omitted: number;
|
|
12
25
|
};
|
|
13
|
-
export function mapProjection(sources:
|
|
14
|
-
selected?: any;
|
|
15
|
-
marked?: any;
|
|
16
|
-
limit?: number;
|
|
17
|
-
}): {
|
|
26
|
+
export declare function mapProjection<T extends ProjectionPoint>(sources: readonly T[], { selected, marked, limit }?: MapOptions): {
|
|
18
27
|
items: {
|
|
19
28
|
id: any;
|
|
20
29
|
title: any;
|
|
@@ -1,26 +1,35 @@
|
|
|
1
1
|
// Read-only, bounded views over exact owner IDs. Coordinates are presentation,
|
|
2
2
|
// never evidence of a semantic relation or an action authority.
|
|
3
|
-
export function visibleProjection(sources,{x=0,z=0,radius=24,limit=200,marked=null}={}){
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
3
|
+
export function visibleProjection(sources, { x = 0, z = 0, radius = 24, limit = 200, marked = null } = {}) {
|
|
4
|
+
if (!Array.isArray(sources) || sources.length > 10000 || !Number.isFinite(x) || !Number.isFinite(z)
|
|
5
|
+
|| !Number.isFinite(radius) || radius <= 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 200)
|
|
6
|
+
throw Error('WorldProjectionLimitExceeded');
|
|
7
|
+
const candidates = [], seen = new Set();
|
|
8
|
+
let mark = null;
|
|
9
|
+
for (const source of sources) {
|
|
10
|
+
if (typeof source.id !== 'string' || !source.id || seen.has(source.id) || !Number.isFinite(source.x) || !Number.isFinite(source.z))
|
|
11
|
+
throw Error('WorldProjectionSourceInvalid');
|
|
12
|
+
seen.add(source.id);
|
|
13
|
+
const dx = source.x - x, dz = source.z - z, distance = dx * dx + dz * dz;
|
|
14
|
+
if (distance <= radius * radius)
|
|
15
|
+
candidates.push({ source, distance });
|
|
16
|
+
if (source.id === marked)
|
|
17
|
+
mark = source;
|
|
18
|
+
}
|
|
19
|
+
candidates.sort((a, b) => a.distance - b.distance || a.source.id.localeCompare(b.source.id));
|
|
20
|
+
const items = candidates.slice(0, limit).map(({ source }) => source);
|
|
21
|
+
return { items, markers: mark && !items.some(item => item.id === mark?.id) ? [mark] : [], total: sources.length, omitted: candidates.length - items.length };
|
|
17
22
|
}
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
23
|
+
export function mapProjection(sources, { selected = null, marked = null, limit = 128 } = {}) {
|
|
24
|
+
if (!Array.isArray(sources) || sources.length > 10000 || !Number.isSafeInteger(limit) || limit < 1 || limit > 128)
|
|
25
|
+
throw Error('WorldProjectionLimitExceeded');
|
|
26
|
+
const seen = new Set();
|
|
27
|
+
for (const item of sources) {
|
|
28
|
+
if (typeof item.id !== 'string' || !item.id || seen.has(item.id) || !Number.isFinite(item.x) || !Number.isFinite(item.z))
|
|
29
|
+
throw Error('WorldProjectionSourceInvalid');
|
|
30
|
+
seen.add(item.id);
|
|
31
|
+
}
|
|
32
|
+
const priority = sources.filter(item => item.id === selected || item.id === marked), base = sources.filter(item => item.id !== selected && item.id !== marked);
|
|
33
|
+
const chosen = sources.length <= limit ? sources : [...priority, ...base].slice(0, limit);
|
|
34
|
+
return { items: chosen.map(item => ({ id: item.id, title: item.title, kind: item.kind, x: item.x, z: item.z, selected: item.id === selected, marked: item.id === marked })), total: sources.length, omitted: sources.length - chosen.length };
|
|
26
35
|
}
|
package/src/reconcile.d.mts
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
export function reconcileChildren(parent:
|
|
1
|
+
export declare function reconcileChildren(parent: Node, children: Iterable<Node>): void;
|
package/src/reconcile.mjs
CHANGED
|
@@ -1,21 +1,33 @@
|
|
|
1
1
|
// Trusted renderer output only. No user forms or independently owned DOM trees.
|
|
2
|
-
const
|
|
3
|
-
|
|
4
|
-
:node.
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
2
|
+
const isElement = (node) => node.nodeType === 1;
|
|
3
|
+
const key = (node, index) => isElement(node)
|
|
4
|
+
? [node.tagName, ...['data-item-id', 'data-world-object', 'data-region', 'data-control'].flatMap(k => node.hasAttribute(k) ? [k, node.getAttribute(k)] : [])].join(':') + (node.matches('[data-item-id],[data-world-object],[data-region],[data-control]') ? '' : ':' + index)
|
|
5
|
+
: node.nodeType + ':' + index;
|
|
6
|
+
export function reconcileChildren(parent, children) {
|
|
7
|
+
const old = new Map([...parent.childNodes].map((node, index) => [key(node, index), node])), wanted = [];
|
|
8
|
+
for (const [index, fresh] of [...children].entries()) {
|
|
9
|
+
const identity = key(fresh, index), existing = old.get(identity);
|
|
10
|
+
if (existing && existing.nodeType === fresh.nodeType && existing.nodeName === fresh.nodeName) {
|
|
11
|
+
old.delete(identity);
|
|
12
|
+
if (isElement(existing) && isElement(fresh)) {
|
|
13
|
+
for (const attribute of [...existing.attributes])
|
|
14
|
+
if (!fresh.hasAttribute(attribute.name))
|
|
15
|
+
existing.removeAttribute(attribute.name);
|
|
16
|
+
for (const attribute of fresh.attributes)
|
|
17
|
+
if (existing.getAttribute(attribute.name) !== attribute.value)
|
|
18
|
+
existing.setAttribute(attribute.name, attribute.value);
|
|
19
|
+
reconcileChildren(existing, fresh.childNodes);
|
|
20
|
+
}
|
|
21
|
+
else if (existing.nodeValue !== fresh.nodeValue)
|
|
22
|
+
existing.nodeValue = fresh.nodeValue;
|
|
23
|
+
wanted.push(existing);
|
|
24
|
+
}
|
|
25
|
+
else
|
|
26
|
+
wanted.push(fresh);
|
|
27
|
+
}
|
|
28
|
+
for (const node of old.values())
|
|
29
|
+
node.parentNode?.removeChild(node);
|
|
30
|
+
for (let index = 0; index < wanted.length; index++)
|
|
31
|
+
if (parent.childNodes[index] !== wanted[index])
|
|
32
|
+
parent.insertBefore(wanted[index], parent.childNodes[index] ?? null);
|
|
21
33
|
}
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import type { RenderItem, RenderPlan } from '@zixcel/projection-client';
|
|
2
|
+
export interface SceneGroup {
|
|
3
|
+
id: string;
|
|
4
|
+
role: string;
|
|
5
|
+
items: readonly RenderItem[];
|
|
6
|
+
}
|
|
7
|
+
export type SceneButton = (label: string, kind: string, id: string, attrs?: Record<string, unknown>) => HTMLButtonElement;
|
|
8
|
+
export declare const reasonOf: (item: RenderItem) => string | undefined;
|
|
9
|
+
export declare function appendSceneItems(document: Document, board: HTMLElement, groups: readonly SceneGroup[], plan: RenderPlan, button: SceneButton): void;
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
import { element } from './dom-elements.mjs';
|
|
2
|
+
import { symbol } from './symbol.mjs';
|
|
3
|
+
export const reasonOf = (item) => 'reason' in item.content ? item.content.reason : undefined;
|
|
4
|
+
export function appendSceneItems(document, board, groups, plan, button) {
|
|
5
|
+
for (const group of groups) {
|
|
6
|
+
const region = element(document, 'section', null, { 'data-region': group.id }), list = element(document, 'ul', null, { class: 'scene-items' });
|
|
7
|
+
region.append(element(document, 'h2', group.role), list);
|
|
8
|
+
for (const item of group.items) {
|
|
9
|
+
const row = element(document, 'li', null, { 'data-item-id': item.id, 'data-selected': item.selected, 'data-related': item.related, 'data-focused': item.focused }), toolbar = element(document, 'div', null, { class: 'item-tools' });
|
|
10
|
+
const itemButton = (label, kind, attrs = {}) => button(label, kind, item.id, { 'data-control': JSON.stringify([group.id, kind, item.id]), ...attrs });
|
|
11
|
+
toolbar.append(itemButton(item.display?.title ?? reasonOf(item) ?? 'Display not configured', 'select', { 'aria-pressed': item.selected }), itemButton('View details', 'inspect', { 'aria-expanded': plan.inspector?.id === item.id, 'aria-haspopup': 'dialog' }));
|
|
12
|
+
if (reasonOf(item))
|
|
13
|
+
toolbar.append(itemButton('Review', 'resolve'));
|
|
14
|
+
const solid = element(document, 'span', null, { class: 'object-solid', 'aria-hidden': 'true' });
|
|
15
|
+
solid.append(symbol(document, item.display?.symbol ?? 'nodes'));
|
|
16
|
+
row.append(solid, toolbar);
|
|
17
|
+
if (item.display?.summary)
|
|
18
|
+
row.append(element(document, 'p', item.display.summary, { 'data-item-summary': item.id }));
|
|
19
|
+
const fields = element(document, 'dl', null, { class: 'object-fields' });
|
|
20
|
+
for (const field of item.display?.fields ?? [])
|
|
21
|
+
fields.append(element(document, 'dt', field.label), element(document, 'dd', field.value === null ? 'Not set' : String(field.value), { 'data-projection-scalar': JSON.stringify(field.value) }));
|
|
22
|
+
row.append(fields);
|
|
23
|
+
if (!item.display)
|
|
24
|
+
row.append(element(document, 'p', reasonOf(item) ? 'This information is not yet resolved. Review the available choices or the missing requirements.' : 'Display information is not configured. Inspect this object in System.'));
|
|
25
|
+
list.append(row);
|
|
26
|
+
}
|
|
27
|
+
board.append(region);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import type { RenderPlan, ActionHandle, Camera } from '@zixcel/projection-client';
|
|
2
|
+
import type { WorldSurface } from './world-api.mjs';
|
|
3
|
+
export interface SceneActor {
|
|
4
|
+
id: string;
|
|
5
|
+
title: string;
|
|
6
|
+
status?: string;
|
|
7
|
+
preparation?: {
|
|
8
|
+
label: string;
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export interface SceneGarden {
|
|
12
|
+
host: HTMLElement;
|
|
13
|
+
actors: readonly SceneActor[];
|
|
14
|
+
activity?: unknown;
|
|
15
|
+
}
|
|
16
|
+
export interface SceneSlots {
|
|
17
|
+
tools?: HTMLElement;
|
|
18
|
+
details?: HTMLElement;
|
|
19
|
+
actions?: HTMLElement;
|
|
20
|
+
describeUnresolved?: (reason: string) => {
|
|
21
|
+
label: string;
|
|
22
|
+
detail: string;
|
|
23
|
+
};
|
|
24
|
+
}
|
|
25
|
+
export type SceneEvents = (kind: string, value: string | ActionHandle | undefined) => void;
|
|
26
|
+
export declare class SceneRenderer {
|
|
27
|
+
#private;
|
|
28
|
+
constructor(root: HTMLElement, onEvent: SceneEvents, slots?: SceneSlots, surface?: WorldSurface | null);
|
|
29
|
+
render(plan: RenderPlan): void;
|
|
30
|
+
camera(value: Camera): void;
|
|
31
|
+
speaker(value: SceneActor | null): void;
|
|
32
|
+
garden(value: SceneGarden | null): void;
|
|
33
|
+
activity(value: unknown): void;
|
|
34
|
+
changeCamera(action: 'in' | 'out' | 'up' | 'down' | 'left' | 'right' | 'reset'): void;
|
|
35
|
+
close(): void;
|
|
36
|
+
dispose(): void;
|
|
37
|
+
}
|
|
@@ -0,0 +1,170 @@
|
|
|
1
|
+
import { disposeGraphics } from './graphics-disposal.mjs';
|
|
2
|
+
import { element, fail } from './dom-elements.mjs';
|
|
3
|
+
import { spatialView } from './spatial.mjs';
|
|
4
|
+
import { reasonOf, appendSceneItems } from './scene-items.mjs';
|
|
5
|
+
import { reconcileChildren } from './reconcile.mjs';
|
|
6
|
+
import { createWorldSurface } from './world-surface.mjs';
|
|
7
|
+
import { bookReader } from './book-reader.mjs';
|
|
8
|
+
export class SceneRenderer {
|
|
9
|
+
#root;
|
|
10
|
+
#events;
|
|
11
|
+
#abort = null;
|
|
12
|
+
#actionHandles = new Map();
|
|
13
|
+
#spatial = null;
|
|
14
|
+
#slots;
|
|
15
|
+
#pose = null;
|
|
16
|
+
#poseKey = null;
|
|
17
|
+
#speaker = null;
|
|
18
|
+
#surface;
|
|
19
|
+
#ownsSurface;
|
|
20
|
+
#garden = null;
|
|
21
|
+
constructor(root, onEvent, slots = {}, surface = null) { this.#root = root; this.#events = onEvent; this.#slots = slots; this.#surface = surface ?? createWorldSurface(); this.#ownsSurface = !surface; }
|
|
22
|
+
render(plan) {
|
|
23
|
+
const root = this.#root, document = root.ownerDocument, focused = document.activeElement instanceof document.defaultView.HTMLElement ? document.activeElement.dataset.control : undefined;
|
|
24
|
+
const previous = root.querySelector('[data-render-key]'), oldViewport = root.querySelector('.scene-viewport');
|
|
25
|
+
const scroll = previous?.dataset.renderKey === plan.projectionKey && previous.dataset.viewMode === plan.viewMode && oldViewport
|
|
26
|
+
? { left: oldViewport.scrollLeft, top: oldViewport.scrollTop } : null;
|
|
27
|
+
if (this.#poseKey === plan.projectionKey)
|
|
28
|
+
this.#pose = this.#spatial?.snapshot() ?? this.#pose;
|
|
29
|
+
else
|
|
30
|
+
this.#pose = null;
|
|
31
|
+
this.#poseKey = plan.projectionKey;
|
|
32
|
+
this.#spatial?.close();
|
|
33
|
+
this.#spatial = null;
|
|
34
|
+
this.#abort?.abort();
|
|
35
|
+
this.#abort = new AbortController();
|
|
36
|
+
this.#actionHandles.clear();
|
|
37
|
+
if (!['spatial', 'structured'].includes(plan.viewMode) || !Array.isArray(plan.relations) || plan.relations.length > 512)
|
|
38
|
+
fail('InvalidRenderPlan');
|
|
39
|
+
const section = element(document, 'section', null, { 'data-render-revision': plan.projectionRevision, 'data-render-key': plan.projectionKey, 'data-view-mode': plan.viewMode, 'aria-label': 'Scene content', tabindex: '-1', class: 'semantic-scene' });
|
|
40
|
+
const button = (label, kind, id, attrs = {}) => element(document, 'button', label, { type: 'button', 'data-kind': kind, 'data-control': JSON.stringify([kind, id]), 'data-target': id, ...attrs });
|
|
41
|
+
const views = element(document, 'div', null, { role: 'group', 'aria-label': 'Scene view', class: 'scene-view-controls' });
|
|
42
|
+
for (const [mode, label] of [['spatial', 'Spatial view'], ['structured', 'List view']])
|
|
43
|
+
views.append(button(label, 'view', mode, { 'aria-pressed': plan.viewMode === mode }));
|
|
44
|
+
const tools = this.#slots.tools ?? section;
|
|
45
|
+
tools.replaceChildren();
|
|
46
|
+
tools.append(views);
|
|
47
|
+
const viewport = element(document, 'div', null, { class: 'scene-viewport', tabindex: '0', 'aria-label': 'Explore information in this space' }), board = element(document, 'div', null, { class: 'spatial-board' });
|
|
48
|
+
for (const person of this.#garden?.actors ?? (this.#speaker ? [this.#speaker] : [])) {
|
|
49
|
+
const actor = button('', this.#garden ? 'actor' : 'conversation', person.id, { 'data-world-object': person.id, 'data-selected': person.id === this.#speaker?.id, 'data-observation-label': person.preparation?.label ?? '' });
|
|
50
|
+
actor.append(element(document, 'strong', person.title));
|
|
51
|
+
actor.disabled = person.status !== undefined && person.status !== 'Available';
|
|
52
|
+
board.append(actor);
|
|
53
|
+
}
|
|
54
|
+
const camera = element(document, 'div', null, { role: 'group', 'aria-label': 'View position', class: 'scene-camera' });
|
|
55
|
+
for (const [id, label, icon] of [['left', 'Move left', '←'], ['right', 'Move right', '→'], ['in', 'Zoom in', '+'], ['out', 'Zoom out', '−'], ['reset', 'View all', '⛶']])
|
|
56
|
+
camera.append(button(icon, 'camera', id, { 'aria-label': label, title: label }));
|
|
57
|
+
if (plan.viewMode === 'spatial')
|
|
58
|
+
tools.append(camera);
|
|
59
|
+
viewport.append(board);
|
|
60
|
+
(plan.viewMode === 'structured' ? tools : section).append(viewport);
|
|
61
|
+
if (plan.empty)
|
|
62
|
+
section.append(element(document, 'p', 'No projected information is available.', { role: 'status' }));
|
|
63
|
+
if (plan.truncated)
|
|
64
|
+
section.append(element(document, 'p', 'The source reports incomplete information.', { role: 'status' }));
|
|
65
|
+
const groups = [...plan.regions, ...(plan.unplaced.length ? [{ id: 'unplaced', role: 'Context', items: plan.unplaced }] : [])];
|
|
66
|
+
if (groups.length > 17 || plan.actions.length > 64)
|
|
67
|
+
fail('RenderLimitExceeded');
|
|
68
|
+
appendSceneItems(document, board, groups, plan, button);
|
|
69
|
+
const time = element(document, 'section', null, { 'aria-label': 'Scene time', class: 'scene-time' });
|
|
70
|
+
time.append(element(document, 'h2', plan.temporal?.label ?? 'Time'));
|
|
71
|
+
if (plan.temporal?.items.length) {
|
|
72
|
+
time.append(button('All times', 'time', '', { 'aria-pressed': plan.temporal.cursor === null }));
|
|
73
|
+
for (const item of plan.temporal.items) {
|
|
74
|
+
const control = button(item.at + ' · ' + item.itemId, 'time', item.itemId, { 'aria-pressed': plan.temporal.cursor === item.itemId });
|
|
75
|
+
time.append(control);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
else
|
|
79
|
+
time.append(element(document, 'p', 'No event time has been declared by this source.', { role: 'status' }));
|
|
80
|
+
tools.append(time);
|
|
81
|
+
if (plan.relations.length) {
|
|
82
|
+
const relations = element(document, 'details', null, { 'aria-label': 'Relationships' }), list = element(document, 'ul', null, { class: 'scene-relations' });
|
|
83
|
+
relations.append(element(document, 'summary', 'Relationships (' + plan.relations.length + ')'), list);
|
|
84
|
+
for (const relation of plan.relations) {
|
|
85
|
+
const row = element(document, 'li', null, { 'data-relation-id': relation.id, 'data-active': relation.active });
|
|
86
|
+
row.append(button(relation.from, 'select', relation.from, { 'data-control': JSON.stringify(['relation', relation.id, 'from']), 'aria-label': 'Select related ' + relation.from }), element(document, 'span', '→ ' + relation.relationRef + ' →'), button(relation.to, 'select', relation.to, { 'data-control': JSON.stringify(['relation', relation.id, 'to']), 'aria-label': 'Select related ' + relation.to }));
|
|
87
|
+
list.append(row);
|
|
88
|
+
}
|
|
89
|
+
tools.append(relations);
|
|
90
|
+
}
|
|
91
|
+
const actions = this.#slots.actions ?? tools;
|
|
92
|
+
if (this.#slots.actions)
|
|
93
|
+
actions.replaceChildren();
|
|
94
|
+
// Unresolved owner observations are conversation material too, including
|
|
95
|
+
// when no executable choice exists. This does not manufacture an action.
|
|
96
|
+
const pending = groups.flatMap(group => group.items).filter(item => reasonOf(item));
|
|
97
|
+
if (pending.length) {
|
|
98
|
+
const questions = element(document, 'section', null, { 'aria-label': 'Information needing clarification' });
|
|
99
|
+
questions.append(element(document, 'h3', 'Why this information needs review'));
|
|
100
|
+
for (const item of pending) {
|
|
101
|
+
const row = element(document, 'div', null, { 'data-unresolved-ref': item.id });
|
|
102
|
+
const reason = reasonOf(item);
|
|
103
|
+
if (!reason)
|
|
104
|
+
continue;
|
|
105
|
+
const explanation = this.#slots.describeUnresolved?.(reason) ?? { label: String(reasonOf(item)), detail: 'The source owner reported this unresolved reason.' };
|
|
106
|
+
row.append(button(item.display?.title ?? explanation.label, 'resolve', item.id), element(document, 'small', explanation.detail));
|
|
107
|
+
questions.append(row);
|
|
108
|
+
}
|
|
109
|
+
actions.append(questions);
|
|
110
|
+
}
|
|
111
|
+
for (const action of plan.actions) {
|
|
112
|
+
this.#actionHandles.set(action.handle.actionId, action.handle);
|
|
113
|
+
actions.append(button(action.descriptor.label, 'action', action.handle.actionId));
|
|
114
|
+
}
|
|
115
|
+
const lastBook = (this.#slots.details ?? root).querySelector('[data-book-id]'), bookPosition = lastBook ? { id: lastBook.dataset.bookId, revision: lastBook.dataset.bookRevision, page: Number(lastBook.dataset.bookPage) } : null;
|
|
116
|
+
this.#slots.details?.replaceChildren();
|
|
117
|
+
if (plan.inspector) {
|
|
118
|
+
const inspector = plan.inspector;
|
|
119
|
+
const chosen = groups.flatMap(r => r.items).find(i => i.id === inspector.id);
|
|
120
|
+
if (chosen)
|
|
121
|
+
(this.#slots.details ?? section).append(bookReader(document, chosen, plan.projectionRevision, button('Close book', 'dismiss', plan.inspector.id), this.#abort.signal, bookPosition));
|
|
122
|
+
}
|
|
123
|
+
reconcileChildren(root, [section]);
|
|
124
|
+
const activeViewport = plan.viewMode === 'spatial' ? root.querySelector('.scene-viewport') : viewport, activeBoard = activeViewport.querySelector('.spatial-board');
|
|
125
|
+
if (!activeViewport || !activeBoard)
|
|
126
|
+
fail('InvalidRenderPlan');
|
|
127
|
+
if (plan.viewMode === 'spatial' || this.#garden)
|
|
128
|
+
this.#spatial = spatialView(this.#garden?.host ?? activeViewport, activeBoard, plan.camera, plan.relations, this.#abort.signal, this.#pose, null, {
|
|
129
|
+
surface: this.#surface, activity: this.#garden?.activity, target: this.#garden ? 'garden' : plan.projectionKey, context: this.#garden ? plan.projectionKey : null, pattern: this.#garden ? 'garden' : 'constellation', items: new Map(groups.flatMap(region => region.items.map(item => [item.id, {
|
|
130
|
+
anchor: this.#speaker?.id, group: 'group' in item.content ? item.content.group : null, semanticRef: 'semanticRef' in item.content ? item.content.semanticRef : null, sourceRefs: item.content.sourceRefs, scalars: (item.display?.fields ?? []).map(f => f.value), unresolved: Boolean(reasonOf(item)), changeKey: JSON.stringify(item.display?.fields ?? [])
|
|
131
|
+
}])))
|
|
132
|
+
});
|
|
133
|
+
if (this.#garden && plan.viewMode === 'structured')
|
|
134
|
+
activeBoard.hidden = false;
|
|
135
|
+
for (const host of new Set([root, this.#slots.tools, this.#slots.details, this.#slots.actions].filter((host) => Boolean(host))))
|
|
136
|
+
host.addEventListener('click', event => {
|
|
137
|
+
const target = (event.target instanceof document.defaultView.Element ? event.target.closest('button[data-kind]') : null);
|
|
138
|
+
if (!target || !host.contains(target) || !target.dataset.kind)
|
|
139
|
+
return;
|
|
140
|
+
if (target.dataset.kind === 'camera') {
|
|
141
|
+
this.#spatial?.change(target.dataset.target ?? '');
|
|
142
|
+
return;
|
|
143
|
+
}
|
|
144
|
+
this.#events(target.dataset.kind, target.dataset.kind === 'action' ? this.#actionHandles.get(target.dataset.target ?? '') : target.dataset.target);
|
|
145
|
+
}, { signal: this.#abort.signal });
|
|
146
|
+
if (focused)
|
|
147
|
+
[...root.querySelectorAll('[data-control]')].find(n => n.dataset.control === focused)?.focus({ preventScroll: true });
|
|
148
|
+
if (scroll) {
|
|
149
|
+
viewport.scrollLeft = scroll.left;
|
|
150
|
+
viewport.scrollTop = scroll.top;
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
camera(value) { this.#spatial?.apply(value); }
|
|
154
|
+
speaker(value) { this.#speaker = value; }
|
|
155
|
+
garden(value) { this.#garden = value; }
|
|
156
|
+
activity(value) { this.#spatial?.activity(value); }
|
|
157
|
+
changeCamera(action) { this.#spatial?.change(action); }
|
|
158
|
+
close() {
|
|
159
|
+
const spatial = this.#spatial, abort = this.#abort, surface = this.#ownsSurface ? this.#surface : null;
|
|
160
|
+
this.#spatial = null;
|
|
161
|
+
this.#abort = null;
|
|
162
|
+
this.#actionHandles.clear();
|
|
163
|
+
if (this.#ownsSurface)
|
|
164
|
+
this.#surface = createWorldSurface();
|
|
165
|
+
disposeGraphics([() => spatial?.close(), () => abort?.abort(), () => this.#root.replaceChildren(),
|
|
166
|
+
...[this.#slots.tools, this.#slots.details, this.#slots.actions].filter((slot) => Boolean(slot)).map(slot => () => slot.replaceChildren()), () => surface?.close()]);
|
|
167
|
+
}
|
|
168
|
+
dispose() { this.close(); if (this.#ownsSurface)
|
|
169
|
+
this.#surface.close(); }
|
|
170
|
+
}
|
|
@@ -1,29 +1,24 @@
|
|
|
1
|
-
|
|
2
|
-
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
|
|
7
|
-
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
17
|
-
|
|
18
|
-
state: {
|
|
19
|
-
pattern: any;
|
|
20
|
-
groupBy: any;
|
|
21
|
-
group: any;
|
|
22
|
-
page: number;
|
|
23
|
-
};
|
|
1
|
+
import type { WorldObject, WorldRelation } from './world-types.mjs';
|
|
2
|
+
export interface SpatialState {
|
|
3
|
+
pattern: string;
|
|
4
|
+
groupBy: string;
|
|
5
|
+
group: string | null;
|
|
6
|
+
page: number;
|
|
7
|
+
}
|
|
8
|
+
export interface SpatialGroup extends WorldObject {
|
|
9
|
+
kind: 'group';
|
|
10
|
+
title: string;
|
|
11
|
+
memberIds: string[];
|
|
12
|
+
}
|
|
13
|
+
export interface SpatialView {
|
|
14
|
+
objects: WorldObject[];
|
|
15
|
+
relations: WorldRelation[];
|
|
16
|
+
groups: SpatialGroup[];
|
|
17
|
+
state: SpatialState;
|
|
24
18
|
total: number;
|
|
25
19
|
pages: number;
|
|
26
|
-
label:
|
|
27
|
-
}
|
|
28
|
-
export const spatialPatterns: readonly string[][];
|
|
29
|
-
export const spatialGroupings: readonly string[][];
|
|
20
|
+
label: string;
|
|
21
|
+
}
|
|
22
|
+
export declare const spatialPatterns: readonly string[][];
|
|
23
|
+
export declare const spatialGroupings: readonly string[][];
|
|
24
|
+
export declare function spatialPattern(objects: WorldObject[], relations?: WorldRelation[], options?: Partial<SpatialState>): SpatialView;
|