@zixcel/dom-renderer 0.0.0-stage → 0.10.1
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/LICENSE +202 -0
- package/LICENSE-PREVIOUS +21 -0
- package/NOTICE +5 -0
- package/README.md +36 -2
- package/package.json +38 -4
- package/src/book-reader.d.mts +1 -0
- package/src/book-reader.mjs +36 -0
- package/src/frame-layout.d.mts +13 -0
- package/src/frame-layout.mjs +25 -0
- package/src/frame.d.mts +13 -0
- package/src/frame.mjs +47 -0
- package/src/index.d.mts +57 -0
- package/src/index.mjs +205 -0
- package/src/inspection-overview.d.mts +1 -0
- package/src/inspection-overview.mjs +35 -0
- package/src/npc-motion.d.mts +5 -0
- package/src/npc-motion.mjs +7 -0
- package/src/object-presentation.d.mts +55 -0
- package/src/object-presentation.mjs +24 -0
- package/src/open-world-projection.d.mts +29 -0
- package/src/open-world-projection.mjs +26 -0
- package/src/reconcile.d.mts +1 -0
- package/src/reconcile.mjs +21 -0
- package/src/spatial-patterns.d.mts +29 -0
- package/src/spatial-patterns.mjs +43 -0
- package/src/spatial.d.mts +8 -0
- package/src/spatial.mjs +27 -0
- package/src/structure-graph.d.mts +16 -0
- package/src/structure-graph.mjs +97 -0
- package/src/style.css +128 -0
- package/src/symbol.d.mts +2 -0
- package/src/symbol.mjs +19 -0
- package/src/world-gpu.d.mts +15 -0
- package/src/world-gpu.mjs +79 -0
- package/src/world-input.d.mts +3 -0
- package/src/world-input.mjs +21 -0
- package/src/world-model.d.mts +49 -0
- package/src/world-model.mjs +83 -0
- package/src/world-runtime.d.mts +36 -0
- package/src/world-runtime.mjs +312 -0
- package/src/world-surface.d.mts +7 -0
- package/src/world-surface.mjs +14 -0
|
@@ -0,0 +1,312 @@
|
|
|
1
|
+
import {layoutWorld,worldLimits,initialPose,movePose,lookPose,cameraMatrix,screenPoint,renderSize,clamp,pickWorld,fitPose,viewCamera} from './world-model.mjs'
|
|
2
|
+
import {MOUSE,TOUCH} from 'three'
|
|
3
|
+
import {OrbitControls} from 'three/addons/controls/OrbitControls.js'
|
|
4
|
+
import {createGpu,worldGeometry} from './world-gpu.mjs'
|
|
5
|
+
import {spatialPattern,spatialPatterns,spatialGroupings} from './spatial-patterns.mjs'
|
|
6
|
+
import {objectForms,spacingPresets,objectBounds} from './object-presentation.mjs'
|
|
7
|
+
import {keyDirection} from './world-input.mjs'
|
|
8
|
+
import {frameLayout} from './frame-layout.mjs'
|
|
9
|
+
import {mapProjection} from './open-world-projection.mjs'
|
|
10
|
+
|
|
11
|
+
// Host-local presentation. No persistence, network, grants, inference or synthetic people.
|
|
12
|
+
export function createWorld(host,{objects=[],relations=[],pose:restored,onActivate=()=>{},conversation=null,activity=null,target=null,pattern='ground',context=null,activityPresentation={}}={}){
|
|
13
|
+
if(restored?.target!==target)restored=null
|
|
14
|
+
const document=host.ownerDocument,window=document.defaultView,controller=new AbortController(),{signal}=controller
|
|
15
|
+
const make=(tag,className,text)=>{const n=document.createElement(tag);n.className=className;if(text)n.textContent=text;return n}
|
|
16
|
+
const wrap=make('section','mind-world'),canvas=make('canvas','world-canvas'),labels=make('div','world-labels'),hud=make('div','world-hud')
|
|
17
|
+
canvas.tabIndex=0;canvas.setAttribute('aria-label','Explore your world. W A S D or arrows to walk. Drag to look. E to interact. M for Map. Q to mark.')
|
|
18
|
+
wrap.setAttribute('aria-label','Thinking garden');wrap.dataset.presentationOnly='true'
|
|
19
|
+
const aim=make('span','world-aim','+'),hint=make('button','world-hint'),status=make('p','world-gpu-error')
|
|
20
|
+
aim.setAttribute('aria-hidden','true');status.hidden=true;status.setAttribute('role','alert')
|
|
21
|
+
const tools=make('div','world-controls'),nearby=make('details','world-nearby'),list=make('div','world-directory'),summary=make('summary','','In this space')
|
|
22
|
+
hint.type='button';hint.addEventListener('click',()=>{nearby.open=true},{signal})
|
|
23
|
+
nearby.append(summary,list);hud.append(hint,tools,nearby)
|
|
24
|
+
wrap.append(canvas,labels,aim,hud,status);host.prepend(wrap)
|
|
25
|
+
const roleStatus=(value,id)=>activityPresentation.describeRole?.(value,id)??null
|
|
26
|
+
const engine=activityPresentation.createView?.(document,wrap)??{panel:document.createElement('div'),update(){}}
|
|
27
|
+
engine.panel.hidden=pattern==='garden'
|
|
28
|
+
let spacing='balanced',placedSpacing=null
|
|
29
|
+
let pose=restored?Object.fromEntries(Object.keys(initialPose()).map(key=>[key,restored[key]])):initialPose(),model={items:[],relations:[]},gpu=null,geometry=null,actorRanges=[],closed=false,lost=false,dirty=true,frame=null,motionTimer=null,last=0,frames=0,drag=null,hovered=null,paused=false,zoom=1
|
|
30
|
+
let projected=[],labelNodes=new Map(),directoryNodes=new Map(),geometryKey=null,uploads=0,initializations=0,navigation='overview',needsFit=!restored,lastWidth=0,lastHeight=0,selectedId=null,markedId=null
|
|
31
|
+
const recent=new Map(),poses=new Map()
|
|
32
|
+
let fittedPopulation=Boolean(restored?.populated)
|
|
33
|
+
let sourceObjects=[],sourceRelations=relations,view=restored?.presentation??{pattern,groupBy:'declared',group:null,page:0},projection=null,inputKey=null
|
|
34
|
+
wrap.dataset.target=target??''
|
|
35
|
+
const bubble=make('aside','world-conversation');bubble.hidden=true;bubble.setAttribute('role','dialog');bubble.tabIndex=-1;wrap.append(bubble)
|
|
36
|
+
let conversationId=null,returnFocus=null
|
|
37
|
+
let anchorId=null
|
|
38
|
+
const shell=host.closest('.delivery-shell')
|
|
39
|
+
shell?.addEventListener('world-anchor-request',event=>{anchorId=event.detail.id;publishAnchor()},{signal})
|
|
40
|
+
function objectRect(id){
|
|
41
|
+
const item=model.items.find(item=>item.id===id),width=host.clientWidth,height=host.clientHeight
|
|
42
|
+
if(!item||!width||!height)return null
|
|
43
|
+
const [w,h,d]=objectBounds[item.form],matrix=cameraMatrix(pose,width/height,zoom),points=[]
|
|
44
|
+
for(const x of [-w/2,w/2])for(const y of [0,h])for(const z of [-d/2,d/2]){
|
|
45
|
+
const point=screenPoint(matrix,item.x+x,item.y+y,item.z+z,width,height)
|
|
46
|
+
if(point?.visible)points.push(point)
|
|
47
|
+
}
|
|
48
|
+
if(!points.length)return null
|
|
49
|
+
return {left:Math.min(...points.map(p=>p.x)),right:Math.max(...points.map(p=>p.x)),top:Math.min(...points.map(p=>p.y)),bottom:Math.max(...points.map(p=>p.y))}
|
|
50
|
+
}
|
|
51
|
+
function publishAnchor(){if(anchorId)shell?.dispatchEvent(new window.CustomEvent('world-anchor',{detail:{id:anchorId,bounds:objectRect(anchorId)}}))}
|
|
52
|
+
const map=make('details','world-map'),mapList=make('div','world-map-list'),mapNote=make('small',''),mapButton=make('button','','Mark selected')
|
|
53
|
+
mapButton.type='button';map.append(make('summary','','Map'),mapNote,mapList,mapButton);hud.append(map)
|
|
54
|
+
const keys=new Set(),inputBlocked=()=>paused||nearby.open||map.open||!bubble.hidden||!document.hasFocus()||Boolean(document.querySelector('.frame-panel:not([hidden])'))
|
|
55
|
+
const button=(text,label,action,parent=tools)=>{const b=make('button','',text);b.type='button';b.setAttribute('aria-label',label);b.addEventListener('click',action,{signal});parent.append(b);return b}
|
|
56
|
+
const inspect=button('Inspect','Inspect aimed object',()=>activate(hovered))
|
|
57
|
+
const reset=button('⛶','View all',()=>overview());reset.dataset.overviewControl='true'
|
|
58
|
+
const settings=make('div','world-layout'),breadcrumb=make('div','world-breadcrumb'),location=make('output',''),pagination=make('div','world-pagination')
|
|
59
|
+
const selector=(label,entries,change)=>{const field=make('label','',label),select=make('select','');select.setAttribute('aria-label',label);for(const [id,title]of entries){const option=make('option','',title);option.value=id;select.append(option)}select.addEventListener('change',()=>change(select.value),{signal});field.append(select);settings.append(field);return select}
|
|
60
|
+
const navigationSelect=selector('Navigation',[['overview','Overview'],['explore','Walk through world']],value=>{navigation=value;walk.hidden=value!=='explore';if(value==='overview')overview();else{pose={...initialPose(),cameraMode:'third-person'};stopInput();invalidate()}})
|
|
61
|
+
const patternSelect=selector('Spatial layout',spatialPatterns,value=>{view={...view,pattern:value,group:null,page:0};needsFit=true;update(sourceObjects,sourceRelations)})
|
|
62
|
+
const groupSelect=selector('Group by',spatialGroupings,value=>{view={...view,groupBy:value,group:null,page:0};needsFit=true;update(sourceObjects,sourceRelations)})
|
|
63
|
+
selector('Object spacing',Object.keys(spacingPresets).map(id=>[id,id[0].toUpperCase()+id.slice(1)]),value=>{spacing=value;needsFit=true;update(sourceObjects,sourceRelations)}).value=spacing
|
|
64
|
+
const legend=make('p','world-legend',pattern==='garden'?'People are shown here. Their published information is available inside each person’s panel; movement is visual only.':'Placement follows layout settings; only lines represent declared relationships.');settings.append(legend)
|
|
65
|
+
const back=button('All shelves','Return to all shelves',()=>{view={...view,group:null,page:0};needsFit=true;update(sourceObjects,sourceRelations)},breadcrumb)
|
|
66
|
+
breadcrumb.append(location);hud.append(breadcrumb);nearby.append(pagination,settings)
|
|
67
|
+
const previous=button('Previous','Previous objects',()=>{view={...view,page:view.page-1};needsFit=true;update(sourceObjects,sourceRelations)},pagination),pageCount=make('output','')
|
|
68
|
+
pagination.append(pageCount)
|
|
69
|
+
const nextPage=button('Next','Next objects',()=>{view={...view,page:view.page+1};needsFit=true;update(sourceObjects,sourceRelations)},pagination)
|
|
70
|
+
const updates=make('details','world-updates'),updatesTitle=make('summary','','Observed changes'),updateList=make('div',''),updateHelp=make('p','','Changes observed while this view is open. Not execution history.')
|
|
71
|
+
updates.setAttribute('aria-label','Changes in this view');updates.append(updatesTitle,updateHelp,updateList);hud.append(updates)
|
|
72
|
+
const query=make('input',''),noMatches=make('p','','No matching objects in this scope.')
|
|
73
|
+
query.type='search';query.maxLength=128;query.placeholder='Find in this space';query.setAttribute('aria-label','Find in this space');noMatches.hidden=true
|
|
74
|
+
nearby.insertBefore(query,list);nearby.insertBefore(noMatches,list)
|
|
75
|
+
function filterDirectory(){let count=0;const term=query.value.trim().toLocaleLowerCase();for(const row of directoryNodes.values()){row.hidden=!row.textContent.toLocaleLowerCase().includes(term);if(!row.hidden)count++}noMatches.hidden=!term||count>0}
|
|
76
|
+
query.addEventListener('input',filterDirectory,{signal})
|
|
77
|
+
host.closest('.delivery-shell')?.addEventListener('frame-panel-open',()=>{nearby.open=false;map.open=false;updates.open=false;closeConversation()},{signal})
|
|
78
|
+
nearby.addEventListener('toggle',()=>{map.hidden=nearby.open;if(nearby.open){map.open=false;updates.open=false;closeConversation()}},{signal})
|
|
79
|
+
updates.addEventListener('toggle',()=>{if(updates.open){nearby.open=false;closeConversation()}},{signal})
|
|
80
|
+
map.addEventListener('toggle',()=>{if(map.open){nearby.open=false;updates.open=false;closeConversation();stopInput()}},{signal})
|
|
81
|
+
const orbitCamera=viewCamera(pose,1),orbit=new OrbitControls(orbitCamera,canvas)
|
|
82
|
+
orbit.enableDamping=false;orbit.autoRotate=false;orbit.enableRotate=false;orbit.screenSpacePanning=true;orbit.minDistance=3;orbit.maxDistance=1500
|
|
83
|
+
orbit.mouseButtons.LEFT=MOUSE.PAN;orbit.touches.ONE=TOUCH.PAN;orbit.touches.TWO=TOUCH.DOLLY_PAN
|
|
84
|
+
let syncing=false
|
|
85
|
+
orbit.addEventListener('change',()=>{if(syncing)return;pose={x:orbitCamera.position.x,y:orbitCamera.position.y,z:orbitCamera.position.z,yaw:-orbitCamera.rotation.y,pitch:orbitCamera.rotation.x,focus:{x:orbit.target.x,y:orbit.target.y,z:orbit.target.z}};needsFit=false;invalidate()})
|
|
86
|
+
function syncOrbit(){syncing=true;viewCamera(pose,Math.max(.1,host.clientWidth/Math.max(1,host.clientHeight)),zoom,orbitCamera);const focus=pose.focus??{x:pose.x,y:pose.y,z:pose.z-8};orbit.target.set(focus.x,focus.y,focus.z);orbit.update();syncing=false}
|
|
87
|
+
function overview(){navigation='overview';navigationSelect.value=navigation;walk.hidden=true;lastWidth=host.clientWidth;lastHeight=host.clientHeight;pose=fitPose(model.items,lastWidth/Math.max(1,lastHeight));needsFit=false;stopInput();syncOrbit();invalidate()}
|
|
88
|
+
function fitViewport(){const width=host.clientWidth,height=host.clientHeight;if(!width||!height||(lastWidth===width&&lastHeight===height))return;lastWidth=width;lastHeight=height;if(navigation==='overview'){pose=fitPose(model.items,width/height);syncOrbit()}invalidate()}
|
|
89
|
+
function focusObject(id){const item=model.items.find(i=>i.id===id);if(!item)return;selectedId=id;wrap.dataset.selectedId=id
|
|
90
|
+
for(const [ref,node]of labelNodes)node.dataset.selected=String(ref===id)
|
|
91
|
+
renderMap();pose=navigation==='overview'?fitPose([item],host.clientWidth/Math.max(1,host.clientHeight)):{...initialPose(),cameraMode:'third-person',x:item.x,y:item.y+1.85,z:item.z+6,pitch:0};syncOrbit();invalidate()}
|
|
92
|
+
function renderMap(){
|
|
93
|
+
const projected=mapProjection(model.items,{selected:selectedId,marked:markedId}),items=projected.items
|
|
94
|
+
mapNote.textContent=projected.omitted?`${items.length} of ${projected.total} objects shown. Narrow this view to see the rest.`:`${items.length} objects in this view`
|
|
95
|
+
mapButton.disabled=!selectedId||!model.items.some(item=>item.id===selectedId);mapButton.textContent=selectedId===markedId?'Clear marker':'Mark selected'
|
|
96
|
+
mapList.replaceChildren();if(!items.length)return
|
|
97
|
+
const xs=items.map(item=>item.x),zs=items.map(item=>item.z),minX=Math.min(...xs),maxX=Math.max(...xs),minZ=Math.min(...zs),maxZ=Math.max(...zs)
|
|
98
|
+
for(const item of items){const node=make('button','world-map-point',item.title.slice(0,1).toUpperCase());node.type='button';node.title=item.title;node.dataset.mapId=item.id;node.dataset.selected=String(item.selected);node.dataset.marked=String(item.marked)
|
|
99
|
+
node.setAttribute('aria-label','Find '+item.title+' on map');node.style.left=(10+80*(item.x-minX)/Math.max(1,maxX-minX))+'%';node.style.top=(10+80*(item.z-minZ)/Math.max(1,maxZ-minZ))+'%';mapList.append(node)}
|
|
100
|
+
}
|
|
101
|
+
mapList.addEventListener('click',event=>{const id=event.target.closest('[data-map-id]')?.dataset.mapId;if(id)focusObject(id)},{signal})
|
|
102
|
+
mapButton.addEventListener('click',()=>{if(!selectedId)return;markedId=markedId===selectedId?null:selectedId;wrap.dataset.markedId=markedId??'';for(const [id,node]of labelNodes)node.dataset.marked=String(id===markedId);renderMap()},{signal})
|
|
103
|
+
const enableOrbit=()=>{orbit.enabled=navigation==='overview'&&!inputBlocked();if(orbit.enabled)syncOrbit()}
|
|
104
|
+
canvas.addEventListener('pointerdown',enableOrbit,{signal,capture:true});canvas.addEventListener('wheel',enableOrbit,{signal,capture:true})
|
|
105
|
+
function closeConversation(){conversationId=null;bubble.hidden=true;bubble.replaceChildren();if(returnFocus?.isConnected)returnFocus.focus({preventScroll:true});returnFocus=null;invalidate()}
|
|
106
|
+
function activate(id){const item=model.items.find(item=>item.id===id);if(!item||item.disabled)return;selectedId=id;wrap.dataset.selectedId=id;for(const [ref,node]of labelNodes)node.dataset.selected=String(ref===id);renderMap();stopInput();nearby.open=false;map.open=false
|
|
107
|
+
if(item.kind==='group'){view={...view,group:view.pattern==='garden'&&view.group===item.id?null:item.id,page:0};needsFit=view.pattern!=='garden';update(sourceObjects,sourceRelations);if(view.pattern==='garden')focusObject(item.id);return}
|
|
108
|
+
recent.delete(id);renderUpdates()
|
|
109
|
+
const body=conversation?.(item,()=>{closeConversation();onActivate(id)})
|
|
110
|
+
if(!body){onActivate(id);return}
|
|
111
|
+
returnFocus=document.activeElement;conversationId=id;bubble.setAttribute('aria-label','Conversation with '+item.title)
|
|
112
|
+
const title=make('strong','',item.title),dismiss=make('button','','×');dismiss.type='button';dismiss.setAttribute('aria-label','Close conversation');dismiss.addEventListener('click',closeConversation,{once:true})
|
|
113
|
+
const head=make('header','');head.append(title,dismiss);bubble.replaceChildren(head,body);bubble.hidden=false;bubble.focus({preventScroll:true});positionBubble();invalidate()
|
|
114
|
+
}
|
|
115
|
+
function positionBubble(){
|
|
116
|
+
if(!conversationId)return
|
|
117
|
+
if(!model.items.some(item=>item.id===conversationId)){closeConversation();return}
|
|
118
|
+
const {panel}=frameLayout(host.clientWidth,host.clientHeight,'actions',objectRect(conversationId))
|
|
119
|
+
Object.assign(bubble.style,{left:panel.x+'px',top:panel.y+'px',width:panel.width+'px',height:panel.height+'px',maxHeight:panel.height+'px'})
|
|
120
|
+
}
|
|
121
|
+
function stopInput(){keys.clear();drag=null;if(document.pointerLockElement===canvas)document.exitPointerLock()}
|
|
122
|
+
function stopFrame(){if(frame!==null){window.cancelAnimationFrame(frame);frame=null}if(motionTimer!==null){window.clearTimeout(motionTimer);motionTimer=null}last=0}
|
|
123
|
+
function invalidate(){dirty=true;if(!closed&&!paused&&!lost&&!document.hidden&&frame===null)frame=window.requestAnimationFrame(tick)}
|
|
124
|
+
function tick(now){
|
|
125
|
+
frame=null;if(closed||paused||lost||document.hidden)return
|
|
126
|
+
if(inputBlocked())keys.clear()
|
|
127
|
+
const moving=keys.size>0
|
|
128
|
+
if(now-last<1000/worldLimits.fps){if(dirty||moving)frame=window.requestAnimationFrame(tick);return}
|
|
129
|
+
if(moving){pose=movePose(pose,Number(keys.has('forward'))-Number(keys.has('back')),Number(keys.has('right'))-Number(keys.has('left')),last?(now-last)/1000:1/30,Number(keys.has('rise'))-Number(keys.has('descend')));dirty=true}
|
|
130
|
+
last=now
|
|
131
|
+
if(dirty){dirty=false;draw()}
|
|
132
|
+
if(moving)frame=window.requestAnimationFrame(tick)
|
|
133
|
+
}
|
|
134
|
+
function draw(){
|
|
135
|
+
if(!gpu)return
|
|
136
|
+
const width=host.clientWidth,height=host.clientHeight;if(!width||!height)return
|
|
137
|
+
fitViewport()
|
|
138
|
+
const size=renderSize(width,height,window.devicePixelRatio||1)
|
|
139
|
+
const matrix=cameraMatrix(pose,width/height,zoom)
|
|
140
|
+
const visibleActors=new Set(model.items.filter(item=>item.kind==='character').filter(item=>{const point=screenPoint(matrix,item.x,item.y+1.5,item.z,width,height);return point?.visible&&point.depth<150}).map(item=>item.id))
|
|
141
|
+
if(window.matchMedia('(prefers-reduced-motion: reduce)').matches)visibleActors.clear()
|
|
142
|
+
gpu.draw(pose,width/height,zoom,size,window.performance.now(),visibleActors);frames++;positionBubble()
|
|
143
|
+
wrap.dataset.animatedNpcs=String(visibleActors.size)
|
|
144
|
+
if(visibleActors.size&&motionTimer===null&&!paused&&!document.hidden)
|
|
145
|
+
motionTimer=window.setTimeout(()=>{motionTimer=null;invalidate()},200)
|
|
146
|
+
projected=[]
|
|
147
|
+
for(const item of model.items){const point=screenPoint(matrix,item.x,item.y+objectBounds[item.form][1]+.45,item.z,width,height);if(point?.visible&&point.depth<1800)projected.push({item,...point})}
|
|
148
|
+
projected.sort((a,b)=>a.depth-b.depth)
|
|
149
|
+
const displayed=[],labelWidth=width<600?138:182
|
|
150
|
+
for(const point of projected){
|
|
151
|
+
const x=clamp(point.x,90,width-90),y=clamp(point.y,96,Math.max(96,height-80))
|
|
152
|
+
if(displayed.some(p=>Math.abs(p.x-x)<labelWidth&&Math.abs(p.y-y)<85))continue
|
|
153
|
+
displayed.push({...point,x,y});if(displayed.length===8)break
|
|
154
|
+
}
|
|
155
|
+
const visible=new Set(displayed.map(p=>p.item.id))
|
|
156
|
+
for(const [id,node]of labelNodes)node.hidden=!visible.has(id)
|
|
157
|
+
for(const point of displayed){const node=labelNodes.get(point.item.id);node.style.left=point.x+'px';node.style.top=point.y+'px';node.dataset.distance=point.depth.toFixed(1)}
|
|
158
|
+
publishAnchor()
|
|
159
|
+
hovered=pickWorld(model.items,pose,width/2,height/2,width,height,zoom)
|
|
160
|
+
const target=model.items.find(item=>item.id===hovered)
|
|
161
|
+
inspect.disabled=!hovered;inspect.hidden=navigation==='overview';aim.hidden=navigation==='overview'
|
|
162
|
+
hint.textContent=navigation==='overview'?(view.pattern==='garden'?`Open people · ${model.items.filter(item=>item.kind==='character').length}`:`Browse this space · ${model.items.length}`):target?target.title+' · Inspect':model.items.length?'Select a person or a group':'No information is visible in this view'
|
|
163
|
+
hint.hidden=navigation!=='overview'||!model.items.length
|
|
164
|
+
canvas.setAttribute('aria-label',navigation==='overview'?'Explore your world. Drag to pan. Wheel or pinch to zoom. Select an object to focus. M for Map.':'Explore your world. W A S D or arrows to walk. Drag to look. E to interact. M for Map. Q to mark.')
|
|
165
|
+
for(const [id,node]of labelNodes)node.dataset.aimed=String(id===hovered)
|
|
166
|
+
wrap.dataset.pose=JSON.stringify(pose);wrap.dataset.frames=String(frames);wrap.dataset.instances=String(gpu.count);wrap.dataset.objects=String(model.items.length);wrap.dataset.bufferPixels=String(canvas.width*canvas.height);wrap.dataset.renderer='three';wrap.dataset.renderStats=JSON.stringify(gpu.stats);wrap.dataset.cameraMode=pose.cameraMode??'overview';wrap.dataset.avatar=pose.cameraMode==='third-person'?'present':'hidden'
|
|
167
|
+
wrap.dataset.uploads=String(uploads);wrap.dataset.initializations=String(initializations);wrap.dataset.navigation=navigation
|
|
168
|
+
}
|
|
169
|
+
function fail(error){stopInput();stopFrame();status.hidden=false;status.textContent=(error.code??error.message??'WorldRenderingUnavailable')+' — The 3D view is unavailable. Use “In this space” to inspect the available information.';wrap.dataset.gpu='unavailable'}
|
|
170
|
+
function initialize(){try{gpu=createGpu(canvas);initializations++;gpu.pattern(view.pattern);if(geometry){gpu.upload(geometry,actorRanges);gpu.motionStates(new Map(model.items.filter(i=>i.kind==='character').map(i=>[i.id,roleStatus(activity,i.id)?.state??'Idle'])));uploads++}lost=false;status.hidden=true;wrap.dataset.gpu='ready';invalidate()}catch(error){gpu?.close();gpu=null;fail(error)}}
|
|
171
|
+
const setText=(node,value)=>{if(node.textContent!==value)node.textContent=value}
|
|
172
|
+
function renderUpdates(){
|
|
173
|
+
const latest=[...recent.values()].at(-1)
|
|
174
|
+
updatesTitle.textContent=latest?'Observed changes · '+recent.size+' · '+latest.title+' · '+latest.kind:'No changes observed in this view'
|
|
175
|
+
updateList.replaceChildren()
|
|
176
|
+
for(const [id,change]of [...recent].reverse()){
|
|
177
|
+
const button=make('button','',change.title+' · '+change.kind);button.type='button';button.dataset.changeId=id
|
|
178
|
+
button.disabled=!sourceObjects.some(item=>item.id===id);updateList.append(button)
|
|
179
|
+
}
|
|
180
|
+
for(const [id,node]of labelNodes)node.dataset.updated=String(recent.has(id))
|
|
181
|
+
}
|
|
182
|
+
updateList.addEventListener('click',event=>{
|
|
183
|
+
const id=event.target.closest('[data-change-id]')?.dataset.changeId;if(!id)return
|
|
184
|
+
if(view.pattern==='garden'&&!model.items.some(item=>item.id===id)){updates.open=false;onActivate(id);return}
|
|
185
|
+
const group=projection?.groups.find(group=>group.memberIds.includes(id))
|
|
186
|
+
if(group){view={...view,group:group.id,page:0};update(sourceObjects,sourceRelations)
|
|
187
|
+
const index=(view.pattern==='garden'?group.memberIds:[...sourceObjects.filter(i=>i.kind==='character').map(i=>i.id),...group.memberIds]).indexOf(id)
|
|
188
|
+
if(index>=24){view={...view,page:Math.floor(index/24)};update(sourceObjects,sourceRelations)}
|
|
189
|
+
}
|
|
190
|
+
updates.open=false;focusObject(id)
|
|
191
|
+
},{signal})
|
|
192
|
+
labels.addEventListener('click',event=>activate(event.target.closest('[data-world-id]')?.dataset.worldId),{signal})
|
|
193
|
+
list.addEventListener('click',event=>{
|
|
194
|
+
const control=event.target.closest('button'),id=control?.closest('[data-directory-id]')?.dataset.directoryId
|
|
195
|
+
if(!id)return
|
|
196
|
+
if(control.dataset.focus==='true'){focusObject(id);nearby.open=false;canvas.focus({preventScroll:true})}else activate(id)
|
|
197
|
+
},{signal})
|
|
198
|
+
function update(objects,relations=[]){
|
|
199
|
+
engine.update(activity)
|
|
200
|
+
const nextProjection=spatialPattern(objects,relations,view),next=layoutWorld(nextProjection.objects,nextProjection.relations,nextProjection.state.pattern,spacing)
|
|
201
|
+
const nextInputKey=JSON.stringify([objects,relations,nextProjection.state,spacing,activity])
|
|
202
|
+
if(inputKey===nextInputKey&&!needsFit)return
|
|
203
|
+
// Keep remembered positions for unchanged membership, even if producer ordering changes.
|
|
204
|
+
if(placedSpacing===spacing&&(model.pattern??'ground')===(next.pattern??'ground')&&model.items.length===next.items.length&&model.items.every(item=>next.items.some(n=>n.id===item.id&&n.anchor===item.anchor&&n.plot===item.plot&&n.member===item.member))){
|
|
205
|
+
const previous=new Map(model.items.map(item=>[item.id,item]))
|
|
206
|
+
for(const item of next.items){const before=previous.get(item.id);item.x=before.x;item.y=before.y;item.z=before.z}
|
|
207
|
+
}
|
|
208
|
+
placedSpacing=spacing
|
|
209
|
+
const key=JSON.stringify([next.pattern,next.items.map(i=>[i.id,i.kind,i.form,i.needsReview,i.x,i.y,i.z,i.selected,i.disabled,i.memberIds?.length]),next.relations.map(r=>[r.from,r.to,r.active])])
|
|
210
|
+
const changedGeometry=key!==geometryKey,nextActors=changedGeometry?[]:actorRanges,nextGeometry=changedGeometry?worldGeometry(next,nextActors):geometry
|
|
211
|
+
const before=new Map(sourceObjects.map(item=>[item.id,item]));let changed=false
|
|
212
|
+
if(before.size){
|
|
213
|
+
for(const item of objects){const previous=before.get(item.id)
|
|
214
|
+
const kind=!previous?'Added to this view':previous.disabled!==item.disabled?'Availability changed':previous.title!==item.title||previous.summary!==item.summary||previous.changeKey!==item.changeKey?'Content changed':null
|
|
215
|
+
if(kind){recent.delete(item.id);recent.set(item.id,{title:item.title,kind});changed=true}
|
|
216
|
+
}
|
|
217
|
+
const ids=new Set(objects.map(item=>item.id));for(const item of before.values())if(!ids.has(item.id)){recent.delete(item.id);recent.set(item.id,{title:item.title,kind:'No longer in this view'});changed=true}
|
|
218
|
+
while(recent.size>5)recent.delete(recent.keys().next().value)
|
|
219
|
+
}
|
|
220
|
+
sourceObjects=objects.map(item=>({...item}));sourceRelations=relations;projection=nextProjection;view=projection.state;inputKey=nextInputKey
|
|
221
|
+
if(conversationId&&(changed||!objects.some(i=>i.id===conversationId)))closeConversation()
|
|
222
|
+
model=next;geometry=nextGeometry;actorRanges=nextActors;geometryKey=key
|
|
223
|
+
if(!fittedPopulation&&model.items.length){fittedPopulation=true;if(navigation==='overview')needsFit=true}
|
|
224
|
+
const ids=new Set(model.items.map(item=>item.id))
|
|
225
|
+
for(const [id,node]of labelNodes)if(!ids.has(id)){node.remove();labelNodes.delete(id);directoryNodes.get(id)?.remove();directoryNodes.delete(id)}
|
|
226
|
+
patternSelect.value=view.pattern;groupSelect.value=view.groupBy;groupSelect.parentElement.hidden=view.pattern==='ground'||view.pattern==='garden'
|
|
227
|
+
patternSelect.parentElement.hidden=pattern==='garden'
|
|
228
|
+
breadcrumb.hidden=view.pattern==='ground'||view.pattern==='garden';back.hidden=!view.group;location.textContent=projection.label+' · '+projection.total+' objects'
|
|
229
|
+
pagination.hidden=projection.pages===1;previous.disabled=view.page===0;nextPage.disabled=view.page===projection.pages-1;pageCount.textContent=(view.page+1)+' / '+projection.pages
|
|
230
|
+
wrap.dataset.pattern=view.pattern;wrap.dataset.groupBy=view.groupBy;wrap.dataset.group=view.group??'';wrap.dataset.sourceObjects=String(objects.length)
|
|
231
|
+
for(const [index,item]of model.items.entries()){
|
|
232
|
+
let b=labelNodes.get(item.id),row=directoryNodes.get(item.id)
|
|
233
|
+
if(!b){
|
|
234
|
+
b=make('button','world-name');b.type='button';b.dataset.worldId=item.id;b.append(make('strong',''),make('span',''));labelNodes.set(item.id,b)
|
|
235
|
+
row=make('div','');row.dataset.directoryId=item.id
|
|
236
|
+
const focus=make('button',''),open=make('button','');focus.type=open.type='button';focus.dataset.focus='true';row.append(focus,open);directoryNodes.set(item.id,row)
|
|
237
|
+
}
|
|
238
|
+
const review=item.kind==='character'?roleStatus(activity,item.id):null,state=review?.state??null
|
|
239
|
+
const detail=item.disabled?'Unavailable':review?review.label:objectForms[item.form]+' · '+(item.summary||'Inspect information')
|
|
240
|
+
setText(b.children[0],item.title);setText(b.children[1],detail);b.dataset.worldKind=item.kind;b.disabled=Boolean(item.disabled);b.setAttribute('aria-label',item.title+'. '+detail)
|
|
241
|
+
b.dataset.position=JSON.stringify([item.x,item.y,item.z])
|
|
242
|
+
b.dataset.objectForm=item.form;b.dataset.activity=state??'';b.dataset.supportRef=item.supportRef??'';b.dataset.selected=String(item.id===selectedId);b.dataset.marked=String(item.id===markedId)
|
|
243
|
+
if(labels.children[index]!==b)labels.insertBefore(b,labels.children[index]??null)
|
|
244
|
+
const [focus,open]=row.children;setText(focus,item.title);focus.setAttribute('aria-label','Find '+item.title);open.disabled=Boolean(item.disabled)
|
|
245
|
+
const action=item.kind==='group'?'Browse shelf':view.pattern==='garden'&&item.kind==='character'?'Talk to':'Inspect'
|
|
246
|
+
setText(open,action);open.setAttribute('aria-label',action+' '+item.title)
|
|
247
|
+
if(item.kind==='group')row.dataset.members=JSON.stringify(item.memberIds);else delete row.dataset.members
|
|
248
|
+
if(list.children[index]!==row)list.insertBefore(row,list.children[index]??null)
|
|
249
|
+
}
|
|
250
|
+
summary.textContent=(view.pattern==='garden'?'People':'In this space')+' · '+model.items.length
|
|
251
|
+
renderMap()
|
|
252
|
+
filterDirectory()
|
|
253
|
+
renderUpdates()
|
|
254
|
+
if(needsFit){overview();needsFit=false}
|
|
255
|
+
try{gpu?.pattern(view.pattern);if(changedGeometry&&gpu){gpu.upload(geometry,actorRanges);uploads++}gpu?.motionStates(new Map(model.items.filter(i=>i.kind==='character').map(i=>[i.id,roleStatus(activity,i.id)?.state??'Idle'])));invalidate()}catch(error){fail(error)}
|
|
256
|
+
}
|
|
257
|
+
function onKey(event){
|
|
258
|
+
if(event.target!==canvas||event.ctrlKey||event.metaKey||event.altKey||event.isComposing)return
|
|
259
|
+
if(event.type==='keydown'&&event.code==='KeyM'){event.preventDefault();map.open=!map.open;return}
|
|
260
|
+
if(event.type==='keydown'&&event.code==='KeyQ'){event.preventDefault();const target=hovered??selectedId;if(target){selectedId=target;mapButton.click()}return}
|
|
261
|
+
if(navigation==='overview'){if(event.type==='keydown'&&event.code==='KeyE')activate(hovered);return}
|
|
262
|
+
const key=keyDirection(event.code)
|
|
263
|
+
if(key){event.preventDefault();if(event.type==='keydown'&&!inputBlocked())keys.add(key);else keys.delete(key);invalidate()}
|
|
264
|
+
else if(event.type==='keydown'&&!event.repeat){if(event.code==='KeyE'){event.preventDefault();activate(hovered)}if(event.code==='Escape'){stopInput();canvas.blur()}}
|
|
265
|
+
}
|
|
266
|
+
canvas.addEventListener('keydown',onKey,{signal});window.addEventListener('keyup',event=>{const key=keyDirection(event.code);if(key){keys.delete(key);invalidate()}},{signal})
|
|
267
|
+
bubble.addEventListener('keydown',event=>{if(event.key==='Escape'){event.stopPropagation();closeConversation()}},{signal})
|
|
268
|
+
canvas.addEventListener('pointerdown',event=>{if(!event.isPrimary){drag=null;return}if(event.button!==0||inputBlocked())return;canvas.focus({preventScroll:true});drag={id:event.pointerId,x:event.clientX,y:event.clientY,startX:event.clientX,startY:event.clientY};canvas.setPointerCapture(event.pointerId)},{signal})
|
|
269
|
+
canvas.addEventListener('pointermove',event=>{if(drag?.id!==event.pointerId)return;if(navigation==='explore')pose=lookPose(pose,event.clientX-drag.x,event.clientY-drag.y);drag.x=event.clientX;drag.y=event.clientY;invalidate()},{signal})
|
|
270
|
+
canvas.addEventListener('pointerup',event=>{if(drag?.id!==event.pointerId)return;const clicked=Math.hypot(event.clientX-drag.startX,event.clientY-drag.startY)<5;drag=null;if(clicked){const r=canvas.getBoundingClientRect();activate(pickWorld(model.items,pose,event.clientX-r.left,event.clientY-r.top,r.width,r.height,zoom))}},{signal,capture:true})
|
|
271
|
+
for(const type of ['pointercancel','lostpointercapture'])canvas.addEventListener(type,()=>drag=null,{signal})
|
|
272
|
+
canvas.addEventListener('wheel',event=>{if(navigation==='overview'||event.ctrlKey||event.metaKey||inputBlocked()||!Number.isFinite(event.deltaY))return;event.preventDefault();const amount=clamp(-event.deltaY*(event.deltaMode===1?16:event.deltaMode===2?host.clientHeight:1)/100,-2,2);pose={...pose,x:clamp(pose.x+Math.sin(pose.yaw)*amount,-28,28),z:clamp(pose.z-Math.cos(pose.yaw)*amount,-350,28)};invalidate()},{signal,passive:false})
|
|
273
|
+
const walk=make('div','world-walk');walk.hidden=true;walk.setAttribute('role','group');walk.setAttribute('aria-label','Walk controls');hud.append(walk)
|
|
274
|
+
for(const [direction,icon]of [['forward','↑'],['left','←'],['back','↓'],['right','→'],['rise','⇧'],['descend','⇩']]){
|
|
275
|
+
const b=button(icon,'Walk '+direction,()=>{},walk)
|
|
276
|
+
b.addEventListener('pointerdown',event=>{if(event.button||inputBlocked())return;event.preventDefault();b.setPointerCapture(event.pointerId);keys.add(direction);invalidate()},{signal})
|
|
277
|
+
for(const type of ['pointerup','pointercancel','lostpointercapture'])b.addEventListener(type,()=>{keys.delete(direction);invalidate()},{signal})
|
|
278
|
+
b.addEventListener('click',event=>{if(event.detail===0&&!inputBlocked()){pose=movePose(pose,direction==='forward'?1:direction==='back'?-1:0,direction==='right'?1:direction==='left'?-1:0,.05,direction==='rise'?1:direction==='descend'?-1:0);invalidate()}},{signal})
|
|
279
|
+
}
|
|
280
|
+
nearby.addEventListener('toggle',()=>{if(nearby.open)stopInput()},{signal})
|
|
281
|
+
window.addEventListener('blur',()=>{wrap.dataset.motionPaused='true';stopInput();stopFrame()},{signal});window.addEventListener('focus',()=>{wrap.dataset.motionPaused='false';invalidate()},{signal})
|
|
282
|
+
document.addEventListener('visibilitychange',()=>{wrap.dataset.motionPaused=String(document.hidden);stopInput();if(document.hidden)stopFrame();else invalidate()},{signal})
|
|
283
|
+
window.addEventListener('pagehide',()=>{paused=true;stopInput();stopFrame()},{signal})
|
|
284
|
+
window.addEventListener('pageshow',()=>{paused=false;invalidate()},{signal})
|
|
285
|
+
canvas.addEventListener('webglcontextlost',event=>{event.preventDefault();lost=true;gpu?.close();gpu=null;fail(Error('WorldGraphicsContextLost'))},{signal})
|
|
286
|
+
canvas.addEventListener('webglcontextrestored',initialize,{signal})
|
|
287
|
+
const resize=new window.ResizeObserver(fitViewport);resize.observe(host)
|
|
288
|
+
update(objects,relations);initialize()
|
|
289
|
+
function snapshot(){return {...pose,target,presentation:{...view},navigation,populated:fittedPopulation}}
|
|
290
|
+
function park(){paused=true;stopInput();stopFrame();orbit.enabled=false;resize.disconnect();wrap.remove()}
|
|
291
|
+
function mount(nextHost,options){
|
|
292
|
+
if(closed)throw Error('WorldDisposed')
|
|
293
|
+
const changedTarget=target!==(options.target??null)
|
|
294
|
+
const changedContext=context!==(options.context??null);context=options.context??null
|
|
295
|
+
if(changedTarget){poses.set(target,snapshot());while(poses.size>8)poses.delete(poses.keys().next().value)}
|
|
296
|
+
host=nextHost;host.prepend(wrap);resize.disconnect();resize.observe(host)
|
|
297
|
+
onActivate=options.onActivate;conversation=options.conversation;activity=options.activity??null;target=options.target??null;wrap.dataset.target=target??'';paused=false
|
|
298
|
+
engine.panel.hidden=options.pattern==='garden'
|
|
299
|
+
if(conversationId)closeConversation()
|
|
300
|
+
if(changedTarget){const saved=poses.get(target);view=saved?.presentation??{pattern:options.pattern??'ground',groupBy:'declared',group:null,page:0};navigation=saved?.navigation??'overview';pose=saved??initialPose();fittedPopulation=Boolean(saved?.populated);needsFit=!saved;sourceObjects=[];recent.clear();query.value='';updates.open=false;nearby.open=false;map.open=false;selectedId=null;markedId=null;wrap.dataset.selectedId='';wrap.dataset.markedId='';inputKey=null;model={items:[],relations:[]}}
|
|
301
|
+
else if(changedContext){view={...view,group:null,page:0};sourceObjects=[];recent.clear();query.value='';updates.open=false;inputKey=null}
|
|
302
|
+
navigationSelect.value=navigation;walk.hidden=navigation!=='explore';update(options.objects,options.relations);syncOrbit();invalidate()
|
|
303
|
+
}
|
|
304
|
+
function close(){if(closed)return;closed=true;stopInput();stopFrame();resize.disconnect();controller.abort();orbit.dispose();gpu?.close();gpu=null;canvas.getContext('webgl2')?.getExtension('WEBGL_lose_context')?.loseContext();geometry=null;labelNodes.clear();directoryNodes.clear();recent.clear();poses.clear();wrap.remove()}
|
|
305
|
+
return {update,close,mount,park,snapshot,activity(value){activity=value??null;update(sourceObjects,sourceRelations)},apply(value){const next=clamp(value.z,.65,1.5);if(next!==zoom){zoom=next;syncOrbit();invalidate()}},change(action){
|
|
306
|
+
fitViewport()
|
|
307
|
+
if(action==='reset'){overview();return}
|
|
308
|
+
if(navigation==='overview'){syncOrbit();if(action==='in')orbit.dollyIn(1/1.2);else if(action==='out')orbit.dollyOut(1/1.2);else orbit.pan(action==='left'?40:action==='right'?-40:0,action==='up'?40:action==='down'?-40:0);orbit.update();return}
|
|
309
|
+
else if(['in','out','up','down','left','right'].includes(action)){const forward=['in','up'].includes(action)?1:['out','down'].includes(action)?-1:0,side=action==='left'?-1:action==='right'?1:0;for(let i=0;i<5;i++)pose=movePose(pose,forward,side,.05)}
|
|
310
|
+
invalidate()
|
|
311
|
+
},pause(value){paused=value;if(value){stopInput();stopFrame()}else invalidate()}}
|
|
312
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import {createWorld} from './world-runtime.mjs'
|
|
2
|
+
// One explicit owner per delivery application; stale leases cannot pause a new view.
|
|
3
|
+
export function createWorldSurface(activityPresentation={}){
|
|
4
|
+
for(const value of Object.values(activityPresentation))if(typeof value!=='function')throw Error('InvalidPresentationAdapter')
|
|
5
|
+
const trustedPresentation=Object.freeze({...activityPresentation})
|
|
6
|
+
let world=null,generation=0,closed=false
|
|
7
|
+
return {attach(host,options){
|
|
8
|
+
if(closed)throw Error('WorldSurfaceClosed')
|
|
9
|
+
options={...options,activityPresentation:trustedPresentation}
|
|
10
|
+
const lease=++generation
|
|
11
|
+
if(world)world.mount(host,options);else world=createWorld(host,options)
|
|
12
|
+
return {world,release(){if(lease===generation)world.park()}}
|
|
13
|
+
},close(){if(closed)return;closed=true;++generation;world?.close();world=null}}
|
|
14
|
+
}
|