diff --git a/ui/src/App.vue b/ui/src/App.vue index f06f2c6..880be8f 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -28,12 +28,91 @@ Show Lidar +
+ + +
+ + +
+ +
+ +
+ + + +
+ +
+
+ {{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }} +
+ + +
+ + @@ -60,11 +139,26 @@ const sandboxOffscreen = ref(false); const baseLayer = ref('osm'); const showOctagon = ref(true); const showLidar = ref(true); +const showGeometry = ref(true); +const imperialUnits = ref(false); +const lidarOpacity = ref(80); const tileCache = ref({}); const currentTileData = ref(null); +// Geometry state +const drawMode = ref(null); // 'line', 'ray', or null +const drawPoints = ref([]); +const geometryFeatures = ref({ type: 'FeatureCollection', features: [] }); +const nextPinNumber = ref(1); +const nextFeatureId = ref(1); + +// UI state +const contextMenu = ref({ visible: false, x: 0, y: 0, lngLat: null }); +const popup = ref({ visible: false, x: 0, y: 0, type: null, feature: null }); + // Map instance let map = null; +let drawingHandler = null; // Tile names to load const TILE_NAMES = [ @@ -76,15 +170,15 @@ const TILE_NAMES = [ // Newark Octagon coordinates const octagonCoords = [ - [-82.44133, 40.05431], - [-82.44264, 40.05311], - [-82.44464, 40.05242], + [-82.44123, 40.05443], + [-82.44260, 40.05309], + [-82.44464, 40.05237], [-82.44631, 40.05342], [-82.44728, 40.05500], [-82.44589, 40.05633], - [-82.44389, 40.05697], - [-82.44192, 40.05589], - [-82.44133, 40.05431], // Close the polygon + [-82.44389, 40.05698], + [-82.44216, 40.05595], + [-82.44123, 40.05443], // Close the polygon ]; const octagonCenter = [-82.44383, 40.05469]; @@ -97,6 +191,104 @@ function webMercatorToLonLat(x, y) { return [lon, lat]; } +// Calculate distance between two points in meters (Haversine formula) +function calculateDistance(lng1, lat1, lng2, lat2) { + const R = 6371000; // Earth's radius in meters + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δφ = (lat2 - lat1) * Math.PI / 180; + const Δλ = (lng2 - lng1) * Math.PI / 180; + + const a = Math.sin(Δφ / 2) * Math.sin(Δφ / 2) + + Math.cos(φ1) * Math.cos(φ2) * + Math.sin(Δλ / 2) * Math.sin(Δλ / 2); + const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a)); + + return R * c; // Distance in meters +} + +// Calculate bearing between two points in degrees +function calculateBearing(lng1, lat1, lng2, lat2) { + const φ1 = lat1 * Math.PI / 180; + const φ2 = lat2 * Math.PI / 180; + const Δλ = (lng2 - lng1) * Math.PI / 180; + + const y = Math.sin(Δλ) * Math.cos(φ2); + const x = Math.cos(φ1) * Math.sin(φ2) - + Math.sin(φ1) * Math.cos(φ2) * Math.cos(Δλ); + const θ = Math.atan2(y, x); + + return (θ * 180 / Math.PI + 360) % 360; // Bearing in degrees +} + +// Extend a line from point1 through point2 to map bounds +function extendRay(lng1, lat1, lng2, lat2, bounds) { + const bearing = calculateBearing(lng1, lat1, lng2, lat2); + const bearingRad = bearing * Math.PI / 180; + + // Calculate a far point (100km away) + const R = 6371000; // Earth's radius in meters + const d = 100000; // 100km + const φ1 = lat1 * Math.PI / 180; + const λ1 = lng1 * Math.PI / 180; + + const φ2 = Math.asin(Math.sin(φ1) * Math.cos(d / R) + + Math.cos(φ1) * Math.sin(d / R) * Math.cos(bearingRad)); + const λ2 = λ1 + Math.atan2(Math.sin(bearingRad) * Math.sin(d / R) * Math.cos(φ1), + Math.cos(d / R) - Math.sin(φ1) * Math.sin(φ2)); + + return [λ2 * 180 / Math.PI, φ2 * 180 / Math.PI]; +} + +// Format coordinate based on imperial units setting +function formatCoordinate(value, type) { + return value.toFixed(6) + (type === 'lng' ? '° E' : '° N'); +} + +// Format distance based on imperial units setting +function formatDistance(meters) { + if (imperialUnits.value) { + const feet = meters * 3.28084; + if (feet > 5280) { + return (feet / 5280).toFixed(2) + ' mi'; + } + return feet.toFixed(1) + ' ft'; + } else { + if (meters > 1000) { + return (meters / 1000).toFixed(2) + ' km'; + } + return meters.toFixed(1) + ' m'; + } +} + +// localStorage helpers +function saveGeometry() { + localStorage.setItem('hopewellGeometry', JSON.stringify(geometryFeatures.value)); + localStorage.setItem('hopewellNextPinNumber', nextPinNumber.value.toString()); + localStorage.setItem('hopewellNextFeatureId', nextFeatureId.value.toString()); +} + +function loadGeometry() { + const saved = localStorage.getItem('hopewellGeometry'); + if (saved) { + try { + geometryFeatures.value = JSON.parse(saved); + } catch (e) { + console.error('Failed to load geometry:', e); + } + } + + const savedPinNumber = localStorage.getItem('hopewellNextPinNumber'); + if (savedPinNumber) { + nextPinNumber.value = parseInt(savedPinNumber); + } + + const savedFeatureId = localStorage.getItem('hopewellNextFeatureId'); + if (savedFeatureId) { + nextFeatureId.value = parseInt(savedFeatureId); + } +} + // Parse .mound binary file async function parseMoundFile(url) { const response = await fetch(url); @@ -189,7 +381,7 @@ async function loadLidarTiles() { 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 { @@ -237,16 +429,43 @@ function toggleLidar() { } } +function updateLidarOpacity() { + if (!map) return; + + const opacity = lidarOpacity.value / 100; + for (const tileName of TILE_NAMES) { + const layerId = `tile-layer-${tileName}`; + if (map.getLayer(layerId)) { + map.setPaintProperty(layerId, 'raster-opacity', opacity); + } + } +} + +function toggleGeometry() { + if (!map) return; + + const visibility = showGeometry.value ? 'visible' : 'none'; + if (map.getLayer('geometry-pins')) { + map.setLayoutProperty('geometry-pins', 'visibility', visibility); + } + if (map.getLayer('geometry-lines')) { + map.setLayoutProperty('geometry-lines', 'visibility', visibility); + } + if (map.getLayer('geometry-labels')) { + map.setLayoutProperty('geometry-labels', 'visibility', visibility); + } +} + function openSandbox() { - const firstTile = Object.keys(tileCache.value)[0]; - if (firstTile) { - currentTileData.value = tileCache.value[firstTile]; + if (Object.keys(tileCache.value).length > 0) { sandboxVisible.value = true; // Load tile data after sandbox mounts setTimeout(() => { if (sandboxRef.value) { - sandboxRef.value.loadTileData(currentTileData.value); + const firstTile = Object.keys(tileCache.value)[0]; + sandboxRef.value.loadTileData(tileCache.value[firstTile]); + sandboxRef.value.setAvailableTiles(tileCache.value); } }, 100); } @@ -264,8 +483,141 @@ function onRenderError(err) { console.error('Renderer error:', err); } +// Geometry functions +function setDrawMode(mode) { + if (drawMode.value === mode) { + drawMode.value = null; + drawPoints.value = []; + if (drawingHandler) { + map.off('click', drawingHandler); + drawingHandler = null; + } + } else { + drawMode.value = mode; + drawPoints.value = []; + + // Remove old handler + if (drawingHandler) { + map.off('click', drawingHandler); + } + + // Add new handler + drawingHandler = (e) => { + if (contextMenu.value.visible) return; // Ignore clicks while context menu is open + + drawPoints.value.push([e.lngLat.lng, e.lngLat.lat]); + + if (drawPoints.value.length === 2) { + completeDrawing(); + } + }; + + map.on('click', drawingHandler); + } +} + +function completeDrawing() { + const [pt1, pt2] = drawPoints.value; + const coords = drawMode.value === 'ray' + ? [pt1, extendRay(pt1[0], pt1[1], pt2[0], pt2[1], map.getBounds())] + : [pt1, pt2]; + + const distance = calculateDistance(coords[0][0], coords[0][1], coords[1][0], coords[1][1]); + const bearing = calculateBearing(coords[0][0], coords[0][1], coords[1][0], coords[1][1]); + + const feature = { + type: 'Feature', + id: `feature-${nextFeatureId.value++}`, + geometry: { + type: 'LineString', + coordinates: coords + }, + properties: { + type: drawMode.value, + length: distance, + bearing: bearing + } + }; + + geometryFeatures.value.features.push(feature); + updateGeometryLayer(); + saveGeometry(); + + // Reset + drawPoints.value = []; + setDrawMode(null); +} + +function dropPin() { + const { lng, lat } = contextMenu.value.lngLat; + + const feature = { + type: 'Feature', + id: `pin-${nextFeatureId.value++}`, + geometry: { + type: 'Point', + coordinates: [lng, lat] + }, + properties: { + type: 'pin', + number: nextPinNumber.value++ + } + }; + + geometryFeatures.value.features.push(feature); + updateGeometryLayer(); + saveGeometry(); + contextMenu.value.visible = false; +} + +function startMeasure() { + contextMenu.value.visible = false; + setDrawMode('line'); + const { lng, lat } = contextMenu.value.lngLat; + drawPoints.value = [[lng, lat]]; +} + +function deleteFeature(featureId) { + geometryFeatures.value.features = geometryFeatures.value.features.filter( + f => f.id !== featureId + ); + updateGeometryLayer(); + saveGeometry(); + popup.value.visible = false; +} + +function clearAllGeometry() { + if (confirm('Clear all pins, lines, and rays?')) { + geometryFeatures.value.features = []; + nextPinNumber.value = 1; + updateGeometryLayer(); + saveGeometry(); + } +} + +function updateGeometryLayer() { + if (!map || !map.getSource('geometry')) return; + + // Add id property to each feature for MapLibre + const dataWithIds = { + ...geometryFeatures.value, + features: geometryFeatures.value.features.map(f => ({ + ...f, + properties: { + ...f.properties, + id: f.id + } + })) + }; + + map.getSource('geometry').setData(dataWithIds); +} + // Lifecycle onMounted(() => { + // Load saved geometry + loadGeometry(); + map = new maplibregl.Map({ container: mapContainer.value, style: { @@ -298,6 +650,11 @@ onMounted(() => { type: 'raster', source: 'satellite', layout: { visibility: 'none' } + }, + { + id: 'lidar-datum', + type: 'background', + paint: { 'background-opacity': 0 } } ] }, @@ -305,6 +662,72 @@ onMounted(() => { zoom: 15 }); + // Context menu handler + map.on('contextmenu', (e) => { + e.preventDefault(); + contextMenu.value = { + visible: true, + x: e.point.x, + y: e.point.y, + lngLat: e.lngLat + }; + }); + + // Close context menu on regular click + map.on('click', (e) => { + // Check if clicking on geometry features first + const features = map.queryRenderedFeatures(e.point, { + layers: ['geometry-pins', 'geometry-lines'] + }); + + if (features.length > 0 && !drawMode.value) { + const feature = features[0]; + const featureData = geometryFeatures.value.features.find(f => f.id === feature.properties.id); + + if (featureData) { + popup.value = { + visible: true, + x: e.point.x, + y: e.point.y, + type: featureData.properties.type, + feature: featureData + }; + } + } else { + popup.value.visible = false; + } + + contextMenu.value.visible = false; + }); + + // Close menus on ESC + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') { + contextMenu.value.visible = false; + popup.value.visible = false; + if (drawMode.value) { + setDrawMode(null); + } + } + }); + + // Change cursor for clickable geometry + map.on('mouseenter', 'geometry-pins', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', 'geometry-pins', () => { + map.getCanvas().style.cursor = ''; + }); + + map.on('mouseenter', 'geometry-lines', () => { + map.getCanvas().style.cursor = 'pointer'; + }); + + map.on('mouseleave', 'geometry-lines', () => { + map.getCanvas().style.cursor = ''; + }); + map.on('load', async () => { // Add octagon overlay map.addSource('octagon', { @@ -338,6 +761,55 @@ onMounted(() => { } }); + // Add geometry source and layers + map.addSource('geometry', { + type: 'geojson', + data: geometryFeatures.value + }); + + // Lines and rays + map.addLayer({ + id: 'geometry-lines', + type: 'line', + source: 'geometry', + filter: ['in', ['get', 'type'], ['literal', ['line', 'ray']]], + paint: { + 'line-color': '#0080ff', + 'line-width': 3 + } + }); + + // Pins + map.addLayer({ + id: 'geometry-pins', + type: 'circle', + source: 'geometry', + filter: ['==', ['get', 'type'], 'pin'], + paint: { + 'circle-radius': 8, + 'circle-color': '#ff0000', + 'circle-stroke-width': 2, + 'circle-stroke-color': '#ffffff' + } + }); + + // Pin labels + map.addLayer({ + id: 'geometry-labels', + type: 'symbol', + source: 'geometry', + filter: ['==', ['get', 'type'], 'pin'], + layout: { + 'text-field': ['get', 'number'], + 'text-size': 12, + 'text-offset': [0, 0], + 'text-anchor': 'center' + }, + paint: { + 'text-color': '#ffffff' + } + }); + // Initialize sandbox offscreen for tile rendering sandboxOffscreen.value = true; sandboxVisible.value = true; @@ -437,4 +909,172 @@ onUnmounted(() => { .sandbox-btn:active { transform: scale(0.98); } + +.slider-control { + margin-top: 10px; + margin-left: 20px; +} + +.slider-control label { + font-weight: normal !important; + margin-bottom: 5px !important; +} + +.opacity-slider { + width: 100%; + cursor: pointer; +} + +.geometry-toolbar { + position: absolute; + top: 20px; + right: 20px; + background: white; + padding: 10px; + border-radius: 4px; + box-shadow: 0 2px 8px rgba(0,0,0,0.3); + display: flex; + flex-direction: column; + gap: 8px; +} + +.tool-btn { + padding: 10px 15px; + background: white; + border: 2px solid #ddd; + border-radius: 4px; + font-size: 14px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; + white-space: nowrap; +} + +.tool-btn:hover { + background: #f5f5f5; + border-color: #999; +} + +.tool-btn.active { + background: #4A9EFF; + color: white; + border-color: #4A9EFF; +} + +.tool-btn.danger { + color: #d32f2f; +} + +.tool-btn.danger:hover { + background: #ffebee; + border-color: #d32f2f; +} + +.context-menu { + position: absolute; + background: white; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0,0,0,0.4); + z-index: 1000; + min-width: 200px; + overflow: hidden; +} + +.context-menu-header { + padding: 10px 12px; + background: #f5f5f5; + font-size: 12px; + font-family: monospace; + border-bottom: 1px solid #ddd; + color: #666; +} + +.context-menu-item { + display: block; + width: 100%; + padding: 10px 12px; + background: white; + border: none; + text-align: left; + font-size: 14px; + cursor: pointer; + transition: background 0.2s; +} + +.context-menu-item:hover { + background: #f0f0f0; +} + +.popup { + position: absolute; + background: white; + border-radius: 4px; + box-shadow: 0 2px 12px rgba(0,0,0,0.4); + z-index: 1000; + min-width: 180px; + overflow: hidden; +} + +.popup-content { + padding: 12px; + font-size: 14px; +} + +.popup-content strong { + display: block; + margin-bottom: 8px; + color: #333; +} + +.popup-content div { + margin: 4px 0; + font-size: 13px; + color: #666; +} + +.popup-btn { + display: block; + width: 100%; + padding: 8px; + margin-top: 10px; + background: white; + border: 1px solid #ddd; + border-radius: 3px; + font-size: 13px; + font-weight: 600; + cursor: pointer; + transition: all 0.2s; +} + +.popup-btn:hover { + background: #f5f5f5; +} + +.popup-btn.danger { + color: #d32f2f; + border-color: #d32f2f; +} + +.popup-btn.danger:hover { + background: #ffebee; +} + +.popup-close { + position: absolute; + top: 4px; + right: 4px; + width: 24px; + height: 24px; + background: none; + border: none; + font-size: 20px; + line-height: 1; + cursor: pointer; + color: #999; + transition: color 0.2s; +} + +.popup-close:hover { + color: #333; +} \ No newline at end of file