Skip to content

Instantly share code, notes, and snippets.

@danbri
Created June 17, 2025 20:30
Show Gist options
  • Select an option

  • Save danbri/2aa7dc4cd178bf7ee2e33898a014481a to your computer and use it in GitHub Desktop.

Select an option

Save danbri/2aa7dc4cd178bf7ee2e33898a014481a to your computer and use it in GitHub Desktop.
Tnk2
<!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