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);
+});