fixes here and there

This commit is contained in:
2026-01-25 12:55:30 +01:00
parent bd5a59d827
commit 6300de22f0
4 changed files with 366 additions and 329 deletions

View File

@@ -30,7 +30,6 @@
v-model:imperialUnits="imperialUnits" v-model:imperialUnits="imperialUnits"
:visibleSites="visibleSites" :visibleSites="visibleSites"
:allHistoricMarkersHidden="allHistoricMarkersHidden" :allHistoricMarkersHidden="allHistoricMarkersHidden"
:tileRequests="tileRequests"
@toggleSite="toggleSite" @toggleSite="toggleSite"
@jumpToSite="jumpToSite" @jumpToSite="jumpToSite"
@toggleAllSites="hideAllHistoricMarkers" @toggleAllSites="hideAllHistoricMarkers"
@@ -39,7 +38,6 @@
@update:lidarOpacity="updateLidarOpacity" @update:lidarOpacity="updateLidarOpacity"
@update:showLidar="toggleLidar" @update:showLidar="toggleLidar"
@update:showGeometry="toggleGeometry" @update:showGeometry="toggleGeometry"
@dismissRequest="dismissTileRequest"
/> />
<!-- ============================================= --> <!-- ============================================= -->
@@ -56,20 +54,12 @@
<!-- CONTEXT MENU --> <!-- CONTEXT MENU -->
<!-- ============================================= --> <!-- ============================================= -->
<ContextMenu <ContextMenu
:visible="contextMenu.visible" ref="contextMenuRef"
:x="contextMenu.x" :parseMoundBuffer="parseMoundBuffer"
:y="contextMenu.y" @dropPin="handleDropPin"
:lngLat="contextMenu.lngLat || { lng: 0, lat: 0 }" @startMeasure="handleStartMeasure"
:tileData="contextMenu.tileData" @addImagesToMap="handleAddImagesToMap"
:imagesLoaded="contextMenu.imagesLoaded" @openSandbox="handleOpenSandbox"
:moundLoaded="contextMenu.moundLoaded"
:apiError="contextMenu.apiError"
@dropPin="dropPin"
@startMeasure="startMeasure"
@requestTile="requestTileFromContextMenu"
@loadImages="loadImagesFromContextMenu"
@loadMound="loadMoundFromContextMenu"
@openSandbox="openSandboxFromContextMenu"
/> />
<!-- ============================================= --> <!-- ============================================= -->
@@ -122,6 +112,7 @@ import { KNOWN_SITES } from './data/historicSites.js';
// UTILITIES // UTILITIES
// ============================================================================ // ============================================================================
import { calculateDistance, calculateBearing, extendRay } from './utils/geometry.js'; import { calculateDistance, calculateBearing, extendRay } from './utils/geometry.js';
import { webMercatorToLonLat } from './utils/coordinates.js';
import { useTilesStore } from './stores/tiles.js'; import { useTilesStore } from './stores/tiles.js';
// For generating the pre-baked tiles: // For generating the pre-baked tiles:
@@ -162,7 +153,7 @@ const sandboxRef = ref(null);
const sandboxVisible = ref(false); const sandboxVisible = ref(false);
const sandboxOffscreen = ref(false); const sandboxOffscreen = ref(false);
const baseLayer = ref('osm'); const baseLayer = ref('osm');
const historicMarkersExpanded = ref(true); const historicMarkersExpanded = ref(false);
const visibleSites = ref({}); const visibleSites = ref({});
const allHistoricMarkersHidden = ref(false); const allHistoricMarkersHidden = ref(false);
const showLidar = ref(true); const showLidar = ref(true);
@@ -175,10 +166,6 @@ const lidarOpacity = ref(80);
// ============================================================================ // ============================================================================
const currentTileData = ref(null); const currentTileData = ref(null);
// Tile request tracking: { requestId: { lat, lng, status, message, tileId } }
const tileRequests = ref({});
let nextRequestId = 1;
// ============================================================================ // ============================================================================
// REFS - Geometry State // REFS - Geometry State
// ============================================================================ // ============================================================================
@@ -191,16 +178,7 @@ const nextFeatureId = ref(1);
// ============================================================================ // ============================================================================
// REFS - UI Overlays // REFS - UI Overlays
// ============================================================================ // ============================================================================
const contextMenu = ref({ const contextMenuRef = ref(null);
visible: false,
x: 0,
y: 0,
lngLat: null,
tileData: null, // API tile metadata
imagesLoaded: false, // JPG/PNG loaded on map
moundLoaded: false, // .mound data in cache
apiError: false // Failed to fetch tile info
});
const popup = ref({ const popup = ref({
visible: false, visible: false,
@@ -220,31 +198,6 @@ const highlightedCitation = ref(null);
let map = null; let map = null;
let drawingHandler = null; let drawingHandler = null;
// ============================================================================
// COORDINATE UTILITIES
// ============================================================================
function webMercatorToLonLat(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return [lon, lat];
}
// ============================================================================
// TILE UTILITIES
// ============================================================================
// Check if tile images are loaded on map
function areTileImagesLoaded(tileId) {
return tilesStore.areImagesOnMap(tileId);
}
// Check if mound data is cached
function isMoundDataCached(tileId) {
return tilesStore.hasMoundData(tileId);
}
// ============================================================================ // ============================================================================
// FILE PARSING // FILE PARSING
// ============================================================================ // ============================================================================
@@ -296,13 +249,13 @@ function parseMoundBuffer(buffer) {
async function loadTileImages(tileId, tileMetadata, imageUrl = null) { async function loadTileImages(tileId, tileMetadata, imageUrl = null) {
// If no explicit imageUrl provided (loading from server), skip if already loaded // If no explicit imageUrl provided (loading from server), skip if already loaded
if (!imageUrl && areTileImagesLoaded(tileId)) { if (!imageUrl && tilesStore.areImagesOnMap(tileId)) {
console.log(`Images for ${tileId} already loaded`); console.log(`Images for ${tileId} already loaded`);
return; return;
} }
// If imageUrl is provided (sandbox render), allow overwriting // If imageUrl is provided (sandbox render), allow overwriting
const isOverwriting = imageUrl && areTileImagesLoaded(tileId); const isOverwriting = imageUrl && tilesStore.areImagesOnMap(tileId);
try { try {
// Determine which format to load (prefer PNG over JPG) // Determine which format to load (prefer PNG over JPG)
@@ -381,157 +334,46 @@ async function loadInitialTiles() {
console.log(`Loaded ${tilesStore.getAllTileIdsWithImages.length} initial tiles`); console.log(`Loaded ${tilesStore.getAllTileIdsWithImages.length} initial tiles`);
} }
async function loadMoundData(tileId) {
if (isMoundDataCached(tileId)) {
console.log(`Mound data for ${tileId} already cached`);
return;
}
try {
await tilesStore.fetchMoundData(tileId, parseMoundBuffer);
console.log(`Loaded mound data for ${tileId}`);
} catch (err) {
console.error(`Failed to load mound data for ${tileId}:`, err);
throw err;
}
}
// ============================================================================ // ============================================================================
// CONTEXT MENU HANDLERS // CONTEXT MENU HANDLER
// ============================================================================ // ============================================================================
async function handleContextMenu(e) { async function handleContextMenu(e) {
e.preventDefault(); e.preventDefault();
const lng = e.lngLat.lng; if (contextMenuRef.value) {
const lat = e.lngLat.lat; contextMenuRef.value.show(e.point.x, e.point.y, {
lng: e.lngLat.lng,
// Reset context menu state lat: e.lngLat.lat
contextMenu.value = { });
visible: false,
x: e.point.x,
y: e.point.y,
lngLat: { lng, lat },
tileData: null,
imagesLoaded: false,
moundLoaded: false,
apiError: false
};
// Try to fetch tile metadata from API
try {
const tileData = await tilesStore.fetchMetadataByCoords(lat, lng);
if (tileData) {
contextMenu.value.tileData = tileData;
contextMenu.value.imagesLoaded = areTileImagesLoaded(tileData.id);
contextMenu.value.moundLoaded = isMoundDataCached(tileData.id);
} }
} catch (err) {
console.error('Failed to fetch tile info:', err);
contextMenu.value.apiError = true;
}
// Show context menu
contextMenu.value.visible = true;
} }
async function requestTileFromContextMenu() { // ============================================================================
const { lng, lat } = contextMenu.value.lngLat; // CONTEXT MENU - MAP OPERATION HANDLERS
contextMenu.value.visible = false; // ============================================================================
const requestId = `req-${nextRequestId++}`; async function handleAddImagesToMap(tileId) {
const tileMetadata = tilesStore.getMetadata(tileId);
// Initialize request tracking if (tileMetadata) {
tileRequests.value[requestId] = {
lat,
lng,
status: 'looking_up',
message: null,
tileId: null
};
// Start SSE request
tilesStore.requestTileProcessing(
lat,
lng,
async (data) => {
// Update request status
tileRequests.value[requestId].status = data.status;
tileRequests.value[requestId].message = data.message || null;
if (data.tile_id) {
tileRequests.value[requestId].tileId = data.tile_id;
}
// On ready: load mound and open sandbox
if (data.status === 'ready' && data.tile_id) {
try { try {
await loadMoundData(data.tile_id); await loadTileImages(tileId, tileMetadata);
await openSandboxWithTile(data.tile_id);
} catch (err) {
console.error('Failed to load mound after request:', err);
tileRequests.value[requestId].status = 'error';
tileRequests.value[requestId].message = 'Failed to load data';
}
}
// Auto-dismiss after 10 seconds if complete
if (data.status === 'ready' || data.status === 'error') {
setTimeout(() => {
delete tileRequests.value[requestId];
}, 10000);
}
},
(error) => {
console.error('Request tile error:', error);
tileRequests.value[requestId].status = 'error';
tileRequests.value[requestId].message = 'Connection failed';
setTimeout(() => {
delete tileRequests.value[requestId];
}, 10000);
}
);
}
async function loadImagesFromContextMenu() {
const tileData = contextMenu.value.tileData;
contextMenu.value.visible = false;
if (tileData) {
try {
await loadTileImages(tileData.id, tileData);
} catch (err) { } catch (err) {
console.error('Failed to load images:', err); console.error('Failed to load images:', err);
} }
} }
} }
async function loadMoundFromContextMenu() { function handleOpenSandbox(tileId) {
const tileData = contextMenu.value.tileData; openSandboxWithTile(tileId);
contextMenu.value.visible = false;
if (tileData) {
try {
await loadMoundData(tileData.id);
} catch (err) {
console.error('Failed to load mound data:', err);
}
}
} }
async function openSandboxFromContextMenu() { function handleDropPin(lngLat) {
const tileData = contextMenu.value.tileData; dropPin(lngLat);
contextMenu.value.visible = false;
if (tileData) {
await openSandboxWithTile(tileData.id);
}
} }
function dismissTileRequest(requestId) { function handleStartMeasure(lngLat) {
delete tileRequests.value[requestId]; startMeasure(lngLat);
} }
// ============================================================================ // ============================================================================
@@ -657,10 +499,6 @@ function openSandbox() {
} }
async function openSandboxWithTile(tileId) { async function openSandboxWithTile(tileId) {
if (!isMoundDataCached(tileId)) {
await loadMoundData(tileId);
}
sandboxVisible.value = true; sandboxVisible.value = true;
setTimeout(() => { setTimeout(() => {
@@ -714,7 +552,8 @@ function setDrawMode(mode) {
} }
drawingHandler = (e) => { drawingHandler = (e) => {
if (contextMenu.value.visible) return; // Don't draw if context menu is visible
if (contextMenuRef.value?.visible) return;
drawPoints.value.push([e.lngLat.lng, e.lngLat.lat]); drawPoints.value.push([e.lngLat.lng, e.lngLat.lat]);
@@ -758,8 +597,8 @@ function completeDrawing() {
setDrawMode(null); setDrawMode(null);
} }
function dropPin() { function dropPin(lngLat) {
const { lng, lat } = contextMenu.value.lngLat; const { lng, lat } = lngLat;
const feature = { const feature = {
type: 'Feature', type: 'Feature',
@@ -777,13 +616,11 @@ function dropPin() {
geometryFeatures.value.features.push(feature); geometryFeatures.value.features.push(feature);
updateGeometryLayer(); updateGeometryLayer();
saveGeometry(); saveGeometry();
contextMenu.value.visible = false;
} }
function startMeasure() { function startMeasure(lngLat) {
contextMenu.value.visible = false;
setDrawMode('line'); setDrawMode('line');
const { lng, lat } = contextMenu.value.lngLat; const { lng, lat } = lngLat;
drawPoints.value = [[lng, lat]]; drawPoints.value = [[lng, lat]];
} }
@@ -882,7 +719,8 @@ onMounted(() => {
type: 'raster', type: 'raster',
tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'], tiles: ['https://tile.openstreetmap.org/{z}/{x}/{y}.png'],
tileSize: 256, tileSize: 256,
attribution: '© OpenStreetMap contributors' attribution: '© OpenStreetMap contributors',
maxZoom: 20
}, },
satellite: { satellite: {
type: 'raster', type: 'raster',
@@ -890,7 +728,8 @@ onMounted(() => {
'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}' 'https://server.arcgisonline.com/ArcGIS/rest/services/World_Imagery/MapServer/tile/{z}/{y}/{x}'
], ],
tileSize: 256, tileSize: 256,
attribution: '© Esri' attribution: '© Esri',
maxZoom: 21
} }
}, },
layers: [ layers: [
@@ -945,12 +784,16 @@ onMounted(() => {
popup.value.visible = false; popup.value.visible = false;
} }
contextMenu.value.visible = false; if (contextMenuRef.value) {
contextMenuRef.value.hide();
}
}); });
document.addEventListener('keydown', (e) => { document.addEventListener('keydown', (e) => {
if (e.key === 'Escape') { if (e.key === 'Escape') {
contextMenu.value.visible = false; if (contextMenuRef.value) {
contextMenuRef.value.hide();
}
popup.value.visible = false; popup.value.visible = false;
bibliographyVisible.value = false; bibliographyVisible.value = false;
if (drawMode.value) { if (drawMode.value) {

View File

@@ -9,8 +9,9 @@
<!-- ============================================= --> <!-- ============================================= -->
<div class="context-menu-header"> <div class="context-menu-header">
{{ formatCoordinate(lngLat.lng, 'lng') }}, {{ formatCoordinate(lngLat.lat, 'lat') }} {{ formatCoordinate(lngLat.lng, 'lng') }}, {{ formatCoordinate(lngLat.lat, 'lat') }}
<span v-if="tileData?.id" class="tile-name">{{ tileData.id }}</span> <span v-if="tileMetadata?.id" class="tile-name">{{ tileMetadata.id }}</span>
<span v-if="apiError" class="tile-error"> Lookup failed</span> <span v-if="metadataError" class="tile-error"> Lookup failed</span>
<span v-if="processingStatus" class="tile-status">{{ processingStatus }}</span>
</div> </div>
<!-- ============================================= --> <!-- ============================================= -->
@@ -18,40 +19,47 @@
<!-- ============================================= --> <!-- ============================================= -->
<!-- Always available --> <!-- Always available -->
<button @click="$emit('dropPin')" class="context-menu-item">📍 Drop Pin</button> <button @click="handleDropPin" class="context-menu-item">📍 Drop Pin</button>
<button @click="$emit('startMeasure')" class="context-menu-item">📏 Measure from here</button> <button @click="handleStartMeasure" class="context-menu-item">📏 Measure from here</button>
<!-- State A: No tile exists (or error checking) --> <!-- State A: No tile exists (or error checking) -->
<button <button
v-if="!tileData && !apiError" v-if="!tileMetadata && !metadataError && !requestingTile"
@click="$emit('requestTile')" @click="handleRequestTile"
class="context-menu-item" class="context-menu-item"
> >
📥 Request Tile 📥 Request Tile
</button> </button>
<!-- Show status while requesting -->
<div v-if="requestingTile" class="context-menu-status">
{{ processingStatus || 'Requesting tile...' }}
</div>
<!-- State B: Tile exists but images not loaded --> <!-- State B: Tile exists but images not loaded -->
<button <button
v-if="tileData && !imagesLoaded && (tileData.jpg_available || tileData.png_available)" v-if="tileMetadata && !imagesOnMap && hasAvailableImages"
@click="$emit('loadImages')" @click="handleLoadImages"
class="context-menu-item" class="context-menu-item"
:disabled="loadingImages"
> >
📦 Load Tile Images {{ loadingImages ? '⏳ Loading...' : '📦 Load Tile Images' }}
</button> </button>
<!-- State C: Images loaded, but mound not loaded --> <!-- State C: Images loaded, but mound not loaded -->
<button <button
v-if="tileData && !moundLoaded" v-if="tileMetadata && !hasMoundData"
@click="$emit('loadMound')" @click="handleLoadMound"
class="context-menu-item" class="context-menu-item"
:disabled="loadingMound"
> >
🔬 Load Interactive Data {{ loadingMound ? '⏳ Loading...' : '🔬 Load Interactive Data' }}
</button> </button>
<!-- State D: Mound loaded, ready to open sandbox --> <!-- State D: Mound loaded, ready to open sandbox -->
<button <button
v-if="moundLoaded" v-if="hasMoundData"
@click="$emit('openSandbox')" @click="handleOpenSandbox"
class="context-menu-item" class="context-menu-item"
> >
🔬 Open in Shading Sandbox 🔬 Open in Shading Sandbox
@@ -60,48 +68,213 @@
</template> </template>
<script setup> <script setup>
import { ref, computed, watch } from 'vue';
import { formatCoordinate } from '../utils/coordinates.js'; import { formatCoordinate } from '../utils/coordinates.js';
import { useTilesStore } from '../stores/tiles.js';
// ============================================================================ // ============================================================================
// INTERFACE // PROPS & EMITS
// ============================================================================ // ============================================================================
const props = defineProps({ const props = defineProps({
visible: { parseMoundBuffer: {
type: Boolean, type: Function,
required: true required: true
},
x: {
type: Number,
required: true
},
y: {
type: Number,
required: true
},
lngLat: {
type: Object, // { lng, lat }
required: true
},
tileData: {
type: Object, // Tile metadata from API
default: null
},
imagesLoaded: {
type: Boolean,
default: false
},
moundLoaded: {
type: Boolean,
default: false
},
apiError: {
type: Boolean,
default: false
} }
}); });
defineEmits(['dropPin', 'startMeasure', 'requestTile', 'loadImages', 'loadMound', 'openSandbox']); const emit = defineEmits([
'dropPin',
'startMeasure',
'addImagesToMap', // Images loaded, ready to add to map
'openSandbox' // Mound loaded, ready to open sandbox
]);
// ============================================================================
// STORE & STATE
// ============================================================================
const tilesStore = useTilesStore();
// Own state
const visible = ref(false);
const x = ref(0);
const y = ref(0);
const lngLat = ref({ lng: 0, lat: 0 });
// Loading/processing states
const loadingImages = ref(false);
const loadingMound = ref(false);
const metadataError = ref(false);
const requestingTile = ref(false);
const processingStatus = ref(null);
// ============================================================================
// COMPUTED - TILE STATE FROM STORE
// ============================================================================
const tileId = computed(() => {
return tilesStore.findTileByCoords(lngLat.value.lat, lngLat.value.lng);
});
const tileMetadata = computed(() => {
return tileId.value ? tilesStore.getMetadata(tileId.value) : null;
});
const imagesOnMap = computed(() => {
return tileId.value ? tilesStore.areImagesOnMap(tileId.value) : false;
});
const hasMoundData = computed(() => {
return tileId.value ? tilesStore.hasMoundData(tileId.value) : false;
});
const hasAvailableImages = computed(() => {
if (!tileMetadata.value) return false;
return tileMetadata.value.jpg_available || tileMetadata.value.png_available;
});
// ============================================================================
// WATCHERS - FETCH METADATA WHEN MENU OPENS
// ============================================================================
watch(visible, async (isVisible) => {
if (isVisible && !tileId.value) {
await fetchMetadata();
}
});
// ============================================================================
// EXPOSED METHODS
// ============================================================================
function show(mouseX, mouseY, coordinates) {
x.value = mouseX;
y.value = mouseY;
lngLat.value = coordinates;
visible.value = true;
// Reset states
metadataError.value = false;
requestingTile.value = false;
processingStatus.value = null;
}
function hide() {
visible.value = false;
}
defineExpose({ show, hide });
// ============================================================================
// ACTIONS - METADATA
// ============================================================================
async function fetchMetadata() {
metadataError.value = false;
try {
await tilesStore.fetchMetadataByCoords(lngLat.value.lat, lngLat.value.lng);
} catch (err) {
console.error('Failed to fetch tile metadata:', err);
metadataError.value = true;
}
}
// ============================================================================
// ACTIONS - TILE OPERATIONS
// ============================================================================
async function handleRequestTile() {
requestingTile.value = true;
processingStatus.value = 'Looking up tile...';
const eventSource = tilesStore.requestTileProcessing(
lngLat.value.lat,
lngLat.value.lng,
async (data) => {
processingStatus.value = data.message || data.status;
// On ready: auto-load mound and open sandbox
if (data.status === 'ready' && data.tile_id) {
try {
await tilesStore.fetchMoundData(data.tile_id, props.parseMoundBuffer);
emit('openSandbox', data.tile_id);
// Close connection and hide menu
eventSource.close();
setTimeout(() => hide(), 500);
} catch (err) {
console.error('Failed to load mound after processing:', err);
processingStatus.value = 'Error loading data';
requestingTile.value = false;
}
}
if (data.status === 'error') {
requestingTile.value = false;
setTimeout(() => {
if (visible.value) {
processingStatus.value = null;
}
}, 5000);
}
},
(error) => {
console.error('Tile processing error:', error);
processingStatus.value = 'Connection failed';
requestingTile.value = false;
setTimeout(() => {
if (visible.value) {
processingStatus.value = null;
}
}, 5000);
}
);
}
async function handleLoadImages() {
if (!tileId.value || loadingImages.value) return;
loadingImages.value = true;
try {
emit('addImagesToMap', tileId.value);
} finally {
loadingImages.value = false;
}
}
async function handleLoadMound() {
if (!tileId.value || loadingMound.value) return;
loadingMound.value = true;
try {
await tilesStore.fetchMoundData(tileId.value, props.parseMoundBuffer);
} catch (err) {
console.error('Failed to load mound data:', err);
} finally {
loadingMound.value = false;
}
}
function handleOpenSandbox() {
if (!tileId.value) return;
emit('openSandbox', tileId.value);
hide();
}
function handleDropPin() {
emit('dropPin', lngLat.value);
hide();
}
function handleStartMeasure() {
emit('startMeasure', lngLat.value);
hide();
}
</script> </script>
<style scoped> <style scoped>
@@ -147,6 +320,26 @@ defineEmits(['dropPin', 'startMeasure', 'requestTile', 'loadImages', 'loadMound'
font-weight: 600; font-weight: 600;
} }
.context-menu-header .tile-status {
display: block;
margin-top: 4px;
font-size: 11px;
color: #2196F3;
font-weight: 500;
}
/* ============================================= */
/* STATUS DISPLAY */
/* ============================================= */
.context-menu-status {
padding: 10px 12px;
font-size: 13px;
color: #666;
background: #f9f9f9;
border-top: 1px solid #eee;
}
/* ============================================= */ /* ============================================= */
/* MENU ITEMS */ /* MENU ITEMS */
/* ============================================= */ /* ============================================= */
@@ -166,4 +359,14 @@ defineEmits(['dropPin', 'startMeasure', 'requestTile', 'loadImages', 'loadMound'
.context-menu-item:hover { .context-menu-item:hover {
background: #f0f0f0; background: #f0f0f0;
} }
.context-menu-item:disabled {
cursor: not-allowed;
opacity: 0.6;
background: #f9f9f9;
}
.context-menu-item:disabled:hover {
background: #f9f9f9;
}
</style> </style>

View File

@@ -290,15 +290,16 @@ const handleResize = () => {
renderer.setSize(width, height); renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio); renderer.setPixelRatio(window.devicePixelRatio);
// Use exact normalized terrain bounds (no padding = no black bars) // Use exact tile boundaries (guaranteed square)
if (geometryCache) { if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2; const halfSpan = geometryCache.tileSpan / 2;
camera.right = geometryCache.normalizedSpanX / 2; camera.left = -halfSpan;
camera.top = geometryCache.normalizedSpanY / 2; camera.right = halfSpan;
camera.bottom = -geometryCache.normalizedSpanY / 2; camera.top = halfSpan;
camera.bottom = -halfSpan;
} else { } else {
// Fallback: square frustum if no tile loaded yet // Fallback: square frustum if no tile loaded yet
const viewSize = 6; const viewSize = 500; // Reasonable default in world units
camera.left = -viewSize; camera.left = -viewSize;
camera.right = viewSize; camera.right = viewSize;
camera.top = viewSize; camera.top = viewSize;
@@ -325,32 +326,30 @@ const loadTileData = (tileData, newTileId) => {
} }
try { try {
// Calculate bounds center // Tile bounds (guaranteed square, defines camera frustum)
const centerX = (tileData.bounds.minX + tileData.bounds.maxX) / 2; const tileBounds = tileData.tileBounds || tileData.bounds;
const centerY = (tileData.bounds.minY + tileData.bounds.maxY) / 2; const tileCenterX = (tileBounds.minX + tileBounds.maxX) / 2;
const centerZ = (tileData.bounds.minZ + tileData.bounds.maxZ) / 2; const tileCenterY = (tileBounds.minY + tileBounds.maxY) / 2;
const tileSpan = tileBounds.maxX - tileBounds.minX; // Should equal maxY - minY (square)
// Calculate spans // Mesh bounds (actual point data extent, for Z normalization)
const spanX = tileData.bounds.maxX - tileData.bounds.minX; const meshBounds = tileData.bounds;
const spanY = tileData.bounds.maxY - tileData.bounds.minY; const meshCenterZ = (meshBounds.minZ + meshBounds.maxZ) / 2;
const spanZ = tileData.bounds.maxZ - tileData.bounds.minZ; const meshSpanZ = meshBounds.maxZ - meshBounds.minZ;
const maxSpan = Math.max(spanX, spanY);
// Calculate actual aspect ratio of the tile // Z normalization: scale Z to be perceptible relative to tile span
const tileAspect = spanX / spanY; // Use 10% of tile span as base Z scale (adjust this factor as needed for aesthetics)
const zNormalizationFactor = (tileSpan * 0.1) / meshSpanZ;
// Normalize XY to fit in view, maintaining actual aspect ratio
const normalizeScale = 10 / maxSpan;
// Z scaling: make Z variation visible but proportional
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
// Transform positions // Transform positions
const transformedPositions = new Float32Array(tileData.positions.length); const transformedPositions = new Float32Array(tileData.positions.length);
for (let i = 0; i < tileData.positions.length; i += 3) { for (let i = 0; i < tileData.positions.length; i += 3) {
transformedPositions[i] = (tileData.positions[i] - centerX) * normalizeScale; // X/Y: keep world coordinates, just centered on tile center
transformedPositions[i + 1] = (tileData.positions[i + 1] - centerY) * normalizeScale; transformedPositions[i] = tileData.positions[i] - tileCenterX;
transformedPositions[i + 2] = (tileData.positions[i + 2] - centerZ) * zScale; transformedPositions[i + 1] = tileData.positions[i + 1] - tileCenterY;
// Z: normalized relative to tile span, then will be height-exaggerated later
transformedPositions[i + 2] = (tileData.positions[i + 2] - meshCenterZ) * zNormalizationFactor;
} }
// Create geometry // Create geometry
@@ -367,20 +366,14 @@ const loadTileData = (tileData, newTileId) => {
baseZ[i + 2] = transformedPositions[i + 2]; // Only Z values baseZ[i + 2] = transformedPositions[i + 2]; // Only Z values
} }
// Calculate exact bounds of normalized terrain (no padding = no black bars)
const normalizedSpanX = spanX * normalizeScale;
const normalizedSpanY = spanY * normalizeScale;
geometryCache = { geometryCache = {
geometry, geometry,
baseZ, baseZ,
spanZ, tileBounds: tileBounds,
zScale, tileSpan: tileSpan,
tileAspect, zNormalizationFactor: zNormalizationFactor,
normalizedSpanX, // Store original bounds for reference
normalizedSpanY, originalBounds: { ...meshBounds }
// Store original Web Mercator bounds for MapLibre positioning
originalBounds: { ...tileData.bounds }
}; };
// Create material and mesh // Create material and mesh
@@ -392,14 +385,15 @@ const loadTileData = (tileData, newTileId) => {
mesh = new THREE.Mesh(geometry, material); mesh = new THREE.Mesh(geometry, material);
scene.add(mesh); scene.add(mesh);
// Configure camera frustum to match tile aspect ratio // Configure camera frustum to match tile boundaries (guaranteed square)
camera.left = -normalizedSpanX / 2; const halfSpan = tileSpan / 2;
camera.right = normalizedSpanX / 2; camera.left = -halfSpan;
camera.top = normalizedSpanY / 2; camera.right = halfSpan;
camera.bottom = -normalizedSpanY / 2; camera.top = halfSpan;
camera.bottom = -halfSpan;
// Adjust near/far to accommodate height exaggeration // Adjust near/far to accommodate height exaggeration
const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration const maxZExtent = (tileSpan * 0.1) * 20; // Max exaggeration (0.1 factor * 20x scale)
camera.near = 0.1; camera.near = 0.1;
camera.far = 100 + maxZExtent * 2; camera.far = 100 + maxZExtent * 2;
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
@@ -491,28 +485,21 @@ const renderTile = async () => {
const originalHeight = renderer.domElement.height; const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio(); const originalPixelRatio = renderer.getPixelRatio();
// Calculate render dimensions based on tile aspect ratio // Tiles are guaranteed square, so render dimensions are always square
let renderWidth = size; const renderWidth = size;
let renderHeight = size; const renderHeight = size;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(size / tileAspect);
} else {
renderWidth = Math.round(size * tileAspect);
}
}
// Set render size // Set render size
renderer.setSize(renderWidth, renderHeight); renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1); // No pixel ratio multiplier for export renderer.setPixelRatio(1); // No pixel ratio multiplier for export
// Update camera to match exact tile bounds (no black bars) // Update camera to match exact tile bounds (guaranteed square)
if (geometryCache) { if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2; const halfSpan = geometryCache.tileSpan / 2;
camera.right = geometryCache.normalizedSpanX / 2; camera.left = -halfSpan;
camera.top = geometryCache.normalizedSpanY / 2; camera.right = halfSpan;
camera.bottom = -geometryCache.normalizedSpanY / 2; camera.top = halfSpan;
camera.bottom = -halfSpan;
} }
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
@@ -588,28 +575,21 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
const originalHeight = renderer.domElement.height; const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio(); const originalPixelRatio = renderer.getPixelRatio();
// Calculate render dimensions based on tile aspect ratio // Tiles are guaranteed square, so render dimensions are always square
let renderWidth = resolution; const renderWidth = resolution;
let renderHeight = resolution; const renderHeight = resolution;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(resolution / tileAspect);
} else {
renderWidth = Math.round(resolution * tileAspect);
}
}
// Set render size // Set render size
renderer.setSize(renderWidth, renderHeight); renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1); renderer.setPixelRatio(1);
// Update camera to match exact tile bounds (no black bars) // Update camera to match exact tile bounds (guaranteed square)
if (geometryCache) { if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2; const halfSpan = geometryCache.tileSpan / 2;
camera.right = geometryCache.normalizedSpanX / 2; camera.left = -halfSpan;
camera.top = geometryCache.normalizedSpanY / 2; camera.right = halfSpan;
camera.bottom = -geometryCache.normalizedSpanY / 2; camera.top = halfSpan;
camera.bottom = -halfSpan;
} }
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();
@@ -623,12 +603,13 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio); renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
renderer.setPixelRatio(originalPixelRatio); renderer.setPixelRatio(originalPixelRatio);
// Restore camera with exact normalized bounds // Restore camera with exact tile bounds
if (geometryCache) { if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2; const halfSpan = geometryCache.tileSpan / 2;
camera.right = geometryCache.normalizedSpanX / 2; camera.left = -halfSpan;
camera.top = geometryCache.normalizedSpanY / 2; camera.right = halfSpan;
camera.bottom = -geometryCache.normalizedSpanY / 2; camera.top = halfSpan;
camera.bottom = -halfSpan;
} }
camera.updateProjectionMatrix(); camera.updateProjectionMatrix();

View File

@@ -40,3 +40,13 @@ export function formatDistance(meters, useImperial = false) {
export function formatBearing(degrees) { export function formatBearing(degrees) {
return `${degrees.toFixed(1)}°`; return `${degrees.toFixed(1)}°`;
} }
/**
* Convert web Mercator to lat/long
*/
export function webMercatorToLonLat(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return [lon, lat];
}