A Pen by Dan Brickley on CodePen.
Created
June 17, 2025 20:30
-
-
Save danbri/2aa7dc4cd178bf7ee2e33898a014481a to your computer and use it in GitHub Desktop.
Tnk2
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!DOCTYPE html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="UTF-8"> | |
| <meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no, viewport-fit=cover"> | |
| <meta name="apple-mobile-web-app-capable" content="yes"> | |
| <meta name="apple-mobile-web-app-status-bar-style" content="black-translucent"> | |
| <meta name="mobile-web-app-capable" content="yes"> | |
| <title>Bristol Vector Hunt - LOD</title> | |
| <script src="https://cdnjs.cloudflare.com/ajax/libs/three.js/r128/three.min.js"></script> | |
| <style> | |
| @import url('https://fonts.googleapis.com/css2?family=Orbitron:wght@400;700&display=swap'); | |
| * { margin: 0; padding: 0; box-sizing: border-box; -webkit-tap-highlight-color: transparent; -webkit-touch-callout: none; -webkit-user-select: none; user-select: none; } | |
| body { background: #000; color: #00ff00; font-family: 'Orbitron', monospace; overflow: hidden; position: fixed; width: 100%; height: 100%; touch-action: none; -webkit-overflow-scrolling: none; } | |
| #container { position: relative; width: 100vw; height: 100vh; height: 100dvh; } | |
| #hud { position: absolute; top: max(10px, env(safe-area-inset-top)); left: max(10px, env(safe-area-inset-left)); right: max(70px, env(safe-area-inset-right) + 60px); z-index: 100; background: rgba(0, 0, 0, 0.9); border: 2px solid #00ff00; padding: 12px; font-size: 11px; box-shadow: 0 0 15px #00ff00; border-radius: 8px; backdrop-filter: blur(10px); } | |
| #dev-toggle { position: absolute; top: max(10px, env(safe-area-inset-top)); right: max(10px, env(safe-area-inset-right)); width: 50px; height: 50px; border: 2px solid #ff6600; background: rgba(0, 0, 0, 0.9); color: #ff6600; font-size: 18px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; z-index: 101; touch-action: manipulation; backdrop-filter: blur(10px); } | |
| #dev-toggle:active { transform: scale(0.95); background: rgba(255, 102, 0, 0.3); } | |
| #dev-panel { position: absolute; top: max(70px, env(safe-area-inset-top) + 60px); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); z-index: 200; background: rgba(0, 0, 0, 0.95); border: 2px solid #ff6600; padding: 15px; font-size: 10px; max-height: 50vh; overflow-y: auto; border-radius: 8px; transform: translateY(-120%); transition: transform 0.3s ease; backdrop-filter: blur(10px); -webkit-overflow-scrolling: touch; } | |
| #dev-panel.visible { transform: translateY(0); } | |
| .dev-btn { background: rgba(255, 102, 0, 0.2); border: 1px solid #ff6600; color: #ff6600; padding: 12px 16px; margin: 6px 3px; cursor: pointer; font-family: 'Orbitron', monospace; font-size: 10px; border-radius: 6px; touch-action: manipulation; display: inline-block; min-height: 44px; line-height: 1.2; } | |
| .dev-btn:active { background: rgba(255, 102, 0, 0.4); transform: scale(0.98); } | |
| #loading { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%); z-index: 200; text-align: center; font-size: 16px; text-shadow: 0 0 20px #00ff00; padding: 20px; background: rgba(0, 0, 0, 0.8); border-radius: 10px; border: 2px solid #00ff00; } | |
| #touch-controls { position: absolute; bottom: max(20px, env(safe-area-inset-bottom) + 10px); left: 50%; transform: translateX(-50%); z-index: 100; display: flex; gap: 15px; align-items: center; } | |
| .touch-control { width: 60px; height: 60px; border: 2px solid #00ff00; background: rgba(0, 0, 0, 0.8); color: #00ff00; font-size: 14px; border-radius: 50%; display: flex; align-items: center; justify-content: center; cursor: pointer; touch-action: manipulation; backdrop-filter: blur(10px); transition: all 0.1s; } | |
| .touch-control:active { background: rgba(0, 255, 0, 0.3); box-shadow: 0 0 20px #00ff00; transform: scale(0.95); } | |
| #view-info { position: absolute; bottom: max(100px, env(safe-area-inset-bottom) + 90px); left: max(10px, env(safe-area-inset-left)); right: max(10px, env(safe-area-inset-right)); z-index: 100; background: rgba(0, 0, 0, 0.9); border: 2px solid #00ff00; padding: 10px; font-size: 10px; text-align: center; border-radius: 8px; backdrop-filter: blur(10px); } | |
| canvas { display: block; touch-action: none; } | |
| @media (display-mode: standalone) { body { background: #000; user-select: none; } } | |
| @media (orientation: landscape) and (max-height: 500px) { #hud { font-size: 9px; padding: 8px; } #view-info { bottom: max(80px, env(safe-area-inset-bottom) + 70px); font-size: 9px; padding: 8px; } .touch-control { width: 50px; height: 50px; font-size: 12px; } } | |
| @media (min-width: 768px) { #hud { font-size: 12px; padding: 15px; } .dev-btn { font-size: 11px; padding: 10px 14px; } .touch-control { width: 70px; height: 70px; font-size: 16px; } } | |
| @media (-webkit-min-device-pixel-ratio: 2) { canvas { image-rendering: -webkit-optimize-contrast; } } | |
| input, select, textarea { font-size: 16px; } | |
| @supports (-webkit-touch-callout: none) { #container { height: 100vh; height: -webkit-fill-available; } } | |
| </style> | |
| </head> | |
| <body> | |
| <div id="container"> | |
| <div id="loading"> | |
| <div>🗺️ LOADING BRISTOL DATA</div> | |
| <div style="font-size: 12px; margin-top: 10px;">Checking cache...</div> | |
| </div> | |
| <div id="hud"> | |
| <div><strong>BRISTOL OSM VIEWER - LOD</strong></div> | |
| <div>Source: <span id="data-source">Unknown</span></div> | |
| <div>Buildings: <span id="building-count">0</span> | Roads: <span id="road-count">0</span></div> | |
| <div>Status: <span id="status">Loading...</span></div> | |
| <div>FPS: <span id="fps-counter">--</span></div> | |
| </div> | |
| <div id="dev-toggle" title="Dev Panel">⚙</div> | |
| <div id="dev-panel"> | |
| <div style="color: #ff6600; font-weight: bold; margin-bottom: 10px;">🛠️ DEV PANEL</div> | |
| <div style="margin-bottom: 10px; line-height: 1.3;"> | |
| <div>Strategy: geodataCache.json → Overpass API → Fallback</div> | |
| <div>Bounds: Bristol ±0.02° lat/lng</div> | |
| <div>LOD: Buildings <600, Roads <400 (minor), <1200 (major)</div> | |
| </div> | |
| <div style="margin: 10px 0;"> | |
| <button class="dev-btn" id="export-btn">📦 Export Cache</button> | |
| <button class="dev-btn" id="reload-api-btn">🔄 Reload API</button> | |
| <button class="dev-btn" id="clear-cache-btn">🗑️ Clear</button> | |
| </div> | |
| <div style="margin: 10px 0;"> | |
| <button class="dev-btn" id="test-minimal-btn">🧪 Test Data</button> | |
| <button class="dev-btn" id="show-bounds-btn">📍 Show Bounds</button> | |
| <button class="dev-btn" id="reset-view-btn">🎯 Reset View</button> | |
| </div> | |
| <div style="font-size: 9px; color: #888; margin-top: 10px; line-height: 1.3;"> | |
| 💡 Export saves to Downloads<br> | |
| 📁 Place geodataCache.json in same directory<br> | |
| 🚀 Commit to repo for offline use | |
| </div> | |
| </div> | |
| <div id="view-info"> | |
| <div><strong>TOUCH CONTROLS</strong></div> | |
| <div>Pinch: Zoom | Drag: Orbit | Two-finger drag: Pan</div> | |
| <div>Camera: <span id="camera-info">0, 0, 0</span></div> | |
| </div> | |
| <div id="touch-controls"> | |
| <div class="touch-control" id="zoom-out-btn">−</div> | |
| <div class="touch-control" id="reset-btn">⌂</div> | |
| <div class="touch-control" id="zoom-in-btn">+</div> | |
| </div> | |
| </div> | |
| <script> | |
| // Global state - simplified for 10x FPS | |
| let scene, camera, renderer; | |
| let allBuildings = [], allRoads = [], markers = []; // Changed from pools to hold all meshes | |
| let bristolGeodata = null; | |
| // Constants - optimized distances | |
| const BRISTOL_CENTER = { lat: 51.4545, lng: -2.5879 }; | |
| const BRISTOL_BOUNDS = { north: 51.4745, south: 51.4345, east: -2.5579, west: -2.6179 }; | |
| const SCALE_FACTOR = 100000; | |
| const LONGITUDE_SCALE_CORRECTION = Math.cos(BRISTOL_CENTER.lat * Math.PI / 180); | |
| const LOD_BUILDING_CULL = 600; // Original value, keeping for reference | |
| const LOD_ROAD_MAJOR_CULL = 1200; // Original value, keeping for reference | |
| const LOD_ROAD_MINOR_CULL = 400; // Original value, keeping for reference | |
| const MAX_VISIBLE_BUILDINGS = 200; // Cap visible objects | |
| const MAX_VISIBLE_ROADS = 100; | |
| // Controls | |
| let touches = [], lastTouchDistance = 0, lastTouchCenter = { x: 0, y: 0 }; | |
| let cameraRotationX = 0.5, cameraRotationY = 0, cameraDistance = 300; | |
| let cameraTarget = new THREE.Vector3(0, 0, 0); | |
| let isMouseDown = false, lastMouseX = 0, lastMouseY = 0; | |
| // Performance | |
| const isMobile = /Android|webOS|iPhone|iPad|iPod|BlackBerry|IEMobile|Opera Mini/i.test(navigator.userAgent); | |
| let lastFrameTime = performance.now(), frameCount = 0; | |
| let lastCullTime = 0; | |
| async function init() { | |
| setupThreeJS(); | |
| setupControls(); | |
| setupDevPanel(); | |
| bristolGeodata = await loadBristolGeodata(); | |
| renderBristolData(); | |
| animate(); | |
| document.getElementById('loading').style.display = 'none'; | |
| document.getElementById('status').textContent = 'Ready'; | |
| if (isMobile && 'wakeLock' in navigator) { | |
| try { await navigator.wakeLock.request('screen'); } catch {} | |
| } | |
| } | |
| function setupThreeJS() { | |
| scene = new THREE.Scene(); | |
| scene.background = new THREE.Color(0x000000); | |
| scene.fog = new THREE.Fog(0x000000, 800, 1500); // Add fog for distant objects | |
| camera = new THREE.PerspectiveCamera(75, window.innerWidth / window.innerHeight, 1, 2000); // Reduced far plane | |
| updateCameraPosition(); | |
| renderer = new THREE.WebGLRenderer({ | |
| antialias: false, // Disabled for FPS | |
| powerPreference: 'high-performance', | |
| alpha: false | |
| }); | |
| renderer.setPixelRatio(isMobile ? 1 : Math.min(window.devicePixelRatio, 1.5)); // Capped pixel ratio | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| renderer.shadowMap.enabled = false; // Disabled shadows | |
| document.getElementById('container').appendChild(renderer.domElement); | |
| // Minimal lighting | |
| scene.add(new THREE.AmbientLight(0x404040, 0.8)); | |
| // Simplified grid | |
| const grid = new THREE.GridHelper(1000, 10, 0x004400, 0x002200); // Fewer lines | |
| scene.add(grid); | |
| } | |
| function setupControls() { | |
| const canvas = renderer.domElement; | |
| canvas.addEventListener('touchstart', handleTouchStart, { passive: false }); | |
| canvas.addEventListener('touchmove', handleTouchMove, { passive: false }); | |
| canvas.addEventListener('touchend', handleTouchEnd, { passive: false }); | |
| canvas.addEventListener('mousedown', e => { isMouseDown = true; lastMouseX = e.clientX; lastMouseY = e.clientY; }); | |
| canvas.addEventListener('mousemove', handleMouseMove); | |
| canvas.addEventListener('mouseup', () => isMouseDown = false); | |
| canvas.addEventListener('wheel', e => { e.preventDefault(); cameraDistance += e.deltaY * 0.2; cameraDistance = Math.max(50, Math.min(1500, cameraDistance)); updateCameraPosition(); }, { passive: false }); | |
| canvas.addEventListener('contextmenu', e => e.preventDefault()); | |
| document.getElementById('zoom-in-btn').addEventListener('click', () => { cameraDistance -= 50; cameraDistance = Math.max(50, cameraDistance); updateCameraPosition(); }); | |
| document.getElementById('zoom-out-btn').addEventListener('click', () => { cameraDistance += 50; cameraDistance = Math.min(1500, cameraDistance); updateCameraPosition(); }); | |
| document.getElementById('reset-btn').addEventListener('click', resetView); | |
| } | |
| function handleTouchStart(e) { | |
| e.preventDefault(); | |
| touches = Array.from(e.touches); | |
| if (touches.length === 1) lastTouchCenter = { x: touches[0].clientX, y: touches[0].clientY }; | |
| else if (touches.length === 2) { lastTouchDistance = getTouchDistance(); lastTouchCenter = getTouchCenter(); } | |
| } | |
| function handleTouchMove(e) { | |
| e.preventDefault(); | |
| const currentTouches = Array.from(e.touches); | |
| if (currentTouches.length === 1 && touches.length === 1) { | |
| const deltaX = currentTouches[0].clientX - lastTouchCenter.x; | |
| const deltaY = currentTouches[0].clientY - lastTouchCenter.y; | |
| cameraRotationY -= deltaX * 0.005; | |
| cameraRotationX -= deltaY * 0.005; | |
| cameraRotationX = Math.max(-Math.PI/2 + 0.01, Math.min(Math.PI/2 - 0.01, cameraRotationX)); | |
| lastTouchCenter = { x: currentTouches[0].clientX, y: currentTouches[0].clientY }; | |
| updateCameraPosition(); | |
| } else if (currentTouches.length === 2 && touches.length === 2) { | |
| const currentDistance = getTouchDistance(currentTouches); | |
| const currentCenter = getTouchCenter(currentTouches); | |
| cameraDistance -= (currentDistance - lastTouchDistance) * 0.5; | |
| cameraDistance = Math.max(50, Math.min(1500, cameraDistance)); | |
| const panSpeed = 0.5 * (cameraDistance / 300); | |
| const panDeltaX = (currentCenter.x - lastTouchCenter.x) * panSpeed; | |
| const panDeltaZ = (currentCenter.y - lastTouchCenter.y) * panSpeed; | |
| const forward = new THREE.Vector3(); | |
| camera.getWorldDirection(forward); | |
| forward.y = 0; | |
| forward.normalize(); | |
| const right = new THREE.Vector3().crossVectors(camera.up, forward).normalize(); | |
| cameraTarget.add(right.multiplyScalar(-panDeltaX)); | |
| cameraTarget.add(forward.multiplyScalar(panDeltaZ)); | |
| lastTouchDistance = currentDistance; | |
| lastTouchCenter = currentCenter; | |
| updateCameraPosition(); | |
| } | |
| touches = currentTouches; | |
| } | |
| function handleTouchEnd(e) { e.preventDefault(); touches = Array.from(e.touches); } | |
| function getTouchDistance(touchArray = touches) { | |
| if (touchArray.length < 2) return 0; | |
| const dx = touchArray[0].clientX - touchArray[1].clientX; | |
| const dy = touchArray[0].clientY - touchArray[1].clientY; | |
| return Math.sqrt(dx * dx + dy * dy); | |
| } | |
| function getTouchCenter(touchArray = touches) { | |
| if (touchArray.length < 2) return { x: 0, y: 0 }; | |
| return { x: (touchArray[0].clientX + touchArray[1].clientX) / 2, y: (touchArray[0].clientY + touchArray[1].clientY) / 2 }; | |
| } | |
| function handleMouseMove(e) { | |
| if (!isMouseDown) return; | |
| const deltaX = e.clientX - lastMouseX; | |
| const deltaY = e.clientY - lastMouseY; | |
| if (e.buttons === 1) { | |
| cameraRotationY -= deltaX * 0.005; | |
| cameraRotationX -= deltaY * 0.005; | |
| cameraRotationX = Math.max(-Math.PI/2 + 0.01, Math.min(Math.PI/2 - 0.01, cameraRotationX)); | |
| updateCameraPosition(); | |
| } | |
| lastMouseX = e.clientX; | |
| lastMouseY = e.clientY; | |
| } | |
| function resetView() { | |
| cameraRotationX = 0.5; | |
| cameraRotationY = 0; | |
| cameraDistance = 300; | |
| cameraTarget.set(0, 0, 0); | |
| updateCameraPosition(); | |
| } | |
| function updateCameraPosition() { | |
| const x = cameraTarget.x + Math.sin(cameraRotationY) * Math.cos(cameraRotationX) * cameraDistance; | |
| const y = cameraTarget.y + Math.sin(cameraRotationX) * cameraDistance; | |
| const z = cameraTarget.z + Math.cos(cameraRotationY) * Math.cos(cameraRotationX) * cameraDistance; | |
| camera.position.set(x, y, z); | |
| camera.lookAt(cameraTarget); | |
| document.getElementById('camera-info').textContent = `${Math.round(x)}, ${Math.round(y)}, ${Math.round(z)}`; | |
| } | |
| function setupDevPanel() { | |
| const devToggle = document.getElementById('dev-toggle'); | |
| const devPanel = document.getElementById('dev-panel'); | |
| devToggle.addEventListener('click', () => devPanel.classList.toggle('visible')); | |
| document.getElementById('export-btn').addEventListener('click', exportGeodata); | |
| document.getElementById('reload-api-btn').addEventListener('click', reloadFromAPI); | |
| document.getElementById('clear-cache-btn').addEventListener('click', clearCache); | |
| document.getElementById('test-minimal-btn').addEventListener('click', testMinimalData); | |
| document.getElementById('show-bounds-btn').addEventListener('click', showBounds); | |
| document.getElementById('reset-view-btn').addEventListener('click', resetView); | |
| } | |
| async function loadBristolGeodata() { | |
| document.getElementById('status').textContent = 'Checking cache...'; | |
| try { | |
| const response = await fetch('./geodataCache.json'); | |
| if (response.ok) { | |
| const data = await response.json(); | |
| document.getElementById('data-source').textContent = 'Cache'; | |
| return data; | |
| } | |
| } catch {} | |
| document.getElementById('status').textContent = 'Fetching from API...'; | |
| document.getElementById('data-source').textContent = 'Live API'; | |
| try { | |
| return await fetchFromOverpassAPI(); | |
| } catch (error) { | |
| console.error("Failed to fetch from Overpass API:", error); | |
| document.getElementById('data-source').textContent = 'Fallback'; | |
| document.getElementById('status').textContent = 'Using fallback'; | |
| return createMinimalBristolData(); | |
| } | |
| } | |
| async function fetchFromOverpassAPI() { | |
| // Query for buildings and highways (primary, secondary, tertiary) within Bristol bounds | |
| const query = `[out:json][timeout:20]; | |
| ( | |
| way["building"](${BRISTOL_BOUNDS.south},${BRISTOL_BOUNDS.west},${BRISTOL_BOUNDS.north},${BRISTOL_BOUNDS.east}); | |
| way["highway"~"^(primary|secondary|tertiary)$"](${BRISTOL_BOUNDS.south},${BRISTOL_BOUNDS.west},${BRISTOL_BOUNDS.north},${BRISTOL_BOUNDS.east}); | |
| ); | |
| out geom;`; | |
| const response = await fetch('https://overpass-api.de/api/interpreter', { | |
| method: 'POST', | |
| headers: { 'Content-Type': 'text/plain' }, | |
| body: query | |
| }); | |
| if (!response.ok) throw new Error(`API Error: ${response.status} - ${response.statusText}`); | |
| const data = await response.json(); | |
| return processOverpassData(data); | |
| } | |
| function processOverpassData(data) { | |
| const processed = { buildings: [], roads: [], timestamp: new Date().toISOString(), bounds: BRISTOL_BOUNDS, source: 'overpass-api' }; | |
| data.elements.forEach(element => { | |
| if (element.type === 'way' && element.geometry?.length > 0) { | |
| if (element.tags?.building) { | |
| const building = processBuildingWay(element); | |
| if (building) processed.buildings.push(building); | |
| } else if (element.tags?.highway) { | |
| const road = processRoadWay(element); | |
| if (road) processed.roads.push(road); | |
| } | |
| } | |
| }); | |
| return processed; | |
| } | |
| function processBuildingWay(way) { | |
| const validGeometry = way.geometry.filter(node => typeof node.lat === 'number' && typeof node.lon === 'number'); | |
| if (validGeometry.length < 3) return null; | |
| const coords = validGeometry.map(node => ({ | |
| x: (node.lon - BRISTOL_CENTER.lng) * SCALE_FACTOR * LONGITUDE_SCALE_CORRECTION, | |
| z: -(node.lat - BRISTOL_CENTER.lat) * SCALE_FACTOR | |
| })); | |
| const bounds = coords.reduce((acc, coord) => ({ | |
| minX: Math.min(acc.minX, coord.x), maxX: Math.max(acc.maxX, coord.x), | |
| minZ: Math.min(acc.minZ, coord.z), maxZ: Math.max(acc.maxZ, coord.z) | |
| }), { minX: Infinity, maxX: -Infinity, minZ: Infinity, maxZ: -Infinity }); | |
| const width = Math.max(2, bounds.maxX - bounds.minX); | |
| const depth = Math.max(2, bounds.maxZ - bounds.minZ); | |
| const centerX = (bounds.minX + bounds.maxX) / 2; | |
| const centerZ = (bounds.minZ + bounds.maxZ) / 2; | |
| let height = 10; | |
| if (way.tags?.['building:levels']) height = Math.max(3, parseInt(way.tags['building:levels']) * 3.5 || 10); | |
| else if (way.tags?.height) height = Math.max(3, parseFloat(way.tags.height) || 10); | |
| else { | |
| const type = way.tags.building; | |
| if (type === 'church' || type === 'cathedral') height = 30; | |
| else if (type === 'university' || type === 'college') height = 20; | |
| else if (type === 'hospital') height = 25; | |
| else if (type === 'office' || type === 'commercial') height = 15; | |
| else if (type === 'apartments' || type === 'residential') height = 7; | |
| } | |
| return { id: way.id, center: { x: centerX, z: centerZ }, dimensions: { width, height, depth }, tags: way.tags || {} }; | |
| } | |
| function processRoadWay(way) { | |
| const validGeometry = way.geometry.filter(node => typeof node.lat === 'number' && typeof node.lon === 'number'); | |
| if (validGeometry.length < 2) return null; | |
| const coords = validGeometry.map(node => ({ | |
| x: (node.lon - BRISTOL_CENTER.lng) * SCALE_FACTOR * LONGITUDE_SCALE_CORRECTION, | |
| z: -(node.lat - BRISTOL_CENTER.lat) * SCALE_FACTOR | |
| })); | |
| return { id: way.id, coords, tags: way.tags || {}, highway: way.tags?.highway || 'unknown' }; | |
| } | |
| function createMinimalBristolData() { | |
| return { | |
| buildings: [ | |
| { center: { x: 0, z: 0 }, dimensions: { width: 30, height: 20, depth: 20 }, tags: { name: "Central" } }, | |
| { center: { x: -100, z: 50 }, dimensions: { width: 20, height: 15, depth: 40 }, tags: { name: "Side" } } | |
| ], | |
| roads: [ | |
| { coords: [{ x: -200, z: 0 }, { x: 200, z: 0 }], highway: 'primary', tags: { name: 'Main' } }, | |
| { coords: [{ x: 0, z: -200 }, { x: 0, z: 200 }], highway: 'secondary', tags: { name: 'Cross' } } | |
| ], | |
| timestamp: new Date().toISOString(), bounds: BRISTOL_BOUNDS, source: 'minimal-fallback' | |
| }; | |
| } | |
| function renderBristolData() { | |
| if (!bristolGeodata) return; | |
| clearScene(); | |
| allBuildings = []; | |
| allRoads = []; | |
| bristolGeodata.buildings.filter(b => b).forEach(building => { | |
| const mesh = createBuildingMesh(building.center.x, building.center.z, building.dimensions.width, building.dimensions.height, building.dimensions.depth, building.tags); | |
| if (mesh) { | |
| mesh.userData = { ...building, type: 'building' }; | |
| allBuildings.push(mesh); | |
| } | |
| }); | |
| bristolGeodata.roads.filter(r => r).forEach(road => { | |
| const mesh = createRoadMesh(road.coords, road.highway, road.tags); | |
| if (mesh) { | |
| mesh.userData = { ...road, type: 'road' }; | |
| allRoads.push(mesh); | |
| } | |
| }); | |
| document.getElementById('building-count').textContent = allBuildings.length; | |
| document.getElementById('road-count').textContent = allRoads.length; | |
| // Initial cull | |
| performFrustumCulling(); | |
| } | |
| function clearScene() { | |
| // Remove all objects from the scene before repopulating | |
| allBuildings.forEach(mesh => scene.remove(mesh)); | |
| allRoads.forEach(mesh => scene.remove(mesh)); | |
| markers.forEach(marker => scene.remove(marker)); | |
| allBuildings = []; | |
| allRoads = []; | |
| markers = []; | |
| } | |
| function createBuildingMesh(x, z, width, height, depth, tags = {}) { | |
| const geometry = new THREE.BoxGeometry(width, height, depth); | |
| const edges = new THREE.EdgesGeometry(geometry); | |
| let color = 0x00ff00; | |
| if (tags.building) { | |
| switch (tags.building) { | |
| case 'church': case 'cathedral': color = 0xffff00; break; | |
| case 'university': case 'school': color = 0xff00ff; break; | |
| case 'hospital': color = 0xff6666; break; | |
| case 'commercial': case 'office': case 'retail': color = 0x00ffff; break; | |
| case 'industrial': color = 0xffaa00; break; | |
| default: color = 0x00cc00; | |
| } | |
| } | |
| const material = new THREE.LineBasicMaterial({ color }); | |
| const wireframe = new THREE.LineSegments(edges, material); | |
| wireframe.position.set(x, height / 2, z); | |
| return wireframe; | |
| } | |
| function createRoadMesh(coords, highway = 'unknown', tags = {}) { | |
| if (coords.length < 2) return null; | |
| const points = coords.map(coord => new THREE.Vector3(coord.x, 0.1, coord.z)); | |
| const geometry = new THREE.BufferGeometry().setFromPoints(points); | |
| // Important: Compute bounding sphere for BufferGeometry for correct culling | |
| geometry.computeBoundingSphere(); | |
| let color = 0x004400; | |
| switch(highway) { | |
| case 'primary': color = 0x009900; break; | |
| case 'secondary': color = 0x007700; break; | |
| case 'tertiary': color = 0x005500; break; | |
| default: color = 0x003300; | |
| } | |
| const material = new THREE.LineBasicMaterial({ color }); | |
| const line = new THREE.Line(geometry, material); | |
| return line; | |
| } | |
| function performFrustumCulling() { | |
| const now = performance.now(); | |
| if (now - lastCullTime < 100) return; // Throttle culling to 10fps | |
| lastCullTime = now; | |
| const camPos = camera.position; | |
| const frustum = new THREE.Frustum(); | |
| const matrix = new THREE.Matrix4().multiplyMatrices(camera.projectionMatrix, camera.matrixWorldInverse); | |
| frustum.setFromProjectionMatrix(matrix); | |
| // Process buildings | |
| let buildingsAdded = 0; | |
| for (const mesh of allBuildings) { | |
| const dist = camPos.distanceTo(mesh.position); | |
| const isInFrustum = frustum.intersectsObject(mesh); // Check if visible within camera view | |
| if (dist <= LOD_BUILDING_CULL && isInFrustum && buildingsAdded < MAX_VISIBLE_BUILDINGS) { | |
| if (!mesh.parent) { // Only add if not already in scene | |
| scene.add(mesh); | |
| buildingsAdded++; | |
| } | |
| } else { | |
| if (mesh.parent) { // Only remove if currently in scene | |
| scene.remove(mesh); | |
| } | |
| } | |
| } | |
| // Process roads | |
| let roadsAdded = 0; | |
| for (const mesh of allRoads) { | |
| // Use mesh.position for distance, as it's the center of the road line after initial setup | |
| // Or if you need precise center for complex lines, calculate it manually from line's points. | |
| // For simple lines, mesh.geometry.boundingSphere.center (after computeBoundingSphere) | |
| // transformed by mesh.matrixWorld gives the world center. | |
| const roadWorldCenter = new THREE.Vector3(); | |
| if (mesh.geometry.boundingSphere) { | |
| roadWorldCenter.copy(mesh.geometry.boundingSphere.center); | |
| mesh.localToWorld(roadWorldCenter); // Transform local center to world coordinates | |
| } else { | |
| // Fallback if boundingSphere is not computed (shouldn't happen with fix) | |
| // Use a heuristic, e.g., the first point's world position or simply mesh.position | |
| roadWorldCenter.copy(mesh.position); | |
| } | |
| const dist = camPos.distanceTo(roadWorldCenter); | |
| const maxDist = mesh.userData.highway === 'primary' || mesh.userData.highway === 'secondary' ? LOD_ROAD_MAJOR_CULL : LOD_ROAD_MINOR_CULL; | |
| const isInFrustum = frustum.intersectsObject(mesh); | |
| if (dist <= maxDist && isInFrustum && roadsAdded < MAX_VISIBLE_ROADS) { | |
| if (!mesh.parent) { | |
| scene.add(mesh); | |
| roadsAdded++; | |
| } | |
| } else { | |
| if (mesh.parent) { | |
| scene.remove(mesh); | |
| } | |
| } | |
| } | |
| } | |
| function showBounds() { | |
| markers.forEach(marker => scene.remove(marker)); | |
| markers = []; | |
| const corners = [ | |
| { lat: BRISTOL_BOUNDS.north, lng: BRISTOL_BOUNDS.west }, | |
| { lat: BRISTOL_BOUNDS.north, lng: BRISTOL_BOUNDS.east }, | |
| { lat: BRISTOL_BOUNDS.south, lng: BRISTOL_BOUNDS.east }, | |
| { lat: BRISTOL_BOUNDS.south, lng: BRISTOL_BOUNDS.west } | |
| ]; | |
| corners.forEach(corner => { | |
| const x = (corner.lng - BRISTOL_CENTER.lng) * SCALE_FACTOR * LONGITUDE_SCALE_CORRECTION; | |
| const z = -(corner.lat - BRISTOL_CENTER.lat) * SCALE_FACTOR; | |
| const geometry = new THREE.SphereGeometry(10, 8, 6); | |
| const material = new THREE.MeshBasicMaterial({ color: 0xff0000, wireframe: true }); | |
| const sphere = new THREE.Mesh(geometry, material); | |
| sphere.position.set(x, 20, z); | |
| scene.add(sphere); | |
| markers.push(sphere); | |
| }); | |
| } | |
| function exportGeodata() { | |
| if (!bristolGeodata || bristolGeodata.source === 'minimal-fallback') { | |
| alert("No significant data to export"); | |
| return; | |
| } | |
| const dataStr = JSON.stringify(bristolGeodata, null, 2); | |
| const dataBlob = new Blob([dataStr], { type: 'application/json' }); | |
| const url = URL.createObjectURL(dataBlob); | |
| const link = document.createElement('a'); | |
| link.href = url; | |
| link.download = 'geodataCache.json'; | |
| link.click(); | |
| URL.revokeObjectURL(url); | |
| document.getElementById('status').textContent = 'Cache exported'; | |
| } | |
| async function reloadFromAPI() { | |
| document.getElementById('loading').style.display = 'block'; | |
| document.getElementById('status').textContent = 'Reloading...'; | |
| try { | |
| bristolGeodata = await fetchFromOverpassAPI(); | |
| renderBristolData(); | |
| document.getElementById('data-source').textContent = 'Live API'; | |
| document.getElementById('status').textContent = 'Reloaded from API'; | |
| } catch (error) { | |
| console.error("Reload from API failed:", error); | |
| document.getElementById('status').textContent = 'Reload failed'; | |
| } finally { | |
| document.getElementById('loading').style.display = 'none'; | |
| } | |
| } | |
| function clearCache() { | |
| // This function currently only updates status. If you want to clear actual stored cache, | |
| // you'd need to use IndexedDB or localStorage, which isn't implemented here. | |
| document.getElementById('status').textContent = 'Cache cleared (simulated)'; | |
| } | |
| function testMinimalData() { | |
| bristolGeodata = createMinimalBristolData(); | |
| renderBristolData(); | |
| document.getElementById('data-source').textContent = 'Test Data'; | |
| document.getElementById('status').textContent = 'Using test data'; | |
| } | |
| function animate() { | |
| requestAnimationFrame(animate); | |
| // Perform culling every few frames for better FPS | |
| performFrustumCulling(); | |
| renderer.render(scene, camera); | |
| // FPS Counter | |
| frameCount++; | |
| const now = performance.now(); | |
| if (now >= lastFrameTime + 1000) { | |
| document.getElementById('fps-counter').textContent = frameCount; | |
| frameCount = 0; | |
| lastFrameTime = now; | |
| } | |
| } | |
| // Event handlers | |
| function handleResize() { | |
| if (!camera || !renderer) return; | |
| camera.aspect = window.innerWidth / window.innerHeight; | |
| camera.updateProjectionMatrix(); | |
| renderer.setSize(window.innerWidth, window.innerHeight); | |
| } | |
| window.addEventListener('resize', handleResize); | |
| window.addEventListener('orientationchange', () => setTimeout(handleResize, 100)); | |
| // Prevent double tap zoom | |
| let lastTouchEnd = 0; | |
| document.addEventListener('touchend', e => { | |
| const now = Date.now(); | |
| if (now - lastTouchEnd <= 300) e.preventDefault(); | |
| lastTouchEnd = now; | |
| }, { passive: false }); | |
| // Start the application | |
| init().catch(err => { | |
| console.error("Init failed:", err); | |
| document.getElementById('loading').innerHTML = `<div>❌ ERROR</div><div style="font-size:12px;">${err.message}</div>`; | |
| }); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment