add tile-pre-rendering and fetching
This commit is contained in:
1
ui/public/png
Symbolic link
1
ui/public/png
Symbolic link
@@ -0,0 +1 @@
|
||||
/home/mark/projects/moundhunters/data/PNG
|
||||
283
ui/src/App.vue
283
ui/src/App.vue
@@ -102,10 +102,13 @@
|
||||
>
|
||||
<div class="context-menu-header">
|
||||
{{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }}
|
||||
<span v-if="contextMenu.tileName" class="tile-name">{{ contextMenu.tileName }}</span>
|
||||
</div>
|
||||
<button @click="dropPin" class="context-menu-item">📍 Drop Pin</button>
|
||||
<button @click="startMeasure" class="context-menu-item">📏 Measure from here</button>
|
||||
<button v-if="!contextMenu.hasTile" @click="requestTile" class="context-menu-item">📥 Request Tile</button>
|
||||
<button v-if="contextMenu.hasTile && !contextMenu.hasMound" @click="loadTileFromContextMenu" class="context-menu-item">📦 Load Tile Data</button>
|
||||
<button v-if="contextMenu.hasMound" @click="openSandboxFromContextMenu" class="context-menu-item">🔬 Open in Shading Sandbox</button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -138,6 +141,11 @@
|
||||
|
||||
<script setup>
|
||||
import { ref, onMounted, onUnmounted } from 'vue';
|
||||
|
||||
// Devmode rendering
|
||||
//import { batchRenderTiles } from './batch-renderer.js';
|
||||
//<button @click="batchRenderTiles(sandboxRef, tileCache, TEST_TILES)">SPAM</button>
|
||||
|
||||
import maplibregl from 'maplibre-gl';
|
||||
import 'maplibre-gl/dist/maplibre-gl.css';
|
||||
import ShadingSandbox from './ShadingSandbox.vue';
|
||||
@@ -164,7 +172,8 @@ const showLidar = ref(true);
|
||||
const showGeometry = ref(true);
|
||||
const imperialUnits = ref(false);
|
||||
const lidarOpacity = ref(80);
|
||||
const tileCache = ref({});
|
||||
const tileCache = ref({}); // Cached .mound data (for shading sandbox)
|
||||
const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map
|
||||
const currentTileData = ref(null);
|
||||
|
||||
// Geometry state
|
||||
@@ -175,7 +184,15 @@ const nextPinNumber = ref(1);
|
||||
const nextFeatureId = ref(1);
|
||||
|
||||
// UI state
|
||||
const contextMenu = ref({ visible: false, x: 0, y: 0, lngLat: null, hasTile: false });
|
||||
const contextMenu = ref({
|
||||
visible: false,
|
||||
x: 0,
|
||||
y: 0,
|
||||
lngLat: null,
|
||||
hasTile: false, // Has PNG tile displayed
|
||||
hasMound: false, // Has .mound data cached
|
||||
tileName: null // The tile name at this location
|
||||
});
|
||||
const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null, lngLat: null });
|
||||
|
||||
// Map instance
|
||||
@@ -476,6 +493,8 @@ const KNOWN_SITES = [
|
||||
}
|
||||
];
|
||||
|
||||
const TEST_TILES = KNOWN_SITES.flatMap(i=>i.tiles);
|
||||
|
||||
// Export for use in application
|
||||
if (typeof module !== 'undefined' && module.exports) {
|
||||
module.exports = { KNOWN_SITES, BIBLIOGRAPHY };
|
||||
@@ -532,16 +551,16 @@ function calculateBearing(lng1, lat1, lng2, lat2) {
|
||||
return (θ * 180 / Math.PI + 360) % 360; // Bearing in degrees
|
||||
}
|
||||
|
||||
// Check if a point has a loaded lidar tile
|
||||
function hasLidarAtPoint(lng, lat) {
|
||||
if (!map) return false;
|
||||
// Check if a point has a loaded lidar tile, return tile info
|
||||
function getTileInfoAtPoint(lng, lat) {
|
||||
if (!map) return { hasTile: false, hasMound: false, tileName: null };
|
||||
|
||||
// Check all loaded tile layers
|
||||
for (const tileName of TILE_NAMES) {
|
||||
const layerId = `tile-layer-${tileName}`;
|
||||
// Check all loaded PNG tile layers
|
||||
for (const tileName of loadedPngTiles.value) {
|
||||
const layerId = `png-tile-layer-${tileName}`;
|
||||
if (!map.getLayer(layerId)) continue;
|
||||
|
||||
const source = map.getSource(`tile-${tileName}`);
|
||||
const source = map.getSource(`png-tile-${tileName}`);
|
||||
if (!source) continue;
|
||||
|
||||
// Get the bounds from the image source coordinates
|
||||
@@ -557,11 +576,20 @@ function hasLidarAtPoint(lng, lat) {
|
||||
|
||||
// Check if point is within bounds
|
||||
if (lng >= minLng && lng <= maxLng && lat >= minLat && lat <= maxLat) {
|
||||
return true;
|
||||
return {
|
||||
hasTile: true,
|
||||
hasMound: !!tileCache.value[tileName],
|
||||
tileName: tileName
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
return false;
|
||||
return { hasTile: false, hasMound: false, tileName: null };
|
||||
}
|
||||
|
||||
// Legacy wrapper for compatibility
|
||||
function hasLidarAtPoint(lng, lat) {
|
||||
return getTileInfoAtPoint(lng, lat).hasTile;
|
||||
}
|
||||
|
||||
// Extend a line from point1 through point2 to map bounds
|
||||
@@ -677,66 +705,88 @@ async function parseMoundFile(url) {
|
||||
};
|
||||
}
|
||||
|
||||
// Load and render all lidar tiles
|
||||
async function loadLidarTiles() {
|
||||
console.log('Loading and rendering tiles...');
|
||||
// Load PNG tiles (pre-rendered hillshade images)
|
||||
async function loadPngTiles() {
|
||||
console.log('Loading PNG tiles...');
|
||||
|
||||
for (const tileName of TILE_NAMES) {
|
||||
try {
|
||||
console.log(`Loading ${tileName}...`);
|
||||
const data = await parseMoundFile(`/tiles/${tileName}.mound`);
|
||||
|
||||
// Cache tile data
|
||||
tileCache.value[tileName] = data;
|
||||
|
||||
// Render using ShadingSandbox
|
||||
if (sandboxRef.value) {
|
||||
const result = await sandboxRef.value.renderTileWithSettings(
|
||||
data,
|
||||
DEFAULT_RENDER_SETTINGS,
|
||||
512
|
||||
);
|
||||
|
||||
if (result.success) {
|
||||
console.log(`Rendered ${tileName} in ${result.renderTime}ms (${result.width}x${result.height})`);
|
||||
|
||||
// Convert bounds to lat/lon
|
||||
const sw = webMercatorToLonLat(data.bounds.minX, data.bounds.minY);
|
||||
const ne = webMercatorToLonLat(data.bounds.maxX, data.bounds.maxY);
|
||||
|
||||
// Add image source to map
|
||||
map.addSource(`tile-${tileName}`, {
|
||||
type: 'image',
|
||||
url: result.dataURL,
|
||||
coordinates: [
|
||||
[sw[0], ne[1]], // top-left
|
||||
[ne[0], ne[1]], // top-right
|
||||
[ne[0], sw[1]], // bottom-right
|
||||
[sw[0], sw[1]] // bottom-left
|
||||
]
|
||||
});
|
||||
|
||||
// Add layer
|
||||
map.addLayer({
|
||||
id: `tile-layer-${tileName}`,
|
||||
type: 'raster',
|
||||
source: `tile-${tileName}`,
|
||||
paint: {
|
||||
'raster-opacity': 0.8
|
||||
}
|
||||
}, 'lidar-datum'); // Insert before datum layer
|
||||
|
||||
console.log(`Added ${tileName} to map at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
|
||||
} else {
|
||||
console.error(`Failed to render ${tileName}:`, result.error);
|
||||
}
|
||||
// Fetch metadata JSON for bounds
|
||||
const metaResponse = await fetch(`/png/${tileName}.json`);
|
||||
if (!metaResponse.ok) {
|
||||
console.warn(`No metadata for ${tileName}, skipping`);
|
||||
continue;
|
||||
}
|
||||
const meta = await metaResponse.json();
|
||||
|
||||
// Check PNG exists
|
||||
const pngUrl = `/png/${tileName}.png`;
|
||||
const pngResponse = await fetch(pngUrl, { method: 'HEAD' });
|
||||
if (!pngResponse.ok) {
|
||||
console.warn(`No PNG for ${tileName}, skipping`);
|
||||
continue;
|
||||
}
|
||||
|
||||
// Convert bounds to lat/lon (assuming meta has bounds in web mercator)
|
||||
const sw = webMercatorToLonLat(meta.bounds.minX, meta.bounds.minY);
|
||||
const ne = webMercatorToLonLat(meta.bounds.maxX, meta.bounds.maxY);
|
||||
|
||||
// Add image source to map
|
||||
map.addSource(`png-tile-${tileName}`, {
|
||||
type: 'image',
|
||||
url: pngUrl,
|
||||
coordinates: [
|
||||
[sw[0], ne[1]], // top-left
|
||||
[ne[0], ne[1]], // top-right
|
||||
[ne[0], sw[1]], // bottom-right
|
||||
[sw[0], sw[1]] // bottom-left
|
||||
]
|
||||
});
|
||||
|
||||
// Add layer
|
||||
map.addLayer({
|
||||
id: `png-tile-layer-${tileName}`,
|
||||
type: 'raster',
|
||||
source: `png-tile-${tileName}`,
|
||||
paint: {
|
||||
'raster-opacity': lidarOpacity.value / 100
|
||||
}
|
||||
}, 'lidar-datum'); // Insert before datum layer
|
||||
|
||||
loadedPngTiles.value.add(tileName);
|
||||
console.log(`Loaded PNG tile ${tileName} at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`Failed to load ${tileName}:`, err);
|
||||
console.error(`Failed to load PNG tile ${tileName}:`, err);
|
||||
}
|
||||
}
|
||||
|
||||
console.log('All tiles loaded');
|
||||
console.log(`Loaded ${loadedPngTiles.value.size} PNG tiles`);
|
||||
}
|
||||
|
||||
// Load .mound data for a specific tile (for shading sandbox)
|
||||
async function loadMoundData(tileName) {
|
||||
if (tileCache.value[tileName]) {
|
||||
console.log(`Mound data for ${tileName} already cached`);
|
||||
return tileCache.value[tileName];
|
||||
}
|
||||
|
||||
console.log(`Loading mound data for ${tileName}...`);
|
||||
|
||||
try {
|
||||
const data = await parseMoundFile(`/mound/${tileName}.mound`);
|
||||
tileCache.value[tileName] = data;
|
||||
console.log(`Cached mound data for ${tileName}`);
|
||||
return data;
|
||||
} catch (err) {
|
||||
console.error(`Failed to load mound data for ${tileName}:`, err);
|
||||
throw err;
|
||||
}
|
||||
}
|
||||
|
||||
// Legacy function - now loads PNG tiles
|
||||
async function loadLidarTiles() {
|
||||
await loadPngTiles();
|
||||
}
|
||||
|
||||
// UI handlers
|
||||
@@ -818,8 +868,8 @@ function toggleLidar() {
|
||||
if (!map) return;
|
||||
|
||||
const visibility = showLidar.value ? 'visible' : 'none';
|
||||
for (const tileName of TILE_NAMES) {
|
||||
const layerId = `tile-layer-${tileName}`;
|
||||
for (const tileName of loadedPngTiles.value) {
|
||||
const layerId = `png-tile-layer-${tileName}`;
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setLayoutProperty(layerId, 'visibility', visibility);
|
||||
}
|
||||
@@ -830,8 +880,8 @@ function updateLidarOpacity() {
|
||||
if (!map) return;
|
||||
|
||||
const opacity = lidarOpacity.value / 100;
|
||||
for (const tileName of TILE_NAMES) {
|
||||
const layerId = `tile-layer-${tileName}`;
|
||||
for (const tileName of loadedPngTiles.value) {
|
||||
const layerId = `png-tile-layer-${tileName}`;
|
||||
if (map.getLayer(layerId)) {
|
||||
map.setPaintProperty(layerId, 'raster-opacity', opacity);
|
||||
}
|
||||
@@ -839,18 +889,37 @@ function updateLidarOpacity() {
|
||||
}
|
||||
|
||||
function openSandbox() {
|
||||
if (Object.keys(tileCache.value).length > 0) {
|
||||
sandboxVisible.value = true;
|
||||
|
||||
// Load tile data after sandbox mounts
|
||||
setTimeout(() => {
|
||||
if (sandboxRef.value) {
|
||||
const firstTile = Object.keys(tileCache.value)[0];
|
||||
sandboxRef.value.loadTileData(tileCache.value[firstTile]);
|
||||
sandboxRef.value.setAvailableTiles(tileCache.value);
|
||||
}
|
||||
}, 100);
|
||||
sandboxVisible.value = true;
|
||||
|
||||
// If we have cached tile data, load it after sandbox mounts
|
||||
setTimeout(() => {
|
||||
if (sandboxRef.value && Object.keys(tileCache.value).length > 0) {
|
||||
const firstTile = Object.keys(tileCache.value)[0];
|
||||
sandboxRef.value.loadTileData(tileCache.value[firstTile]);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
// Open sandbox with a specific tile
|
||||
async function openSandboxWithTile(tileName) {
|
||||
// Load mound data if not cached
|
||||
if (!tileCache.value[tileName]) {
|
||||
try {
|
||||
await loadMoundData(tileName);
|
||||
} catch (err) {
|
||||
console.error('Failed to load tile for sandbox:', err);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
sandboxVisible.value = true;
|
||||
|
||||
setTimeout(() => {
|
||||
if (sandboxRef.value) {
|
||||
sandboxRef.value.loadTileData(tileCache.value[tileName]);
|
||||
sandboxRef.value.setAvailableTiles(tileCache.value);
|
||||
}
|
||||
}, 100);
|
||||
}
|
||||
|
||||
function onRenderComplete(data) {
|
||||
@@ -1004,6 +1073,38 @@ async function requestTile() {
|
||||
// TODO: Handle response and update UI
|
||||
}
|
||||
|
||||
// Load mound data for interactive shading
|
||||
async function loadTileFromContextMenu() {
|
||||
const tileName = contextMenu.value.tileName;
|
||||
contextMenu.value.visible = false;
|
||||
|
||||
if (!tileName) {
|
||||
console.error('No tile name available');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
await loadMoundData(tileName);
|
||||
console.log(`Mound data loaded for ${tileName}, ready for shading sandbox`);
|
||||
} catch (err) {
|
||||
console.error('Failed to load mound data:', err);
|
||||
// TODO: Show error toast
|
||||
}
|
||||
}
|
||||
|
||||
// Open shading sandbox for the tile at context menu location
|
||||
async function openSandboxFromContextMenu() {
|
||||
const tileName = contextMenu.value.tileName;
|
||||
contextMenu.value.visible = false;
|
||||
|
||||
if (!tileName) {
|
||||
console.error('No tile name available');
|
||||
return;
|
||||
}
|
||||
|
||||
await openSandboxWithTile(tileName);
|
||||
}
|
||||
|
||||
function updateGeometryLayer() {
|
||||
if (!map || !map.getSource('geometry')) return;
|
||||
|
||||
@@ -1074,13 +1175,15 @@ onMounted(() => {
|
||||
// Context menu handler
|
||||
map.on('contextmenu', (e) => {
|
||||
e.preventDefault();
|
||||
const hasTile = hasLidarAtPoint(e.lngLat.lng, e.lngLat.lat);
|
||||
const tileInfo = getTileInfoAtPoint(e.lngLat.lng, e.lngLat.lat);
|
||||
contextMenu.value = {
|
||||
visible: true,
|
||||
x: e.point.x,
|
||||
y: e.point.y,
|
||||
lngLat: e.lngLat,
|
||||
hasTile: hasTile
|
||||
hasTile: tileInfo.hasTile,
|
||||
hasMound: tileInfo.hasMound,
|
||||
tileName: tileInfo.tileName
|
||||
};
|
||||
});
|
||||
|
||||
@@ -1333,17 +1436,8 @@ onMounted(() => {
|
||||
}
|
||||
});
|
||||
|
||||
// Initialize sandbox offscreen for tile rendering
|
||||
sandboxOffscreen.value = true;
|
||||
sandboxVisible.value = true;
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
// Load and render tiles
|
||||
await loadLidarTiles();
|
||||
|
||||
// Hide sandbox
|
||||
sandboxVisible.value = false;
|
||||
sandboxOffscreen.value = false;
|
||||
// Load PNG tiles (no sandbox needed for initial display)
|
||||
await loadPngTiles();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1575,6 +1669,13 @@ onUnmounted(() => {
|
||||
color: #666;
|
||||
}
|
||||
|
||||
.context-menu-header .tile-name {
|
||||
display: block;
|
||||
margin-top: 4px;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
}
|
||||
|
||||
.context-menu-item {
|
||||
display: block;
|
||||
width: 100%;
|
||||
|
||||
136
ui/src/batch-renderer.js
Normal file
136
ui/src/batch-renderer.js
Normal file
@@ -0,0 +1,136 @@
|
||||
/**
|
||||
* Batch Renderer for Hopewell Lidar Tiles
|
||||
*
|
||||
* Usage in dev console with loaded app:
|
||||
* import { batchRenderTiles } from './batch-renderer.js';
|
||||
* const app = document.querySelector('#app').__vue_app__;
|
||||
* const sandboxRef = app._instance.refs.sandboxRef;
|
||||
* const tileCache = app._instance.data.tileCache;
|
||||
*
|
||||
* await batchRenderTiles(sandboxRef, tileCache, tileNames);
|
||||
*/
|
||||
|
||||
/**
|
||||
* Download a data URL as a file
|
||||
*/
|
||||
function downloadDataURL(dataURL, filename) {
|
||||
const link = document.createElement('a');
|
||||
link.href = dataURL;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
}
|
||||
|
||||
/**
|
||||
* Download text content as a file
|
||||
*/
|
||||
function downloadText(text, filename) {
|
||||
const blob = new Blob([text], { type: 'application/json' });
|
||||
const url = URL.createObjectURL(blob);
|
||||
const link = document.createElement('a');
|
||||
link.href = url;
|
||||
link.download = filename;
|
||||
document.body.appendChild(link);
|
||||
link.click();
|
||||
document.body.removeChild(link);
|
||||
URL.revokeObjectURL(url);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch render tiles using already-loaded app
|
||||
*
|
||||
* @param {Object} sandboxRef - Vue ref to ShadingSandbox component
|
||||
* @param {Object} tileCache - Cache of loaded tile data
|
||||
* @param {string[]} tileNames - Array of tile names to render
|
||||
* @param {Object} options - Options
|
||||
* @param {Object} options.renderSettings - Override render settings
|
||||
* @param {number} options.renderQuality - Render quality (default: 1024)
|
||||
*
|
||||
* @returns {Promise<Object[]>} Array of results
|
||||
*/
|
||||
export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options = {}) {
|
||||
const {
|
||||
renderSettings = null, // Use sandbox defaults if null
|
||||
renderQuality = 1024
|
||||
} = options;
|
||||
|
||||
console.log(`[BatchRenderer] Starting batch render of ${tileNames.length} tiles`);
|
||||
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
|
||||
|
||||
const results = [];
|
||||
const startTime = Date.now();
|
||||
|
||||
for (let i = 0; i < tileNames.length; i++) {
|
||||
const tileName = tileNames[i];
|
||||
const current = i + 1;
|
||||
const total = tileNames.length;
|
||||
|
||||
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileName}...`);
|
||||
|
||||
try {
|
||||
const tileData = tileCache[tileName];
|
||||
if (!tileData) {
|
||||
throw new Error(`Tile ${tileName} not found in cache`);
|
||||
}
|
||||
|
||||
// Render
|
||||
console.log(`[BatchRenderer] Rendering...`);
|
||||
const renderResult = await sandboxRef.renderTileWithSettings(
|
||||
tileData,
|
||||
renderSettings || sandboxRef.getSettings(),
|
||||
renderQuality
|
||||
);
|
||||
|
||||
if (!renderResult.success) {
|
||||
throw new Error(renderResult.error || 'Render failed');
|
||||
}
|
||||
|
||||
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
|
||||
|
||||
// Generate metadata
|
||||
const metadata = {
|
||||
tileName,
|
||||
bounds: tileData.bounds,
|
||||
renderSettings: renderSettings || sandboxRef.getSettings(),
|
||||
renderQuality,
|
||||
pointCount: tileData.pointCount,
|
||||
triangleCount: tileData.triangleCount,
|
||||
renderedAt: new Date().toISOString()
|
||||
};
|
||||
|
||||
// Download
|
||||
downloadDataURL(renderResult.dataURL, `${tileName}.png`);
|
||||
downloadText(JSON.stringify(metadata, null, 2), `${tileName}.json`);
|
||||
|
||||
results.push({
|
||||
tileName,
|
||||
metadata,
|
||||
renderTime: renderResult.renderTime,
|
||||
success: true
|
||||
});
|
||||
|
||||
console.log(`[BatchRenderer] ✓ Complete`);
|
||||
|
||||
// Small delay
|
||||
await new Promise(resolve => setTimeout(resolve, 100));
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
|
||||
results.push({
|
||||
tileName,
|
||||
success: false,
|
||||
error: err.message
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
const totalTime = Date.now() - startTime;
|
||||
const successCount = results.filter(r => r.success).length;
|
||||
const failCount = results.filter(r => !r.success).length;
|
||||
|
||||
console.log(`[BatchRenderer] Batch complete in ${(totalTime / 1000).toFixed(1)}s`);
|
||||
console.log(`[BatchRenderer] Success: ${successCount}, Failed: ${failCount}`);
|
||||
|
||||
return results;
|
||||
}
|
||||
Reference in New Issue
Block a user