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 @@
>
+
+
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