Touch controls, guided city tour and shareable places

Touch: in walk and fly the left thumb drives a virtual joystick while
the right thumb looks around; pinch zooms in every view (and climbs
when flying); help text switches for coarse pointers.

Tour (T or the Tour button) flies the helicopter over the six largest
districts with captions of their file and line counts, circling each
before moving on, and stops on any input.

Copy link encodes the GitHub repository, view, city camera and selected
file in the URL fragment. Opening such a link loads the repository
(including through GitHub sign-in for private repos) and restores the
place. Fragments are validated: only github.com repositories and finite
camera values are accepted. Helpers are unit tested.
This commit is contained in:
Andy Dixon
2026-05-31 21:36:54 +01:00
parent f035ecb4a5
commit dd8976b13c
5 changed files with 225 additions and 10 deletions
+123 -9
View File
@@ -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:'<b>Drag</b> orbit · <b>Pinch</b> zoom · <b>Double-tap</b> a street to walk',
walk:'<b>Left thumb</b> move · <b>Right thumb</b> look · <b>Tap</b> a building to inspect',
fly:'<b>Left thumb</b> fly · <b>Right thumb</b> look · <b>Pinch</b> climb',
}:{
heli:'<b>Drag</b> orbit · <b>Shift+drag</b> pan · <b>Scroll</b> zoom · <b>Double-click</b> a street to walk',
walk:'<b>Click</b> to look · <b>WASD</b> move · <b>Shift</b> run · <b>E</b> inspect · <b>Esc</b> release mouse',
fly:'<b>Click</b> to look · <b>WASD</b> fly · <b>Space</b>/<b>C</b> up/down · <b>Shift</b> fast · <b>E</b> 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<rect.width/2){
joy={id:event.pointerId,x0:event.clientX,y0:event.clientY};
const stick=$('#joystick');stick.hidden=false;stick.style.left=`${x}px`;stick.style.top=`${y}px`;stick.firstElementChild.style.transform='';
viewport.setPointerCapture(event.pointerId);event.preventDefault();return true;
}
return false;
}
function moveTouch(event){
if(!touches.has(event.pointerId))return false;
touches.set(event.pointerId,{x:event.clientX,y:event.clientY});
if(pinch&&touches.size>=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=`<small>${tour.index+1} / ${tour.stops.length}</small><strong>${escapeHtml(district.block.node.name)}</strong><span>${format(district.files)} files · ${format(district.lines)} lines</span>`;
}
function advanceTour(now,seconds){
if(!cameraAnimation&&now<tour.dwellUntil){renderer.city.yaw+=seconds*.1;dirty=true;}
else if(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)}<small>${escapeHtml(item.path)} · ${format(item.lines)} lines</small>`;}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='<circle cx="12" cy="12" r="3"/><path d="m9.5 3-.5 2-2 1-2-.5L3 9l1.5 1.5v3L3 15l2 3.5 2-.5 2 1 .5 2h5l.5-2 2-1 2 .5 2-3.5-1.5-1.5v-3L21 9l-2-3.5-2 .5-2-1-.5-2z"/>';
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());
+45
View File
@@ -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 };
}
+6
View File
@@ -87,9 +87,15 @@
<button type="button" data-city-view="walk" title="Walk (2)"><kbd>2</kbd> Walk</button>
<button type="button" data-city-view="fly" title="Fly (3)"><kbd>3</kbd> Fly</button>
</div>
<div class="city-actions">
<button type="button" id="tourButton" title="Guided tour (T)">Tour</button>
<button type="button" id="shareButton" title="Copy a link to this place">Copy link</button>
</div>
<span class="city-help" id="cityHelp"></span>
</div>
<div class="crosshair" id="crosshair" hidden></div>
<div class="tour-caption" id="tourCaption" hidden></div>
<div class="joystick" id="joystick" hidden><i></i></div>
<div class="city-target" id="cityTarget" hidden></div>
<div class="city-address" id="cityAddress" hidden></div>
<canvas class="minimap" id="minimap" width="220" height="150" hidden title="Click to travel"></canvas>
+14 -1
View File
@@ -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; }
}
+37
View File
@@ -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);
});