diff --git a/ui/public/tiles b/ui/public/mound similarity index 100% rename from ui/public/tiles rename to ui/public/mound diff --git a/ui/public/png b/ui/public/png new file mode 120000 index 0000000..716ebab --- /dev/null +++ b/ui/public/png @@ -0,0 +1 @@ +/home/mark/projects/moundhunters/data/PNG \ No newline at end of file diff --git a/ui/src/App.vue b/ui/src/App.vue index 1052511..df35111 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -102,10 +102,13 @@ >
{{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }} + {{ contextMenu.tileName }}
+ +
import { ref, onMounted, onUnmounted } from 'vue'; + +// Devmode rendering +//import { batchRenderTiles } from './batch-renderer.js'; +// + 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%; diff --git a/ui/src/batch-renderer.js b/ui/src/batch-renderer.js new file mode 100644 index 0000000..ad9142e --- /dev/null +++ b/ui/src/batch-renderer.js @@ -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} 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; +} \ No newline at end of file