372 lines
9.6 KiB
Vue
372 lines
9.6 KiB
Vue
<template>
|
||
<div
|
||
v-if="visible"
|
||
class="context-menu"
|
||
:style="{ left: x + 'px', top: y + 'px' }"
|
||
>
|
||
<!-- ============================================= -->
|
||
<!-- HEADER - COORDINATES -->
|
||
<!-- ============================================= -->
|
||
<div class="context-menu-header">
|
||
{{ formatCoordinate(lngLat.lng, 'lng') }}, {{ formatCoordinate(lngLat.lat, 'lat') }}
|
||
<span v-if="tileMetadata?.id" class="tile-name">{{ tileMetadata.id }}</span>
|
||
<span v-if="metadataError" class="tile-error">⚠️ Lookup failed</span>
|
||
<span v-if="processingStatus" class="tile-status">{{ processingStatus }}</span>
|
||
</div>
|
||
|
||
<!-- ============================================= -->
|
||
<!-- MENU ITEMS -->
|
||
<!-- ============================================= -->
|
||
|
||
<!-- Always available -->
|
||
<button @click="handleDropPin" class="context-menu-item">📍 Drop Pin</button>
|
||
<button @click="handleStartMeasure" class="context-menu-item">📏 Measure from here</button>
|
||
|
||
<!-- State A: No tile exists (or error checking) -->
|
||
<button
|
||
v-if="!tileMetadata && !metadataError && !requestingTile"
|
||
@click="handleRequestTile"
|
||
class="context-menu-item"
|
||
>
|
||
📥 Request Tile
|
||
</button>
|
||
|
||
<!-- Show status while requesting -->
|
||
<div v-if="requestingTile" class="context-menu-status">
|
||
⏳ {{ processingStatus || 'Requesting tile...' }}
|
||
</div>
|
||
|
||
<!-- State B: Tile exists but images not loaded -->
|
||
<button
|
||
v-if="tileMetadata && !imagesOnMap && hasAvailableImages"
|
||
@click="handleLoadImages"
|
||
class="context-menu-item"
|
||
:disabled="loadingImages"
|
||
>
|
||
{{ loadingImages ? '⏳ Loading...' : '📦 Load Tile Images' }}
|
||
</button>
|
||
|
||
<!-- State C: Images loaded, but mound not loaded -->
|
||
<button
|
||
v-if="tileMetadata && !hasMoundData"
|
||
@click="handleLoadMound"
|
||
class="context-menu-item"
|
||
:disabled="loadingMound"
|
||
>
|
||
{{ loadingMound ? '⏳ Loading...' : '🔬 Load Interactive Data' }}
|
||
</button>
|
||
|
||
<!-- State D: Mound loaded, ready to open sandbox -->
|
||
<button
|
||
v-if="hasMoundData"
|
||
@click="handleOpenSandbox"
|
||
class="context-menu-item"
|
||
>
|
||
🔬 Open in Shading Sandbox
|
||
</button>
|
||
</div>
|
||
</template>
|
||
|
||
<script setup>
|
||
import { ref, computed, watch } from 'vue';
|
||
import { formatCoordinate } from '../utils/coordinates.js';
|
||
import { useTilesStore } from '../stores/tiles.js';
|
||
|
||
// ============================================================================
|
||
// PROPS & EMITS
|
||
// ============================================================================
|
||
|
||
const props = defineProps({
|
||
parseMoundBuffer: {
|
||
type: Function,
|
||
required: true
|
||
}
|
||
});
|
||
|
||
const emit = defineEmits([
|
||
'dropPin',
|
||
'startMeasure',
|
||
'addImagesToMap', // Images loaded, ready to add to map
|
||
'openSandbox' // Mound loaded, ready to open sandbox
|
||
]);
|
||
|
||
// ============================================================================
|
||
// STORE & STATE
|
||
// ============================================================================
|
||
|
||
const tilesStore = useTilesStore();
|
||
|
||
// Own state
|
||
const visible = ref(false);
|
||
const x = ref(0);
|
||
const y = ref(0);
|
||
const lngLat = ref({ lng: 0, lat: 0 });
|
||
|
||
// Loading/processing states
|
||
const loadingImages = ref(false);
|
||
const loadingMound = ref(false);
|
||
const metadataError = ref(false);
|
||
const requestingTile = ref(false);
|
||
const processingStatus = ref(null);
|
||
|
||
// ============================================================================
|
||
// COMPUTED - TILE STATE FROM STORE
|
||
// ============================================================================
|
||
|
||
const tileId = computed(() => {
|
||
return tilesStore.findTileByCoords(lngLat.value.lat, lngLat.value.lng);
|
||
});
|
||
|
||
const tileMetadata = computed(() => {
|
||
return tileId.value ? tilesStore.getMetadata(tileId.value) : null;
|
||
});
|
||
|
||
const imagesOnMap = computed(() => {
|
||
return tileId.value ? tilesStore.areImagesOnMap(tileId.value) : false;
|
||
});
|
||
|
||
const hasMoundData = computed(() => {
|
||
return tileId.value ? tilesStore.hasMoundData(tileId.value) : false;
|
||
});
|
||
|
||
const hasAvailableImages = computed(() => {
|
||
if (!tileMetadata.value) return false;
|
||
return tileMetadata.value.jpg_available || tileMetadata.value.png_available;
|
||
});
|
||
|
||
// ============================================================================
|
||
// WATCHERS - FETCH METADATA WHEN MENU OPENS
|
||
// ============================================================================
|
||
|
||
watch(visible, async (isVisible) => {
|
||
if (isVisible && !tileId.value) {
|
||
await fetchMetadata();
|
||
}
|
||
});
|
||
|
||
// ============================================================================
|
||
// EXPOSED METHODS
|
||
// ============================================================================
|
||
|
||
function show(mouseX, mouseY, coordinates) {
|
||
x.value = mouseX;
|
||
y.value = mouseY;
|
||
lngLat.value = coordinates;
|
||
visible.value = true;
|
||
|
||
// Reset states
|
||
metadataError.value = false;
|
||
requestingTile.value = false;
|
||
processingStatus.value = null;
|
||
}
|
||
|
||
function hide() {
|
||
visible.value = false;
|
||
}
|
||
|
||
defineExpose({ show, hide });
|
||
|
||
// ============================================================================
|
||
// ACTIONS - METADATA
|
||
// ============================================================================
|
||
|
||
async function fetchMetadata() {
|
||
metadataError.value = false;
|
||
|
||
try {
|
||
await tilesStore.fetchMetadataByCoords(lngLat.value.lat, lngLat.value.lng);
|
||
} catch (err) {
|
||
console.error('Failed to fetch tile metadata:', err);
|
||
metadataError.value = true;
|
||
}
|
||
}
|
||
|
||
// ============================================================================
|
||
// ACTIONS - TILE OPERATIONS
|
||
// ============================================================================
|
||
|
||
async function handleRequestTile() {
|
||
requestingTile.value = true;
|
||
processingStatus.value = 'Looking up tile...';
|
||
|
||
const eventSource = tilesStore.requestTileProcessing(
|
||
lngLat.value.lat,
|
||
lngLat.value.lng,
|
||
async (data) => {
|
||
processingStatus.value = data.message || data.status;
|
||
|
||
// On ready: auto-load mound and open sandbox
|
||
if (data.status === 'ready' && data.tile_id) {
|
||
try {
|
||
await tilesStore.fetchMoundData(data.tile_id, props.parseMoundBuffer);
|
||
emit('openSandbox', data.tile_id);
|
||
|
||
// Close connection and hide menu
|
||
eventSource.close();
|
||
setTimeout(() => hide(), 500);
|
||
} catch (err) {
|
||
console.error('Failed to load mound after processing:', err);
|
||
processingStatus.value = 'Error loading data';
|
||
requestingTile.value = false;
|
||
}
|
||
}
|
||
|
||
if (data.status === 'error') {
|
||
requestingTile.value = false;
|
||
setTimeout(() => {
|
||
if (visible.value) {
|
||
processingStatus.value = null;
|
||
}
|
||
}, 5000);
|
||
}
|
||
},
|
||
(error) => {
|
||
console.error('Tile processing error:', error);
|
||
processingStatus.value = 'Connection failed';
|
||
requestingTile.value = false;
|
||
|
||
setTimeout(() => {
|
||
if (visible.value) {
|
||
processingStatus.value = null;
|
||
}
|
||
}, 5000);
|
||
}
|
||
);
|
||
}
|
||
|
||
async function handleLoadImages() {
|
||
if (!tileId.value || loadingImages.value) return;
|
||
|
||
loadingImages.value = true;
|
||
|
||
try {
|
||
emit('addImagesToMap', tileId.value);
|
||
} finally {
|
||
loadingImages.value = false;
|
||
}
|
||
}
|
||
|
||
async function handleLoadMound() {
|
||
if (!tileId.value || loadingMound.value) return;
|
||
|
||
loadingMound.value = true;
|
||
|
||
try {
|
||
await tilesStore.fetchMoundData(tileId.value, props.parseMoundBuffer);
|
||
} catch (err) {
|
||
console.error('Failed to load mound data:', err);
|
||
} finally {
|
||
loadingMound.value = false;
|
||
}
|
||
}
|
||
|
||
function handleOpenSandbox() {
|
||
if (!tileId.value) return;
|
||
emit('openSandbox', tileId.value);
|
||
hide();
|
||
}
|
||
|
||
function handleDropPin() {
|
||
emit('dropPin', lngLat.value);
|
||
hide();
|
||
}
|
||
|
||
function handleStartMeasure() {
|
||
emit('startMeasure', lngLat.value);
|
||
hide();
|
||
}
|
||
</script>
|
||
|
||
<style scoped>
|
||
/* ============================================= */
|
||
/* CONTEXT MENU CONTAINER */
|
||
/* ============================================= */
|
||
|
||
.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;
|
||
}
|
||
|
||
/* ============================================= */
|
||
/* HEADER */
|
||
/* ============================================= */
|
||
|
||
.context-menu-header {
|
||
padding: 10px 12px;
|
||
background: #f5f5f5;
|
||
font-size: 12px;
|
||
font-family: monospace;
|
||
border-bottom: 1px solid #ddd;
|
||
color: #666;
|
||
}
|
||
|
||
.context-menu-header .tile-name {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: #999;
|
||
}
|
||
|
||
.context-menu-header .tile-error {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: #f44336;
|
||
font-weight: 600;
|
||
}
|
||
|
||
.context-menu-header .tile-status {
|
||
display: block;
|
||
margin-top: 4px;
|
||
font-size: 11px;
|
||
color: #2196F3;
|
||
font-weight: 500;
|
||
}
|
||
|
||
/* ============================================= */
|
||
/* STATUS DISPLAY */
|
||
/* ============================================= */
|
||
|
||
.context-menu-status {
|
||
padding: 10px 12px;
|
||
font-size: 13px;
|
||
color: #666;
|
||
background: #f9f9f9;
|
||
border-top: 1px solid #eee;
|
||
}
|
||
|
||
/* ============================================= */
|
||
/* MENU ITEMS */
|
||
/* ============================================= */
|
||
|
||
.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;
|
||
}
|
||
|
||
.context-menu-item:disabled {
|
||
cursor: not-allowed;
|
||
opacity: 0.6;
|
||
background: #f9f9f9;
|
||
}
|
||
|
||
.context-menu-item:disabled:hover {
|
||
background: #f9f9f9;
|
||
}
|
||
</style> |