Files
MoundHunters/ui/src/components/ContextMenu.vue
2026-01-25 12:55:30 +01:00

372 lines
9.6 KiB
Vue
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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