feat: geometry + opacity tools
This commit is contained in:
662
ui/src/App.vue
662
ui/src/App.vue
@@ -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>
|
||||||
Reference in New Issue
Block a user