feat: geometry + opacity tools

This commit is contained in:
2026-01-21 21:55:07 +01:00
parent cf2bcdc059
commit e82b3421c0

View File

@@ -28,12 +28,91 @@
<input type="checkbox" v-model="showLidar" @change="toggleLidar"> <input type="checkbox" v-model="showLidar" @change="toggleLidar">
Show Lidar Show Lidar
</label> </label>
<div v-if="showLidar" class="slider-control">
<label>Opacity: {{ Math.round(lidarOpacity) }}%</label>
<input
type="range"
min="0"
max="100"
v-model.number="lidarOpacity"
@input="updateLidarOpacity"
class="opacity-slider"
>
</div>
</div>
<div class="control-section">
<label>
<input type="checkbox" v-model="showGeometry" @change="toggleGeometry">
Show Geometry
</label>
<label>
<input type="checkbox" v-model="imperialUnits">
Imperial Units
</label>
</div> </div>
<div class="control-section"> <div class="control-section">
<button class="sandbox-btn" @click="openSandbox">Open Shading Sandbox</button> <button class="sandbox-btn" @click="openSandbox">Open Shading Sandbox</button>
</div> </div>
</div> </div>
<div class="geometry-toolbar">
<button
:class="['tool-btn', { active: drawMode === 'line' }]"
@click="setDrawMode('line')"
title="Draw Line"
>
📏 Line
</button>
<button
:class="['tool-btn', { active: drawMode === 'ray' }]"
@click="setDrawMode('ray')"
title="Draw Ray"
>
Ray
</button>
<button
class="tool-btn danger"
@click="clearAllGeometry"
title="Clear All Geometry"
>
🗑 Clear
</button>
</div>
<div
v-if="contextMenu.visible"
class="context-menu"
:style="{ left: contextMenu.x + 'px', top: contextMenu.y + 'px' }"
>
<div class="context-menu-header">
{{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }}
</div>
<button @click="dropPin" class="context-menu-item">📍 Drop Pin</button>
<button @click="startMeasure" class="context-menu-item">📏 Measure from here</button>
</div>
<div
v-if="popup.visible"
class="popup"
:style="{ left: popup.x + 'px', top: popup.y + 'px' }"
>
<div class="popup-content">
<div v-if="popup.type === 'pin'">
<strong>Pin #{{ popup.feature.properties.number }}</strong>
<div>{{ formatCoordinate(popup.feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(popup.feature.geometry.coordinates[1], 'lat') }}</div>
<button @click="deleteFeature(popup.feature.id)" class="popup-btn danger">Delete</button>
</div>
<div v-else-if="popup.type === 'line' || popup.type === 'ray'">
<strong>{{ popup.type === 'line' ? 'Line' : 'Ray' }}</strong>
<div>Length: {{ formatDistance(popup.feature.properties.length) }}</div>
<div>Bearing: {{ popup.feature.properties.bearing.toFixed(1) }}°</div>
<button @click="deleteFeature(popup.feature.id)" class="popup-btn danger">Delete</button>
</div>
</div>
<button @click="popup.visible = false" class="popup-close">×</button>
</div>
</div> </div>
</template> </template>
@@ -60,11 +139,26 @@ const sandboxOffscreen = ref(false);
const baseLayer = ref('osm'); const baseLayer = ref('osm');
const showOctagon = ref(true); const showOctagon = ref(true);
const showLidar = ref(true); const showLidar = ref(true);
const showGeometry = ref(true);
const imperialUnits = ref(false);
const lidarOpacity = ref(80);
const tileCache = ref({}); const tileCache = ref({});
const currentTileData = ref(null); 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 // Map instance
let map = null; let map = null;
let drawingHandler = null;
// Tile names to load // Tile names to load
const TILE_NAMES = [ const TILE_NAMES = [
@@ -76,15 +170,15 @@ const TILE_NAMES = [
// Newark Octagon coordinates // Newark Octagon coordinates
const octagonCoords = [ const octagonCoords = [
[-82.44133, 40.05431], [-82.44123, 40.05443],
[-82.44264, 40.05311], [-82.44260, 40.05309],
[-82.44464, 40.05242], [-82.44464, 40.05237],
[-82.44631, 40.05342], [-82.44631, 40.05342],
[-82.44728, 40.05500], [-82.44728, 40.05500],
[-82.44589, 40.05633], [-82.44589, 40.05633],
[-82.44389, 40.05697], [-82.44389, 40.05698],
[-82.44192, 40.05589], [-82.44216, 40.05595],
[-82.44133, 40.05431], // Close the polygon [-82.44123, 40.05443], // Close the polygon
]; ];
const octagonCenter = [-82.44383, 40.05469]; const octagonCenter = [-82.44383, 40.05469];
@@ -97,6 +191,104 @@ function webMercatorToLonLat(x, y) {
return [lon, lat]; 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 // Parse .mound binary file
async function parseMoundFile(url) { async function parseMoundFile(url) {
const response = await fetch(url); const response = await fetch(url);
@@ -189,7 +381,7 @@ async function loadLidarTiles() {
paint: { paint: {
'raster-opacity': 0.8 '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)}]`); console.log(`Added ${tileName} to map at [${sw[0].toFixed(5)}, ${sw[1].toFixed(5)}] - [${ne[0].toFixed(5)}, ${ne[1].toFixed(5)}]`);
} else { } 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() { function openSandbox() {
const firstTile = Object.keys(tileCache.value)[0]; if (Object.keys(tileCache.value).length > 0) {
if (firstTile) {
currentTileData.value = tileCache.value[firstTile];
sandboxVisible.value = true; sandboxVisible.value = true;
// Load tile data after sandbox mounts // Load tile data after sandbox mounts
setTimeout(() => { setTimeout(() => {
if (sandboxRef.value) { 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); }, 100);
} }
@@ -264,8 +483,141 @@ function onRenderError(err) {
console.error('Renderer error:', 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 // Lifecycle
onMounted(() => { onMounted(() => {
// Load saved geometry
loadGeometry();
map = new maplibregl.Map({ map = new maplibregl.Map({
container: mapContainer.value, container: mapContainer.value,
style: { style: {
@@ -298,6 +650,11 @@ onMounted(() => {
type: 'raster', type: 'raster',
source: 'satellite', source: 'satellite',
layout: { visibility: 'none' } layout: { visibility: 'none' }
},
{
id: 'lidar-datum',
type: 'background',
paint: { 'background-opacity': 0 }
} }
] ]
}, },
@@ -305,6 +662,72 @@ onMounted(() => {
zoom: 15 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 () => { map.on('load', async () => {
// Add octagon overlay // Add octagon overlay
map.addSource('octagon', { 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 // Initialize sandbox offscreen for tile rendering
sandboxOffscreen.value = true; sandboxOffscreen.value = true;
sandboxVisible.value = true; sandboxVisible.value = true;
@@ -437,4 +909,172 @@ onUnmounted(() => {
.sandbox-btn:active { .sandbox-btn:active {
transform: scale(0.98); 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;
}
</style> </style>