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