full frontend refactor

This commit is contained in:
2026-01-25 10:26:23 +01:00
parent 317ee96ba3
commit 559a4c3e9f
18 changed files with 2803 additions and 1224 deletions

File diff suppressed because it is too large Load Diff

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

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

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

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

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

View File

@@ -162,6 +162,7 @@ const tileLoaded = ref(false);
const isRendering = ref(false);
const lastRenderedImage = ref(null);
const selectedQuality = ref(1024);
const tileId = ref(null);
// Settings
const settings = reactive({
@@ -304,11 +305,12 @@ const handleResize = () => {
};
// Load tile data
const loadTileData = (tileData) => {
const loadTileData = (tileData, newTileId) => {
if (!scene) {
console.error('Three.js not initialized');
return false;
}
tileId.value = newTileId;
// Remove old mesh
if (mesh) {
@@ -539,6 +541,7 @@ const renderTile = async () => {
renderStats.value.lastRenderTime = renderTime;
emit('renderComplete', {
tileId: tileId.value,
dataURL,
settings: { ...settings },
size,
@@ -573,7 +576,7 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
Object.assign(settings, renderSettings);
// Load tile
const loaded = loadTileData(tileData);
const loaded = loadTileData(tileData, null);
if (!loaded) {
return { success: false, error: 'Failed to load tile' };
}

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

197
ui/src/utils/api.js Normal file
View File

@@ -0,0 +1,197 @@
// ============================================================================
// API UTILITIES
// All backend API interactions for the Hopewell Road Lidar application
// ============================================================================
const API_BASE = ''; // Same origin
// ============================================================================
// TILE METADATA API
// ============================================================================
/**
* Get tile metadata by coordinates
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @returns {Promise<Object|null>} Tile metadata or null if not found
*/
export async function getTileByCoordinates(lat, lng) {
try {
const response = await fetch(`${API_BASE}/api/meta/tile?lat=${lat}&lng=${lng}`);
if (response.status === 404) {
return null; // Tile doesn't exist
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error('Failed to fetch tile metadata:', err);
throw err;
}
}
/**
* Get tile metadata by ID
* @param {string} tileId - Tile identifier
* @returns {Promise<Object>} Tile metadata
*/
export async function getTileById(tileId) {
try {
const response = await fetch(`${API_BASE}/api/meta/tile/${tileId}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error('Failed to fetch tile by ID:', err);
throw err;
}
}
// ============================================================================
// TILE REQUEST API (SSE)
// ============================================================================
/**
* Request tile processing via Server-Sent Events
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @param {Function} onMessage - Callback for status updates
* @param {Function} onError - Callback for errors
* @returns {EventSource} The EventSource connection (call .close() to cancel)
*/
export function requestTileProcessing(lat, lng, onMessage, onError) {
const eventSource = new EventSource(`${API_BASE}/tiles/request?lat=${lat}&lng=${lng}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessage(data);
// Auto-close on terminal states
if (data.status === 'ready' || data.status === 'error') {
eventSource.close();
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
onError(err);
eventSource.close();
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
onError(error);
eventSource.close();
};
return eventSource;
}
// ============================================================================
// TILE FILE FETCHING
// ============================================================================
// NOTE: Image URL generation is now in tileCache.js via getImageUrl()
// This keeps all cache-related logic in one place
/**
* Fetch tile MOUND data (binary point cloud)
* @param {string} tileId - Tile identifier
* @returns {Promise<ArrayBuffer>} Binary mound data
*/
export async function getTileMoundData(tileId) {
try {
const response = await fetch(`${API_BASE}/tiles/mound/${tileId}.mound`);
if (!response.ok) {
throw new Error(`Failed to fetch mound data: ${response.status}`);
}
return await response.arrayBuffer();
} catch (err) {
console.error('Failed to fetch mound data:', err);
throw err;
}
}
// ============================================================================
// GEOMETRY SHARING API
// ============================================================================
/**
* Share geometry (create shareable link)
* @param {Object} geojson - GeoJSON feature
* @returns {Promise<string>} Share ID
*/
export async function shareGeometry(geojson) {
try {
const response = await fetch(`${API_BASE}/api/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ geojson })
});
if (!response.ok) {
throw new Error(`Failed to share geometry: ${response.status}`);
}
const data = await response.json();
return data.id;
} catch (err) {
console.error('Failed to share geometry:', err);
throw err;
}
}
/**
* Get shared geometry by ID
* @param {string} shareId - Share identifier
* @returns {Promise<Object>} GeoJSON feature
*/
export async function getSharedGeometry(shareId) {
try {
const response = await fetch(`${API_BASE}/api/share/${shareId}`);
if (!response.ok) {
throw new Error(`Failed to fetch shared geometry: ${response.status}`);
}
const data = await response.json();
return data.geojson;
} catch (err) {
console.error('Failed to fetch shared geometry:', err);
throw err;
}
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
/**
* Check API health
* @returns {Promise<boolean>} True if API is healthy
*/
export async function checkHealth() {
try {
const response = await fetch(`${API_BASE}/health`);
if (!response.ok) {
return false;
}
const data = await response.json();
return data.status === 'ok';
} catch (err) {
console.error('Health check failed:', err);
return false;
}
}

73
ui/src/utils/citations.js Normal file
View File

@@ -0,0 +1,73 @@
// ============================================================================
// CITATION PARSING UTILITIES
// ============================================================================
/**
* Parse text containing \cite{key} citations and convert to structured format
* Returns array of text segments and citation keys for rendering
*
* Example input: "This is text \\cite{lepper_1995} more text \\cite{schwarz_2016}."
* Example output: [
* { type: 'text', content: 'This is text ' },
* { type: 'citation', key: 'lepper_1995' },
* { type: 'text', content: ' more text ' },
* { type: 'citation', key: 'schwarz_2016' },
* { type: 'text', content: '.' }
* ]
*/
export function parseCitations(text) {
if (!text) return [];
const segments = [];
const citationRegex = /\\cite\{([^}]+)\}/g;
let lastIndex = 0;
let match;
while ((match = citationRegex.exec(text)) !== null) {
// Add text before citation
if (match.index > lastIndex) {
segments.push({
type: 'text',
content: text.substring(lastIndex, match.index)
});
}
// Add citation
segments.push({
type: 'citation',
key: match[1]
});
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
segments.push({
type: 'text',
content: text.substring(lastIndex)
});
}
return segments;
}
/**
* Extract all citation keys from text
* Returns array of unique citation keys
*/
export function extractCitationKeys(text) {
if (!text) return [];
const citationRegex = /\\cite\{([^}]+)\}/g;
const keys = [];
let match;
while ((match = citationRegex.exec(text)) !== null) {
if (!keys.includes(match[1])) {
keys.push(match[1]);
}
}
return keys;
}

View File

@@ -0,0 +1,42 @@
// ============================================================================
// COORDINATE & DISTANCE FORMATTING UTILITIES
// ============================================================================
/**
* Format coordinate to 6 decimal places (±11cm precision)
*/
export function formatCoordinate(value, type) {
const dir = type === 'lat'
? (value >= 0 ? 'N' : 'S')
: (value >= 0 ? 'E' : 'W');
return `${Math.abs(value).toFixed(6)}°${dir}`;
}
/**
* Format distance in meters or feet based on unit preference
*/
export function formatDistance(meters, useImperial = false) {
if (useImperial) {
const feet = meters * 3.28084;
if (feet < 5280) {
return `${feet.toFixed(1)} ft`;
} else {
const miles = feet / 5280;
return `${miles.toFixed(2)} mi`;
}
} else {
if (meters < 1000) {
return `${meters.toFixed(1)} m`;
} else {
const km = meters / 1000;
return `${km.toFixed(2)} km`;
}
}
}
/**
* Format bearing angle
*/
export function formatBearing(degrees) {
return `${degrees.toFixed(1)}°`;
}

59
ui/src/utils/geometry.js Normal file
View File

@@ -0,0 +1,59 @@
// ============================================================================
// GEOMETRY CALCULATION UTILITIES
// ============================================================================
/**
* Calculate distance between two points in meters using Haversine formula
*/
export function calculateDistance(lng1, lat1, lng2, lat2) {
const R = 6371000; // Earth's radius in meters
const phi_1 = lat1 * Math.PI / 180;
const phi_2 = lat2 * Math.PI / 180;
const delta_phi = (lat2 - lat1) * Math.PI / 180;
const delta_lambda = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(delta_phi / 2) * Math.sin(delta_phi / 2) +
Math.cos(phi_1) * Math.cos(phi_2) *
Math.sin(delta_lambda / 2) * Math.sin(delta_lambda / 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 (0-360)
*/
export function calculateBearing(lng1, lat1, lng2, lat2) {
const phi_1 = lat1 * Math.PI / 180;
const phi_2 = lat2 * Math.PI / 180;
const delta_lambda = (lng2 - lng1) * Math.PI / 180;
const y = Math.sin(delta_lambda) * Math.cos(phi_2);
const x = Math.cos(phi_1) * Math.sin(phi_2) -
Math.sin(phi_1) * Math.cos(phi_2) * Math.cos(delta_lambda);
const theta = Math.atan2(y, x);
return (theta * 180 / Math.PI + 360) % 360; // Bearing in degrees
}
/**
* Extend a line from point1 through point2 to a far distance (100km)
* Used for ray drawing
*/
export 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 phi_1 = lat1 * Math.PI / 180;
const labmda_1 = lng1 * Math.PI / 180;
const phi_2 = Math.asin(Math.sin(phi_1) * Math.cos(d / R) +
Math.cos(phi_1) * Math.sin(d / R) * Math.cos(bearingRad));
const labmda_2 = labmda_1 + Math.atan2(Math.sin(bearingRad) * Math.sin(d / R) * Math.cos(phi_1),
Math.cos(d / R) - Math.sin(phi_1) * Math.sin(phi_2));
return [labmda_2 * 180 / Math.PI, phi_2 * 180 / Math.PI];
}

301
ui/src/utils/tileCache.js Normal file
View File

@@ -0,0 +1,301 @@
// ============================================================================
// TILE CACHE SYSTEM
// Centralized storage and lookup for all tile-related data
// ============================================================================
import { ref } from 'vue';
// ============================================================================
// CACHE STORAGE
// ============================================================================
// Metadata from API - keyed by tile ID
// Entry: { id, status, min_lat, max_lat, min_lng, max_lng, error_message,
// jpg_available, png_available, created_at, updated_at }
const metadataCache = ref(new Map());
// Mound data - keyed by tile ID
// Entry: { positions: Float32Array, indices: Uint32Array, bounds: {...} }
const moundCache = ref(new Map());
// Loading state tracker - keyed by tile ID
// Entry: { metadata: bool, mound: bool, jpg: bool, png: bool }
const loadingState = ref(new Map());
// Images loaded on map - keyed by tile ID
// Simple Set tracking which tiles have their images rendered
const imagesOnMap = ref(new Set());
// ============================================================================
// METADATA METHODS
// ============================================================================
/**
* Get tile metadata by ID
* @param {string} tileId
* @returns {Object|null} Metadata object or null if not cached
*/
export function getMetadata(tileId) {
return metadataCache.value.get(tileId) || null;
}
/**
* Store tile metadata
* @param {string} tileId
* @param {Object} metadata - API metadata object
*/
export function setMetadata(tileId, metadata) {
metadataCache.value.set(tileId, metadata);
}
/**
* Find tile ID by coordinates (linear scan with bounds check)
* @param {number} lat
* @param {number} lng
* @returns {string|null} Tile ID or null if no tile contains these coordinates
*/
export function findTileByCoords(lat, lng) {
for (const [tileId, meta] of metadataCache.value) {
if (lat >= meta.min_lat && lat <= meta.max_lat &&
lng >= meta.min_lng && lng <= meta.max_lng) {
return tileId;
}
}
return null;
}
/**
* Check if metadata is cached for a tile
* @param {string} tileId
* @returns {boolean}
*/
export function hasMetadata(tileId) {
return metadataCache.value.has(tileId);
}
// ============================================================================
// MOUND DATA METHODS
// ============================================================================
/**
* Get mound data for a tile
* @param {string} tileId
* @returns {Object|null} Mound data object or null if not cached
*/
export function getMoundData(tileId) {
return moundCache.value.get(tileId) || null;
}
/**
* Store mound data for a tile
* @param {string} tileId
* @param {Object} moundData - { positions, indices, bounds }
*/
export function setMoundData(tileId, moundData) {
moundCache.value.set(tileId, moundData);
}
/**
* Check if mound data is cached for a tile
* @param {string} tileId
* @returns {boolean}
*/
export function hasMoundData(tileId) {
return moundCache.value.has(tileId);
}
// ============================================================================
// IMAGE METHODS
// ============================================================================
/**
* Get image URL for a tile
* @param {string} tileId
* @param {string} type - 'jpg' or 'png'
* @returns {string} Image URL
*/
export function getImageUrl(tileId, type) {
const API_BASE = ''; // Same origin
return `${API_BASE}/tiles/${type}/${tileId}.${type}`;
}
/**
* Mark tile images as loaded on map
* @param {string} tileId
*/
export function setImagesOnMap(tileId) {
imagesOnMap.value.add(tileId);
}
/**
* Check if tile images are on the map
* @param {string} tileId
* @returns {boolean}
*/
export function areImagesOnMap(tileId) {
return imagesOnMap.value.has(tileId);
}
/**
* Remove tile images from map tracking
* @param {string} tileId
*/
export function removeImagesFromMap(tileId) {
imagesOnMap.value.delete(tileId);
}
// ============================================================================
// LOADING STATE METHODS
// ============================================================================
/**
* Initialize loading state for a tile
* @param {string} tileId
*/
function initLoadingState(tileId) {
if (!loadingState.value.has(tileId)) {
loadingState.value.set(tileId, {
metadata: false,
mound: false,
jpg: false,
png: false
});
}
}
/**
* Set loading state for a specific data type
* @param {string} tileId
* @param {string} dataType - 'metadata' | 'mound' | 'jpg' | 'png'
* @param {boolean} isLoading
*/
export function setLoading(tileId, dataType, isLoading) {
initLoadingState(tileId);
loadingState.value.get(tileId)[dataType] = isLoading;
}
/**
* Check if metadata is loading
* @param {string} tileId
* @returns {boolean}
*/
export function isMetadataLoading(tileId) {
return loadingState.value.get(tileId)?.metadata || false;
}
/**
* Check if mound data is loading
* @param {string} tileId
* @returns {boolean}
*/
export function isMoundLoading(tileId) {
return loadingState.value.get(tileId)?.mound || false;
}
/**
* Check if image is loading
* @param {string} tileId
* @param {string} type - 'jpg' or 'png'
* @returns {boolean}
*/
export function isImageLoading(tileId, type) {
return loadingState.value.get(tileId)?.[type] || false;
}
// ============================================================================
// STATUS CHECK METHODS
// ============================================================================
/**
* Check if tile has metadata loaded
* @param {string} tileId
* @returns {boolean}
*/
export function isMetadataLoaded(tileId) {
return hasMetadata(tileId);
}
/**
* Check if tile has mound data loaded
* @param {string} tileId
* @returns {boolean}
*/
export function isMoundLoaded(tileId) {
return hasMoundData(tileId);
}
/**
* Check if tile is ready to render (has both metadata and mound data)
* @param {string} tileId
* @returns {boolean}
*/
export function isReadyToRender(tileId) {
return hasMetadata(tileId) && hasMoundData(tileId);
}
/**
* Check if images are available for a tile (from metadata)
* @param {string} tileId
* @returns {{ jpg: boolean, png: boolean }}
*/
export function getImageAvailability(tileId) {
const meta = getMetadata(tileId);
if (!meta) {
return { jpg: false, png: false };
}
return {
jpg: meta.jpg_available || false,
png: meta.png_available || false
};
}
// ============================================================================
// BULK OPERATIONS
// ============================================================================
/**
* Get all tile IDs that have images on the map
* @returns {string[]}
*/
export function getAllTileIdsWithImages() {
return Array.from(imagesOnMap.value);
}
/**
* Get all tile IDs that have mound data
* @returns {string[]}
*/
export function getAllTileIdsWithMounds() {
return Array.from(moundCache.value.keys());
}
/**
* Get all tile IDs that have metadata
* @returns {string[]}
*/
export function getAllTileIds() {
return Array.from(metadataCache.value.keys());
}
/**
* Clear all caches (useful for testing/debugging)
*/
export function clearAllCaches() {
metadataCache.value.clear();
moundCache.value.clear();
loadingState.value.clear();
imagesOnMap.value.clear();
}
/**
* Get cache statistics (useful for debugging)
* @returns {Object}
*/
export function getCacheStats() {
return {
metadataCount: metadataCache.value.size,
moundCount: moundCache.value.size,
imagesOnMapCount: imagesOnMap.value.size,
loadingCount: loadingState.value.size
};
}