diff --git a/web/app.js b/web/app.js index 5f12770..2cde2f1 100644 --- a/web/app.js +++ b/web/app.js @@ -1,5 +1,5 @@ import { LandscapeRenderer, KIND } from './renderer.js'; -import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, facadeQuad, heading, MOVE } from './city.js'; +import { layoutCity, squarifiedLayout, spatialIndex, pickRay, stepCamera, collide, blockAt, groundHit, flatQuad, wallSigns, facadeQuad, heading, tourStops, encodePlace, decodePlace, joystick, MOVE } from './city.js'; import { LabelAtlas } from './labels.js'; import { apiFetch, progressEvents } from './api.mjs'; @@ -128,7 +128,8 @@ function computeCity(){ instances.push({...building,color:item.color,atlas:[item.x/1000,item.y/680,item.w/1000,item.h/680],kind:KIND.building,id:item.id,lit:Math.min(.85,.06+Math.sqrt(symbols)/9)}); } cityIndex=spatialIndex(cityEntries); - cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))})); + const tally=node=>{let files=node.files.length,lines=node.files.reduce((n,f)=>n+(f.lines||0),0);for(const child of node.children.values()){const t=tally(child);files+=t.files;lines+=t.lines;}return{files,lines};}; + cityDistricts=cityModel.blocks.filter(block=>block.depth===1).map(block=>({block,...tally(block.node),top:Math.max(10,...[...cityIndex.near(block.x+block.w/2,block.y+block.h/2,Math.hypot(block.w,block.h)/2)].filter(b=>b.x>=block.x&&b.x+b.w<=block.x+block.w&&b.y>=block.y&&b.y+b.h<=block.y+block.h).map(b=>b.height))})); renderer.setCity(instances,{width:cityModel.width,height:cityModel.height}); buildMinimap();updateCityHud();updateTrails();updateBeacons();signState.key='';facadeState={entry:null,texture:null};renderer.setFacade(null); } @@ -136,7 +137,12 @@ function computeCity(){ // ---- City navigation: helicopter, walk and fly cameras, HUD, crosshair and minimap ---- const heldKeys=new Set(),MOVE_KEYS=new Set(['KeyW','KeyA','KeyS','KeyD','ArrowUp','ArrowDown','ArrowLeft','ArrowRight','ShiftLeft','ShiftRight','Space','KeyC']); let minimapBase=null,cityTargetEntry=null,lastAddress=''; -const CITY_HELP={ +const COARSE=matchMedia('(pointer: coarse)').matches; +const CITY_HELP=COARSE?{ + heli:'Drag orbit · Pinch zoom · Double-tap a street to walk', + walk:'Left thumb move · Right thumb look · Tap a building to inspect', + fly:'Left thumb fly · Right thumb look · Pinch climb', +}:{ heli:'Drag orbit · Shift+drag pan · Scroll zoom · Double-click a street to walk', walk:'Click to look · WASD move · Shift run · E inspect · Esc release mouse', fly:'Click to look · WASD fly · Space/C up/down · Shift fast · E inspect', @@ -180,8 +186,8 @@ function openHeading(x,y,current){ function stepCity(seconds){ const down=code=>heldKeys.has(code)?1:0; stepCamera(renderer.city,{ - forward:down('KeyW')+down('ArrowUp')-down('KeyS')-down('ArrowDown'), - right:down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft'), + forward:Math.max(-1,Math.min(1,down('KeyW')+down('ArrowUp')-down('KeyS')-down('ArrowDown')+touchMove.forward)), + right:Math.max(-1,Math.min(1,down('KeyD')+down('ArrowRight')-down('KeyA')-down('ArrowLeft')+touchMove.right)), up:down('Space')-down('KeyC'), run:heldKeys.has('ShiftLeft')||heldKeys.has('ShiftRight'), },seconds,cityIndex,cityModel); @@ -195,6 +201,8 @@ function lookCity(dx,dy){ function handleCityKey(event){ const c=renderer.city; + if(event.code==='KeyT'){event.preventDefault();tour?endTour():startTour();return true;} + if(tour)endTour(); if(/INPUT|TEXTAREA|SELECT/.test(document.activeElement?.tagName))return false; const views={Digit1:'heli',Digit2:'walk',Digit3:'fly'}; if(views[event.code]){event.preventDefault();setCityView(views[event.code]);return true;} @@ -222,7 +230,106 @@ function updateCityHud(){ $('#cityHelp').innerHTML=CITY_HELP[view]; if(!inCity&&document.pointerLockElement)document.exitPointerLock(); } -$$('[data-city-view]').forEach(button=>button.addEventListener('click',()=>{button.blur();setCityView(button.dataset.cityView);})); +$$('[data-city-view]').forEach(button=>button.addEventListener('click',()=>{button.blur();endTour();setCityView(button.dataset.cityView);})); + +// ---- Touch: left-thumb joystick while walking or flying, drag to look, pinch to zoom ---- +const touches=new Map();let joy=null,pinch=null,touchMove={forward:0,right:0}; +function startTouch(event){ + touches.set(event.pointerId,{x:event.clientX,y:event.clientY}); + if(touches.size===2){ + pointer.down=false;endJoystick(); + const [a,b]=[...touches.values()];pinch={distance:Math.hypot(a.x-b.x,a.y-b.y)}; + viewport.setPointerCapture(event.pointerId);event.preventDefault();return true; + } + const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top; + if(renderer.mode==='city'&&renderer.city.view!=='heli'&&x=2){ + const [a,b]=[...touches.values()],distance=Math.hypot(a.x-b.x,a.y-b.y),ratio=distance/Math.max(1,pinch.distance);pinch.distance=distance; + const amount=-Math.log(ratio||1),c=renderer.city; + if(renderer.mode==='city'){if(c.view==='heli')dollyCity(amount);else if(c.view==='fly')c.ez=Math.max(MOVE.eyeHeight,Math.min(3000,c.ez-amount*120));} + else if(renderer.mode==='3d')dolly3d(amount); + else renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*ratio)); + dirty=true;return true; + } + if(joy&&event.pointerId===joy.id){ + const dx=event.clientX-joy.x0,dy=event.clientY-joy.y0,length=Math.min(56,Math.hypot(dx,dy)),angle=Math.atan2(dy,dx); + touchMove=joystick(dx,dy); + $('#joystick').firstElementChild.style.transform=`translate(${Math.cos(angle)*length}px,${Math.sin(angle)*length}px)`; + dirty=true;return true; + } + return false; +} +function endTouch(event){ + touches.delete(event.pointerId); + if(joy?.id===event.pointerId)endJoystick(); + if(touches.size<2)pinch=null; +} +function endJoystick(){joy=null;touchMove={forward:0,right:0};$('#joystick').hidden=true;} +viewport.addEventListener('pointercancel',event=>{if(event.pointerType==='touch')endTouch(event);}); + +// ---- Guided tour: a helicopter flight over the largest districts ---- +let tour=null; +function startTour(){ + if(!cityModel||renderer.mode!=='city')return; + const stops=tourStops(cityDistricts);if(!stops.length)return; + if(renderer.city.view!=='heli')setCityView('heli'); + tour={stops,index:-1,dwellUntil:0};$('#tourButton').classList.add('active');nextTourStop(); +} +function nextTourStop(){ + tour.index++; + const caption=$('#tourCaption'); + if(tour.index>=tour.stops.length){ + const size=Math.max(cityModel.width,cityModel.height); + animateCameraTo({x:cityModel.width/2,y:cityModel.height/2,distance:size*1.05,pitch:.72,yaw:renderer.city.yaw+.6},renderer.city,3000); + endTour();return; + } + const {district,pose}=tour.stops[tour.index]; + animateCameraTo({...pose,yaw:renderer.city.yaw+((pose.yaw-renderer.city.yaw)%(Math.PI*2))},renderer.city,2600); + tour.dwellUntil=performance.now()+2600+3400; + caption.hidden=false; + caption.innerHTML=`${tour.index+1} / ${tour.stops.length}${escapeHtml(district.block.node.name)}${format(district.files)} files · ${format(district.lines)} lines`; +} +function advanceTour(now,seconds){ + if(!cameraAnimation&&now=tour.dwellUntil)nextTourStop(); +} +function endTour(){if(!tour)return;tour=null;$('#tourCaption').hidden=true;$('#tourButton').classList.remove('active');} +$('#tourButton').addEventListener('click',event=>{event.currentTarget.blur();tour?endTour():startTour();}); + +// ---- Shareable places: repository, view, camera and file in the URL fragment ---- +let currentRepoUrl=''; +$('#shareButton').addEventListener('click',async event=>{ + const button=event.currentTarget,label=button.textContent;button.blur(); + const flash=text=>{button.textContent=text;setTimeout(()=>{button.textContent=label;},1800);}; + if(!currentRepoUrl){flash('Needs a GitHub repo');return;} + const hash=encodePlace({repo:currentRepoUrl,view:renderer.mode,camera:renderer.city,file:selected?.path}); + window.history.replaceState(null,'',hash); + try{await navigator.clipboard.writeText(location.href);flash('Link copied');}catch{flash('Link in address bar');} +}); +function openPlaceFromHash(){ + const place=decodePlace(location.hash);if(!place)return; + try{sessionStorage.setItem('codenav.pendingPlace',JSON.stringify(place));}catch{} + $('#githubInput').value=place.repo;startGithub(); +} +function applyPendingPlace(){ + let place=null;try{place=JSON.parse(sessionStorage.getItem('codenav.pendingPlace')||'null');}catch{} + if(!place||place.repo!==currentRepoUrl)return; + try{sessionStorage.removeItem('codenav.pendingPlace');}catch{} + $(`[data-view="${place.view}"]`)?.click(); + const file=place.file&&files.find(candidate=>candidate.path===place.file); + if(file){selectFile(file);if(!place.camera)focusFile(file);} + if(place.camera&&place.view==='city'){cancelCameraAnimation();Object.assign(renderer.city,place.camera);updateCityHud();} + dirty=true; +} // Crosshair target, street address and minimap, refreshed with each city frame. function updateCityReadouts(){ @@ -311,6 +418,8 @@ function applySnapshot(snapshot){ const layers=new Map();for(const file of files){const layer=file.layer in layerLabels?file.layer:'unknown';layers.set(layer,(layers.get(layer)||0)+1);}sceneStats={definitions:snapshot.definitions??files.reduce((n,file)=>n+(file.symbolCount||0),0),totalLines:snapshot.totalLines??files.reduce((n,file)=>n+file.lines,0),known,inferred,layers}; computeLayout();fitScene();fitCity();updateStats(snapshot);renderInspector();renderResults([]);renderHistory(); $('#breadcrumbText').textContent=currentName;$('#emptyState').hidden=files.length>0;$('#welcome').hidden=true; + currentRepoUrl=snapshot.source==='github'?lastGithubUrl.replace(/\/+$/,'').replace(/\.git$/,''):''; + endTour();applyPendingPlace(); } function fitScene(){ @@ -839,7 +948,8 @@ function drawCodeTexture(ctx,item,rect){ function animate(now){ const frameSeconds=Math.min(.1,(now-lastFrame)/1000); - if(renderer.mode==='city'&&renderer.city.view!=='heli'&&!cameraAnimation&&heldKeys.size)stepCity(frameSeconds); + if(renderer.mode==='city'&&renderer.city.view!=='heli'&&!cameraAnimation&&(heldKeys.size||touchMove.forward||touchMove.right))stepCity(frameSeconds); + if(tour)advanceTour(now,frameSeconds); if(cameraAnimation){ const progress=Math.min(1,(now-cameraAnimation.start)/cameraAnimation.duration),eased=1-(1-progress)**4; for(const key of Object.keys(cameraAnimation.to))cameraAnimation.camera[key]=cameraAnimation.from[key]+(cameraAnimation.to[key]-cameraAnimation.from[key])*eased; @@ -879,6 +989,8 @@ viewport.addEventListener('pointerdown',event=>{ if(document.pointerLockElement===glCanvas)inspectCityTarget(false);else glCanvas.requestPointerLock(); return; } + if(tour)endTour(); + if(event.pointerType==='touch'&&startTouch(event))return; const pan=renderer.mode!=='2d'&&(event.button===1||event.button===2||event.shiftKey||event.altKey||event.metaKey||event.ctrlKey); if(renderer.mode==='2d'&&event.button!==0)return; event.preventDefault(); @@ -888,6 +1000,7 @@ viewport.addEventListener('pointerdown',event=>{ }); viewport.addEventListener('pointermove',event=>{ if(document.pointerLockElement)return; + if(event.pointerType==='touch'&&moveTouch(event))return; const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top; if(pointer.down&&event.pointerId===pointer.id){ const dx=event.clientX-pointer.x,dy=event.clientY-pointer.y;pointer.x=event.clientX;pointer.y=event.clientY; @@ -900,6 +1013,7 @@ viewport.addEventListener('pointermove',event=>{ const item=pickAt(x,y),tip=$('#tooltip');if(item){tip.hidden=false;tip.style.left=`${Math.min(viewport.clientWidth-320,x+13)}px`;tip.style.top=`${Math.min(viewport.clientHeight-70,y+13)}px`;tip.innerHTML=`${escapeHtml(item.name)}${escapeHtml(item.path)} · ${format(item.lines)} lines`;}else tip.hidden=true; }); function finishPointer(event){ + if(event.pointerType==='touch')endTouch(event); if(!pointer.down||event.pointerId!==pointer.id)return; viewport.classList.remove('dragging','panning','orbiting'); if(event.type==='pointerup'&&event.button===0&&Math.hypot(event.clientX-pointer.startX,event.clientY-pointer.startY)<4){const rect=viewport.getBoundingClientRect();selectFile(pickAt(event.clientX-rect.left,event.clientY-rect.top));} @@ -909,7 +1023,7 @@ viewport.addEventListener('pointerup',finishPointer); viewport.addEventListener('pointercancel',finishPointer); viewport.addEventListener('pointerleave',()=>{$('#tooltip').hidden=true;}); viewport.addEventListener('contextmenu',event=>{if(renderer.mode!=='2d')event.preventDefault();}); -viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false}); +viewport.addEventListener('wheel',event=>{event.preventDefault();cancelCameraAnimation();if(tour)endTour();if(renderer.mode==='city'){dollyCity(event.deltaY*.00115);dirty=true;return;}if(renderer.mode==='3d'){dolly3d(event.deltaY*.00115);dirty=true;return;}const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,before=renderer.worldAt(x,y),factor=Math.exp(-event.deltaY*.0012);renderer.camera.zoom=Math.max(.25,Math.min(MAX_2D_ZOOM,renderer.camera.zoom*factor));const after=renderer.worldAt(x,y);renderer.camera.x+=before.x-after.x;renderer.camera.y+=before.y-after.y;dirty=true;},{passive:false}); viewport.addEventListener('dblclick',event=>{ const rect=viewport.getBoundingClientRect(),x=event.clientX-rect.left,y=event.clientY-rect.top,file=pickAt(x,y); if(renderer.mode==='city'&&renderer.city.view!=='heli')return; @@ -1083,4 +1197,4 @@ switchTab('inspector'); new ResizeObserver(()=>{dirty=true;}).observe(viewport); $('#settingsButton').title='Workspace settings';$('#settingsButton').setAttribute('aria-label','Workspace settings'); $('#settingsButton svg').innerHTML=''; -renderer.setData([]);fitScene();renderInspector();renderHistory();requestAnimationFrame(animate);checkBackend();loadGithubStatus().then(resumeAfterGithub); +renderer.setData([]);fitScene();renderInspector();renderHistory();requestAnimationFrame(animate);checkBackend();loadGithubStatus().then(()=>/[?&](github|setup_action)=/.test(location.search)?resumeAfterGithub():openPlaceFromHash()); diff --git a/web/city.js b/web/city.js index 7395f8c..b03c8c1 100644 --- a/web/city.js +++ b/web/city.js @@ -261,3 +261,48 @@ export function facadeQuad(b, eye, aspect) { if (height > room) { height = room; width = height * aspect; } return wallQuad(best, 1.2 + height / 2, width, height); } + +// ---- Guided tour, shareable places and touch input ---- + +// Largest districts first, each with a helicopter pose that frames it. +export function tourStops(districts, limit = 6) { + return [...districts].sort((a, b) => b.lines - a.lines).slice(0, limit).map((district, index) => { + const b = district.block; + return { district, pose: { x: b.x + b.w / 2, y: b.y + b.h / 2, distance: Math.max(120, Math.max(b.w, b.h) * 1.35), pitch: .5, yaw: -.6 + index * 1.1 } }; + }); +} + +const CAMERA_FIELDS = { heli: ['x', 'y', 'yaw', 'pitch', 'distance'], walk: ['ex', 'ey', 'ez', 'lookYaw', 'lookPitch'], fly: ['ex', 'ey', 'ez', 'lookYaw', 'lookPitch'] }; +const GITHUB_REPO = /^https:\/\/github\.com\/[\w.-]+\/[\w.-]+$/; + +// A place in a codebase as a URL fragment: repository, view, city camera and selected file. +export function encodePlace({ repo, view, camera, file }) { + const params = new URLSearchParams({ repo, view }); + if (view === 'city' && camera && CAMERA_FIELDS[camera.view]) { + params.set('cam', `${camera.view}:${CAMERA_FIELDS[camera.view].map(key => +camera[key].toFixed(key.startsWith('e') || key === 'x' || key === 'y' || key === 'distance' ? 1 : 3)).join(',')}`); + } + if (file) params.set('file', file); + return `#${params}`; +} + +export function decodePlace(hash) { + const params = new URLSearchParams(String(hash || '').replace(/^#/, '')); + const repo = params.get('repo'); + if (!repo || !GITHUB_REPO.test(repo)) return null; + const view = ['2d', '3d', 'city'].includes(params.get('view')) ? params.get('view') : '2d'; + const place = { repo, view, file: params.get('file') || null, camera: null }; + const [camView, values] = (params.get('cam') || '').split(':'); + const numbers = (values || '').split(',').map(Number); + if (CAMERA_FIELDS[camView] && numbers.length === CAMERA_FIELDS[camView].length && numbers.every(Number.isFinite)) { + place.camera = { view: camView, ...Object.fromEntries(CAMERA_FIELDS[camView].map((key, i) => [key, numbers[i]])) }; + } + return place; +} + +// Virtual joystick: drag offset in pixels to forward/right in -1..1, with a small dead zone. +export function joystick(dx, dy, radius = 56) { + const length = Math.hypot(dx, dy); + if (length < radius * .12) return { forward: 0, right: 0 }; + const scale = Math.min(1, length / radius) / length; + return { forward: -dy * scale, right: dx * scale }; +} diff --git a/web/index.html b/web/index.html index 88df6b6..d68eeb7 100644 --- a/web/index.html +++ b/web/index.html @@ -87,9 +87,15 @@ +
+ + +
+ + diff --git a/web/styles.css b/web/styles.css index d26d23d..9242fa7 100644 --- a/web/styles.css +++ b/web/styles.css @@ -149,7 +149,7 @@ kbd { font: 500 10px/1.5 var(--mono); border: 1px solid var(--line-strong); bord #glCanvas, #overlayCanvas { position: absolute; inset: 0; width: 100%; height: 100%; } #overlayCanvas { pointer-events: none; } -.breadcrumb, .canvas-controls, .camera-help, .legend, .tooltip, .progress-card, .city-hud, .city-target, .city-address, .minimap { +.breadcrumb, .canvas-controls, .camera-help, .legend, .tooltip, .progress-card, .city-hud, .city-target, .city-address, .minimap, .tour-caption { border: 1px solid var(--line); background: var(--surface); -webkit-backdrop-filter: var(--glass); @@ -282,6 +282,16 @@ kbd { font: 500 10px/1.5 var(--mono); border: 1px solid var(--line-strong); bord .city-views button.active { color: #fff; border-color: var(--accent-line); background: var(--accent-soft); } .city-views kbd { padding: 0 4px; font-size: 9px; } .city-help b { color: var(--muted); font-weight: 600; } +.city-actions { display: flex; gap: 2px; padding-left: 8px; border-left: 1px solid var(--line); } +.city-actions button { padding: 5px 10px; border: 1px solid transparent; border-radius: 8px; background: transparent; color: var(--muted); font-size: 11px; font-weight: 500; cursor: pointer; white-space: nowrap; } +.city-actions button:hover { color: #fff; background: var(--hover); } +.city-actions button.active { color: #fff; border-color: var(--accent-line); background: var(--accent-soft); } +.tour-caption { position: absolute; z-index: 2; left: 50%; bottom: 84px; transform: translateX(-50%); display: grid; justify-items: center; gap: 2px; padding: 12px 22px; border-radius: 16px; text-align: center; pointer-events: none; animation: rise .3s ease-out; } +.tour-caption small { color: var(--accent-2); font: 600 10px var(--mono); letter-spacing: .12em; } +.tour-caption strong { color: #fff; font-size: 22px; font-weight: 700; letter-spacing: -.03em; } +.tour-caption span { color: var(--muted); font-size: 11.5px; } +.joystick { position: absolute; z-index: 3; width: 112px; height: 112px; margin: -56px 0 0 -56px; border: 1px solid var(--line-strong); border-radius: 50%; background: rgba(16,19,27,.45); pointer-events: none; } +.joystick i { position: absolute; left: 36px; top: 36px; width: 40px; height: 40px; border-radius: 50%; background: var(--accent-grad); box-shadow: 0 0 16px rgba(95,157,255,.6); } .crosshair { position: absolute; z-index: 2; left: 50%; top: 50%; width: 18px; height: 18px; margin: -9px 0 0 -9px; pointer-events: none; } .crosshair::before, .crosshair::after { content: ""; position: absolute; background: rgba(255,255,255,.85); box-shadow: 0 0 6px rgba(0,0,0,.8); } .crosshair::before { left: 8px; top: 0; width: 2px; height: 18px; } @@ -405,6 +415,9 @@ html[data-input-method="pointer"] button:focus { outline: none; } .welcome { padding: 20px; } .welcome-features { display: none; } .camera-help { left: 10px; top: 10px; gap: 8px; font-size: 9.5px; } + .city-hud { left: 10px; top: 10px; gap: 6px; } + .city-help, .minimap { display: none; } + .city-address { left: 10px; top: 58px; } .breadcrumb { left: 10px; bottom: 10px; } .canvas-controls { right: 10px; bottom: 10px; } } diff --git a/webtest/place.test.mjs b/webtest/place.test.mjs new file mode 100644 index 0000000..a899ec3 --- /dev/null +++ b/webtest/place.test.mjs @@ -0,0 +1,37 @@ +import { test } from 'node:test'; +import assert from 'node:assert/strict'; +import { tourStops, encodePlace, decodePlace, joystick } from '../web/city.js'; + +test('tour visits the largest districts first', () => { + const districts = [5, 50, 20].map((lines, i) => ({ lines, block: { x: i * 100, y: 0, w: 80, h: 40, node: { name: `d${i}` } } })); + const stops = tourStops(districts, 2); + assert.deepEqual(stops.map(stop => stop.district.lines), [50, 20]); + assert.equal(stops[0].pose.x, 140); + assert.ok(stops[0].pose.distance >= 120); +}); + +test('places round-trip through the URL fragment', () => { + const camera = { view: 'walk', ex: 12.345, ey: 67.891, ez: 1.7, lookYaw: 1.23456, lookPitch: -.1 }; + const hash = encodePlace({ repo: 'https://github.com/prometheus/prometheus', view: 'city', camera, file: 'cmd/prometheus/main.go' }); + const place = decodePlace(hash); + assert.equal(place.repo, 'https://github.com/prometheus/prometheus'); + assert.equal(place.file, 'cmd/prometheus/main.go'); + assert.deepEqual(place.camera, { view: 'walk', ex: 12.3, ey: 67.9, ez: 1.7, lookYaw: 1.235, lookPitch: -.1 }); + assert.equal(decodePlace(encodePlace({ repo: 'https://github.com/a/b', view: '3d' })).camera, null); +}); + +test('untrusted fragments are rejected or sanitised', () => { + assert.equal(decodePlace('#repo=https://evil.example/a/b'), null); + assert.equal(decodePlace('#repo=javascript:alert(1)'), null); + const place = decodePlace('#repo=https://github.com/a/b&view=bogus&cam=walk:1,2,NaN,4,5'); + assert.equal(place.view, '2d'); + assert.equal(place.camera, null); +}); + +test('joystick maps drag to movement with a dead zone and clamp', () => { + assert.deepEqual(joystick(2, 2), { forward: 0, right: 0 }); + const full = joystick(0, -200); + assert.ok(Math.abs(full.forward - 1) < 1e-9 && Math.abs(full.right) < 1e-9); + const half = joystick(28, 0); + assert.ok(Math.abs(half.right - .5) < 1e-9); +});