full frontend refactor
This commit is contained in:
279
ui/src/components/Bibliography.vue
Normal file
279
ui/src/components/Bibliography.vue
Normal file
@@ -0,0 +1,279 @@
|
||||
<template>
|
||||
<div v-if="visible" class="bibliography-overlay" @click="handleOverlayClick">
|
||||
<div class="bibliography-modal" @click.stop>
|
||||
<!-- ============================================= -->
|
||||
<!-- HEADER -->
|
||||
<!-- ============================================= -->
|
||||
<div class="bibliography-header">
|
||||
<h2>Bibliography</h2>
|
||||
<button class="close-btn" @click="$emit('close')" title="Close">×</button>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- CONTENT -->
|
||||
<!-- ============================================= -->
|
||||
<div class="bibliography-content">
|
||||
<div
|
||||
v-for="(entry, key) in bibliography"
|
||||
:key="key"
|
||||
:class="['bibliography-entry', { highlighted: key === highlightedKey }]"
|
||||
:id="`bib-${key}`"
|
||||
>
|
||||
<div class="citation-key">[{{ key }}]</div>
|
||||
<div class="citation-text">
|
||||
<template v-if="entry.type === 'article'">
|
||||
<span class="authors">{{ formatAuthors(entry.author) }}</span>
|
||||
({{ entry.year }}).
|
||||
<br/>
|
||||
"{{ entry.title }}."
|
||||
<em>{{ entry.journal }}</em><template v-if="entry.volume">, {{ entry.volume }}</template><template v-if="entry.number">({{ entry.number }})</template><template v-if="entry.pages">: {{ entry.pages }}</template>.
|
||||
</template>
|
||||
|
||||
<template v-else-if="entry.type === 'book'">
|
||||
<span class="authors">{{ formatAuthors(entry.author) }}</span>
|
||||
({{ entry.year }}).
|
||||
<br/>
|
||||
<em>{{ entry.title }}</em>.
|
||||
<template v-if="entry.series">{{ entry.series }}. </template>
|
||||
{{ entry.publisher }}.
|
||||
</template>
|
||||
|
||||
<template v-else-if="entry.type === 'incollection'">
|
||||
<span class="authors">{{ formatAuthors(entry.author) }}</span>
|
||||
({{ entry.year }}).
|
||||
<br/>
|
||||
"{{ entry.title }}."
|
||||
In <em>{{ entry.booktitle }}</em><template v-if="entry.editor">, edited by {{ formatAuthors(entry.editor) }}</template>.
|
||||
{{ entry.publisher }}.
|
||||
</template>
|
||||
|
||||
<template v-else-if="entry.type === 'misc'">
|
||||
<span class="authors">{{ formatAuthors(entry.author) }}</span><template v-if="entry.year"> ({{ entry.year }})</template>.
|
||||
<br/>
|
||||
<template v-if="entry.url">
|
||||
<a :href="entry.url" target="_blank" rel="noopener">{{ entry.title }}</a>
|
||||
</template>
|
||||
<template v-else>
|
||||
<em>{{ entry.title }}</em>
|
||||
</template>.
|
||||
</template>
|
||||
|
||||
<div v-if="entry.note" class="citation-note">{{ entry.note }}</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { watch, nextTick } from 'vue';
|
||||
import { BIBLIOGRAPHY } from '../data/bibliography.js';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
highlightedKey: {
|
||||
type: String,
|
||||
default: null
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close']);
|
||||
|
||||
// ============================================================================
|
||||
// DATA
|
||||
// ============================================================================
|
||||
|
||||
const bibliography = BIBLIOGRAPHY;
|
||||
|
||||
// ============================================================================
|
||||
// METHODS
|
||||
// ============================================================================
|
||||
|
||||
function formatAuthors(authors) {
|
||||
if (!authors || authors.length === 0) return '';
|
||||
|
||||
if (authors.length === 1) {
|
||||
return authors[0];
|
||||
} else if (authors.length === 2) {
|
||||
return `${authors[0]} and ${authors[1]}`;
|
||||
} else {
|
||||
return `${authors[0]} et al.`;
|
||||
}
|
||||
}
|
||||
|
||||
function handleOverlayClick() {
|
||||
emit('close');
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// LIFECYCLE
|
||||
// ============================================================================
|
||||
|
||||
// Scroll to highlighted entry when it changes
|
||||
watch(() => props.highlightedKey, (newKey) => {
|
||||
if (newKey && props.visible) {
|
||||
nextTick(() => {
|
||||
const element = document.getElementById(`bib-${newKey}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
// Scroll to highlighted entry when modal opens
|
||||
watch(() => props.visible, (newVisible) => {
|
||||
if (newVisible && props.highlightedKey) {
|
||||
nextTick(() => {
|
||||
const element = document.getElementById(`bib-${props.highlightedKey}`);
|
||||
if (element) {
|
||||
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================= */
|
||||
/* OVERLAY */
|
||||
/* ============================================= */
|
||||
|
||||
.bibliography-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 2000;
|
||||
padding: 20px;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* MODAL */
|
||||
/* ============================================= */
|
||||
|
||||
.bibliography-modal {
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
|
||||
max-width: 900px;
|
||||
width: 100%;
|
||||
max-height: 80vh;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* HEADER */
|
||||
/* ============================================= */
|
||||
|
||||
.bibliography-header {
|
||||
padding: 20px 24px;
|
||||
border-bottom: 2px solid #eee;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
.bibliography-header h2 {
|
||||
margin: 0;
|
||||
font-size: 24px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.close-btn {
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 28px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.close-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* CONTENT */
|
||||
/* ============================================= */
|
||||
|
||||
.bibliography-content {
|
||||
padding: 24px;
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.bibliography-entry {
|
||||
display: flex;
|
||||
gap: 16px;
|
||||
margin-bottom: 4px;
|
||||
padding: 12px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.3s;
|
||||
}
|
||||
|
||||
.bibliography-entry.highlighted {
|
||||
background-color: #FFF9C4;
|
||||
border-left: 4px solid #FBC02D;
|
||||
padding-left: 8px;
|
||||
}
|
||||
|
||||
.citation-key {
|
||||
font-family: monospace;
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
flex-shrink: 0;
|
||||
min-width: 12rem;
|
||||
}
|
||||
|
||||
.citation-text {
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
.authors {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.citation-note {
|
||||
margin-top: 8px;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
font-style: italic;
|
||||
padding-left: 16px;
|
||||
border-left: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.citation-text a {
|
||||
color: #4A9EFF;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
.citation-text a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.citation-text em {
|
||||
font-style: italic;
|
||||
}
|
||||
</style>
|
||||
169
ui/src/components/ContextMenu.vue
Normal file
169
ui/src/components/ContextMenu.vue
Normal file
@@ -0,0 +1,169 @@
|
||||
<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="tileData?.id" class="tile-name">{{ tileData.id }}</span>
|
||||
<span v-if="apiError" class="tile-error">⚠️ Lookup failed</span>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- MENU ITEMS -->
|
||||
<!-- ============================================= -->
|
||||
|
||||
<!-- Always available -->
|
||||
<button @click="$emit('dropPin')" class="context-menu-item">📍 Drop Pin</button>
|
||||
<button @click="$emit('startMeasure')" class="context-menu-item">📏 Measure from here</button>
|
||||
|
||||
<!-- State A: No tile exists (or error checking) -->
|
||||
<button
|
||||
v-if="!tileData && !apiError"
|
||||
@click="$emit('requestTile')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
📥 Request Tile
|
||||
</button>
|
||||
|
||||
<!-- State B: Tile exists but images not loaded -->
|
||||
<button
|
||||
v-if="tileData && !imagesLoaded && (tileData.jpg_available || tileData.png_available)"
|
||||
@click="$emit('loadImages')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
📦 Load Tile Images
|
||||
</button>
|
||||
|
||||
<!-- State C: Images loaded, but mound not loaded -->
|
||||
<button
|
||||
v-if="tileData && !moundLoaded"
|
||||
@click="$emit('loadMound')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
🔬 Load Interactive Data
|
||||
</button>
|
||||
|
||||
<!-- State D: Mound loaded, ready to open sandbox -->
|
||||
<button
|
||||
v-if="moundLoaded"
|
||||
@click="$emit('openSandbox')"
|
||||
class="context-menu-item"
|
||||
>
|
||||
🔬 Open in Shading Sandbox
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { formatCoordinate } from '../utils/coordinates.js';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
lngLat: {
|
||||
type: Object, // { lng, lat }
|
||||
required: true
|
||||
},
|
||||
tileData: {
|
||||
type: Object, // Tile metadata from API
|
||||
default: null
|
||||
},
|
||||
imagesLoaded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
moundLoaded: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
apiError: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['dropPin', 'startMeasure', 'requestTile', 'loadImages', 'loadMound', 'openSandbox']);
|
||||
</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;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* 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;
|
||||
}
|
||||
</style>
|
||||
230
ui/src/components/FeaturePopup.vue
Normal file
230
ui/src/components/FeaturePopup.vue
Normal file
@@ -0,0 +1,230 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="visible"
|
||||
class="popup"
|
||||
:style="{ left: x + 'px', top: y + 'px' }"
|
||||
>
|
||||
<!-- ============================================= -->
|
||||
<!-- POPUP CONTENT -->
|
||||
<!-- ============================================= -->
|
||||
<div class="popup-content">
|
||||
<!-- PIN -->
|
||||
<div v-if="type === 'pin'">
|
||||
<strong>Pin #{{ feature.properties.number }}</strong>
|
||||
<div>{{ formatCoordinate(feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(feature.geometry.coordinates[1], 'lat') }}</div>
|
||||
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- LINE or RAY -->
|
||||
<div v-else-if="type === 'line' || type === 'ray'">
|
||||
<strong>{{ type === 'line' ? 'Line' : 'Ray' }}</strong>
|
||||
<div>Length: {{ formatDistance(feature.properties.length, imperialUnits) }}</div>
|
||||
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
|
||||
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
|
||||
</div>
|
||||
|
||||
<!-- HISTORIC SITE -->
|
||||
<div v-else-if="type === 'site'" class="site-popup">
|
||||
<strong>{{ feature.properties.name }}</strong>
|
||||
<div class="site-type">{{ feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }}</div>
|
||||
<div class="site-description">
|
||||
<template v-for="(segment, idx) in parsedDescription" :key="idx">
|
||||
<template v-if="segment.type === 'text'">{{ segment.content }}</template>
|
||||
<a
|
||||
v-else-if="segment.type === 'citation'"
|
||||
href="#"
|
||||
@click.prevent="$emit('showBibliography', segment.key)"
|
||||
class="citation-link"
|
||||
:title="`View ${segment.key} in bibliography`"
|
||||
>[{{ segment.key }}]</a>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- CLOSE BUTTON -->
|
||||
<!-- ============================================= -->
|
||||
<button @click="$emit('close')" class="popup-close">×</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
import { formatCoordinate, formatDistance, formatBearing } from '../utils/coordinates.js';
|
||||
import { parseCitations } from '../utils/citations.js';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
visible: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
x: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
y: {
|
||||
type: Number,
|
||||
required: true
|
||||
},
|
||||
type: {
|
||||
type: String, // 'pin', 'line', 'ray', 'site'
|
||||
default: null
|
||||
},
|
||||
feature: {
|
||||
type: Object,
|
||||
default: null
|
||||
},
|
||||
imperialUnits: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const emit = defineEmits(['close', 'delete', 'showBibliography']);
|
||||
|
||||
// ============================================================================
|
||||
// COMPUTED
|
||||
// ============================================================================
|
||||
|
||||
const parsedDescription = computed(() => {
|
||||
if (props.type === 'site' && props.feature?.properties?.description) {
|
||||
return parseCitations(props.feature.properties.description);
|
||||
}
|
||||
return [];
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================= */
|
||||
/* POPUP CONTAINER */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
max-width: 350px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* POPUP CONTENT */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* SITE-SPECIFIC STYLING */
|
||||
/* ============================================= */
|
||||
|
||||
.site-popup {
|
||||
max-width: 320px;
|
||||
}
|
||||
|
||||
.site-type {
|
||||
font-size: 11px !important;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
color: #999 !important;
|
||||
margin-bottom: 8px !important;
|
||||
}
|
||||
|
||||
.site-description {
|
||||
line-height: 1.5;
|
||||
color: #333 !important;
|
||||
font-size: 13px !important;
|
||||
}
|
||||
|
||||
.citation-link {
|
||||
color: #4A9EFF;
|
||||
text-decoration: none;
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.citation-link:hover {
|
||||
color: #2E8FE3;
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* POPUP BUTTONS */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* CLOSE BUTTON */
|
||||
/* ============================================= */
|
||||
|
||||
.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>
|
||||
108
ui/src/components/GeometryToolbar.vue
Normal file
108
ui/src/components/GeometryToolbar.vue
Normal file
@@ -0,0 +1,108 @@
|
||||
<template>
|
||||
<div v-if="showGeometry" class="geometry-toolbar">
|
||||
<!-- ============================================= -->
|
||||
<!-- DRAWING TOOLS -->
|
||||
<!-- ============================================= -->
|
||||
<button
|
||||
:class="['tool-btn', { active: drawMode === 'line' }]"
|
||||
@click="$emit('setDrawMode', 'line')"
|
||||
title="Draw Line"
|
||||
>
|
||||
📏 Line
|
||||
</button>
|
||||
|
||||
<button
|
||||
:class="['tool-btn', { active: drawMode === 'ray' }]"
|
||||
@click="$emit('setDrawMode', 'ray')"
|
||||
title="Draw Ray"
|
||||
>
|
||||
➡️ Ray
|
||||
</button>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- CLEAR BUTTON -->
|
||||
<!-- ============================================= -->
|
||||
<button
|
||||
class="tool-btn danger"
|
||||
@click="$emit('clearAll')"
|
||||
title="Clear All Geometry"
|
||||
>
|
||||
🗑️ Clear
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
defineProps({
|
||||
drawMode: {
|
||||
type: String, // 'line', 'ray', or null
|
||||
default: null
|
||||
},
|
||||
showGeometry: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['setDrawMode', 'clearAll']);
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================= */
|
||||
/* TOOLBAR CONTAINER */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* TOOL BUTTONS */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
}
|
||||
</style>
|
||||
356
ui/src/components/MapControls.vue
Normal file
356
ui/src/components/MapControls.vue
Normal file
@@ -0,0 +1,356 @@
|
||||
<template>
|
||||
<div class="layer-controls">
|
||||
<!-- ============================================= -->
|
||||
<!-- BASE MAP SELECTION -->
|
||||
<!-- ============================================= -->
|
||||
<div class="control-section">
|
||||
<label>Base Map:</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value="osm"
|
||||
:checked="baseLayer === 'osm'"
|
||||
@change="$emit('update:baseLayer', 'osm')"
|
||||
>
|
||||
Street
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="radio"
|
||||
value="satellite"
|
||||
:checked="baseLayer === 'satellite'"
|
||||
@change="$emit('update:baseLayer', 'satellite')"
|
||||
>
|
||||
Satellite
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- HISTORIC MARKERS -->
|
||||
<!-- ============================================= -->
|
||||
<div class="control-section">
|
||||
<label
|
||||
class="section-header"
|
||||
@click="$emit('update:historicMarkersExpanded', !historicMarkersExpanded)"
|
||||
style="cursor: pointer;"
|
||||
>
|
||||
{{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers
|
||||
</label>
|
||||
<div v-if="historicMarkersExpanded" class="subsection">
|
||||
<button class="hide-all-btn" @click="$emit('toggleAllSites')">
|
||||
{{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }}
|
||||
</button>
|
||||
<div v-for="site in sites" :key="site.name" class="site-control">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="visibleSites[site.name]"
|
||||
@change="$emit('toggleSite', site.name)"
|
||||
>
|
||||
{{ site.name }}
|
||||
</label>
|
||||
<button
|
||||
class="jump-btn"
|
||||
@click="$emit('jumpToSite', site)"
|
||||
title="Jump to location"
|
||||
>
|
||||
📍
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- LIDAR CONTROLS -->
|
||||
<!-- ============================================= -->
|
||||
<div class="control-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="showLidar"
|
||||
@change="$emit('update:showLidar', !showLidar)"
|
||||
>
|
||||
Show Lidar
|
||||
</label>
|
||||
<div v-if="showLidar" class="slider-control">
|
||||
<label>Opacity: {{ Math.round(lidarOpacity) }}%</label>
|
||||
<input
|
||||
type="range"
|
||||
min="0"
|
||||
max="100"
|
||||
:value="lidarOpacity"
|
||||
@input="$emit('update:lidarOpacity', $event.target.value)"
|
||||
class="opacity-slider"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- GEOMETRY & UNITS -->
|
||||
<!-- ============================================= -->
|
||||
<div class="control-section">
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="showGeometry"
|
||||
@change="$emit('update:showGeometry', !showGeometry)"
|
||||
>
|
||||
Show Geometry
|
||||
</label>
|
||||
<label>
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="imperialUnits"
|
||||
@change="$emit('update:imperialUnits', !imperialUnits)"
|
||||
>
|
||||
Imperial Units
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- SANDBOX BUTTON -->
|
||||
<!-- ============================================= -->
|
||||
<div class="control-section">
|
||||
<button class="sandbox-btn" @click="$emit('openSandbox')">
|
||||
Open Shading Sandbox
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- TILE REQUEST NOTIFICATIONS -->
|
||||
<!-- ============================================= -->
|
||||
<TileRequestNotification
|
||||
:requests="tileRequests"
|
||||
@dismiss="(id) => $emit('dismissRequest', id)"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { KNOWN_SITES } from '../data/historicSites.js';
|
||||
import TileRequestNotification from './TileRequestNotification.vue';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
defineProps({
|
||||
baseLayer: {
|
||||
type: String,
|
||||
default: 'osm'
|
||||
},
|
||||
historicMarkersExpanded: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
visibleSites: {
|
||||
type: Object,
|
||||
required: true
|
||||
},
|
||||
allHistoricMarkersHidden: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
showLidar: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
lidarOpacity: {
|
||||
type: Number,
|
||||
default: 80
|
||||
},
|
||||
showGeometry: {
|
||||
type: Boolean,
|
||||
default: true
|
||||
},
|
||||
imperialUnits: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
},
|
||||
tileRequests: {
|
||||
type: Object,
|
||||
default: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits([
|
||||
'update:baseLayer',
|
||||
'update:historicMarkersExpanded',
|
||||
'update:showLidar',
|
||||
'update:lidarOpacity',
|
||||
'update:showGeometry',
|
||||
'update:imperialUnits',
|
||||
'toggleSite',
|
||||
'jumpToSite',
|
||||
'toggleAllSites',
|
||||
'openSandbox',
|
||||
'dismissRequest'
|
||||
]);
|
||||
|
||||
// ============================================================================
|
||||
// DATA
|
||||
// ============================================================================
|
||||
|
||||
const sites = KNOWN_SITES;
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================= */
|
||||
/* CONTROLS CONTAINER */
|
||||
/* ============================================= */
|
||||
|
||||
.layer-controls {
|
||||
position: absolute;
|
||||
top: 20px;
|
||||
left: 20px;
|
||||
background: white;
|
||||
padding: 15px;
|
||||
border-radius: 4px;
|
||||
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
|
||||
z-index: 100;
|
||||
max-width: 250px;
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* CONTROL SECTIONS */
|
||||
/* ============================================= */
|
||||
|
||||
.control-section {
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.control-section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.control-section label {
|
||||
display: block;
|
||||
margin: 5px 0;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.control-section label:first-child {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
cursor: default;
|
||||
}
|
||||
|
||||
.control-section .section-header {
|
||||
font-weight: bold;
|
||||
margin-bottom: 8px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.control-section input[type="radio"],
|
||||
.control-section input[type="checkbox"] {
|
||||
margin-right: 8px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* SUBSECTIONS (Historic Markers) */
|
||||
/* ============================================= */
|
||||
|
||||
.subsection {
|
||||
margin-left: 10px;
|
||||
padding-left: 10px;
|
||||
border-left: 2px solid #ddd;
|
||||
}
|
||||
|
||||
.hide-all-btn {
|
||||
width: 100%;
|
||||
padding: 6px 10px;
|
||||
margin: 8px 0;
|
||||
background: #f5f5f5;
|
||||
border: 1px solid #ddd;
|
||||
border-radius: 3px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.hide-all-btn:hover {
|
||||
background: #e8e8e8;
|
||||
border-color: #999;
|
||||
}
|
||||
|
||||
.site-control {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin: 4px 0;
|
||||
}
|
||||
|
||||
.site-control label {
|
||||
flex: 1;
|
||||
margin: 0 !important;
|
||||
font-weight: normal !important;
|
||||
}
|
||||
|
||||
.jump-btn {
|
||||
padding: 4px 8px;
|
||||
background: #4A9EFF;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 3px;
|
||||
font-size: 12px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
margin-left: 8px;
|
||||
}
|
||||
|
||||
.jump-btn:hover {
|
||||
background: #2E8FE3;
|
||||
}
|
||||
|
||||
.jump-btn:active {
|
||||
transform: scale(0.95);
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* SLIDER CONTROLS */
|
||||
/* ============================================= */
|
||||
|
||||
.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;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* SANDBOX BUTTON */
|
||||
/* ============================================= */
|
||||
|
||||
.sandbox-btn {
|
||||
width: 100%;
|
||||
padding: 10px;
|
||||
margin-top: 10px;
|
||||
background: #4A9EFF;
|
||||
color: white;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
font-size: 14px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s;
|
||||
}
|
||||
|
||||
.sandbox-btn:hover {
|
||||
background: #2E8FE3;
|
||||
}
|
||||
|
||||
.sandbox-btn:active {
|
||||
transform: scale(0.98);
|
||||
}
|
||||
</style>
|
||||
1043
ui/src/components/ShadingSandbox.vue
Normal file
1043
ui/src/components/ShadingSandbox.vue
Normal file
File diff suppressed because it is too large
Load Diff
224
ui/src/components/TileRequestNotification.vue
Normal file
224
ui/src/components/TileRequestNotification.vue
Normal file
@@ -0,0 +1,224 @@
|
||||
<template>
|
||||
<div v-if="activeRequests.length > 0" class="tile-notifications">
|
||||
<!-- ============================================= -->
|
||||
<!-- HEADER -->
|
||||
<!-- ============================================= -->
|
||||
<div class="notifications-header">
|
||||
Tile Requests
|
||||
</div>
|
||||
|
||||
<!-- ============================================= -->
|
||||
<!-- ACTIVE REQUESTS -->
|
||||
<!-- ============================================= -->
|
||||
<div
|
||||
v-for="request in activeRequests"
|
||||
:key="request.id"
|
||||
:class="['notification-item', `status-${request.status}`]"
|
||||
>
|
||||
<div class="notification-content">
|
||||
<div class="notification-location">
|
||||
{{ formatLocation(request.lat, request.lng) }}
|
||||
</div>
|
||||
<div class="notification-status">
|
||||
<span class="status-icon">{{ getStatusIcon(request.status) }}</span>
|
||||
<span class="status-text">{{ getStatusText(request.status) }}</span>
|
||||
</div>
|
||||
<div v-if="request.message" class="notification-message">
|
||||
{{ request.message }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Close button for completed/failed requests -->
|
||||
<button
|
||||
v-if="request.status === 'ready' || request.status === 'error'"
|
||||
@click="$emit('dismiss', request.id)"
|
||||
class="dismiss-btn"
|
||||
title="Dismiss"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { computed } from 'vue';
|
||||
|
||||
// ============================================================================
|
||||
// INTERFACE
|
||||
// ============================================================================
|
||||
|
||||
const props = defineProps({
|
||||
requests: {
|
||||
type: Object, // { requestId: { lat, lng, status, message, tileId } }
|
||||
required: true
|
||||
}
|
||||
});
|
||||
|
||||
defineEmits(['dismiss']);
|
||||
|
||||
// ============================================================================
|
||||
// COMPUTED
|
||||
// ============================================================================
|
||||
|
||||
const activeRequests = computed(() => {
|
||||
return Object.entries(props.requests).map(([id, data]) => ({
|
||||
id,
|
||||
...data
|
||||
}));
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// METHODS
|
||||
// ============================================================================
|
||||
|
||||
function formatLocation(lat, lng) {
|
||||
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
|
||||
}
|
||||
|
||||
function getStatusIcon(status) {
|
||||
switch (status) {
|
||||
case 'looking_up': return '🔍';
|
||||
case 'found': return '✓';
|
||||
case 'processing': return '⚙️';
|
||||
case 'ready': return '✅';
|
||||
case 'error': return '❌';
|
||||
default: return '•';
|
||||
}
|
||||
}
|
||||
|
||||
function getStatusText(status) {
|
||||
switch (status) {
|
||||
case 'looking_up': return 'Finding tile...';
|
||||
case 'found': return 'Tile found';
|
||||
case 'processing': return 'Processing...';
|
||||
case 'ready': return 'Ready!';
|
||||
case 'error': return 'Failed';
|
||||
default: return status;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
/* ============================================= */
|
||||
/* NOTIFICATIONS CONTAINER */
|
||||
/* ============================================= */
|
||||
|
||||
.tile-notifications {
|
||||
margin-top: 15px;
|
||||
padding-top: 15px;
|
||||
border-top: 2px solid #ddd;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* HEADER */
|
||||
/* ============================================= */
|
||||
|
||||
.notifications-header {
|
||||
font-weight: 600;
|
||||
font-size: 13px;
|
||||
color: #666;
|
||||
margin-bottom: 10px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* NOTIFICATION ITEMS */
|
||||
/* ============================================= */
|
||||
|
||||
.notification-item {
|
||||
background: #f9f9f9;
|
||||
border-radius: 4px;
|
||||
padding: 10px;
|
||||
margin-bottom: 8px;
|
||||
border-left: 3px solid #ccc;
|
||||
position: relative;
|
||||
transition: all 0.3s;
|
||||
}
|
||||
|
||||
.notification-item.status-looking_up {
|
||||
border-left-color: #4A9EFF;
|
||||
background: #f0f7ff;
|
||||
}
|
||||
|
||||
.notification-item.status-found {
|
||||
border-left-color: #4CAF50;
|
||||
background: #f1f8f4;
|
||||
}
|
||||
|
||||
.notification-item.status-processing {
|
||||
border-left-color: #FF9800;
|
||||
background: #fff8f0;
|
||||
}
|
||||
|
||||
.notification-item.status-ready {
|
||||
border-left-color: #4CAF50;
|
||||
background: #f1f8f4;
|
||||
}
|
||||
|
||||
.notification-item.status-error {
|
||||
border-left-color: #f44336;
|
||||
background: #fff0f0;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* NOTIFICATION CONTENT */
|
||||
/* ============================================= */
|
||||
|
||||
.notification-content {
|
||||
padding-right: 24px; /* Space for dismiss button */
|
||||
}
|
||||
|
||||
.notification-location {
|
||||
font-family: monospace;
|
||||
font-size: 11px;
|
||||
color: #999;
|
||||
margin-bottom: 4px;
|
||||
}
|
||||
|
||||
.notification-status {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
color: #333;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.status-icon {
|
||||
font-size: 14px;
|
||||
}
|
||||
|
||||
.notification-message {
|
||||
font-size: 12px;
|
||||
color: #666;
|
||||
margin-top: 4px;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* DISMISS BUTTON */
|
||||
/* ============================================= */
|
||||
|
||||
.dismiss-btn {
|
||||
position: absolute;
|
||||
top: 8px;
|
||||
right: 8px;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 18px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #999;
|
||||
transition: color 0.2s;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.dismiss-btn:hover {
|
||||
color: #333;
|
||||
}
|
||||
</style>
|
||||
Reference in New Issue
Block a user