full frontend refactor
This commit is contained in:
@@ -266,4 +266,70 @@ defmodule MoundHunters.MoundParser do
|
||||
end
|
||||
end
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts Web Mercator coordinates (EPSG:3857) to WGS84 lat/lng (EPSG:4326).
|
||||
|
||||
Web Mercator uses meters from origin, this converts back to decimal degrees.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MoundHunters.MoundParser.web_mercator_to_latlon(0, 0)
|
||||
{0.0, 0.0}
|
||||
|
||||
iex> MoundHunters.MoundParser.web_mercator_to_latlon(1113194.91, 5009377.09)
|
||||
{10.0, 40.0}
|
||||
"""
|
||||
@spec web_mercator_to_latlon(float(), float()) :: {lat :: float(), lon :: float()}
|
||||
def web_mercator_to_latlon(x, y) do
|
||||
# Web Mercator origin offset (meters)
|
||||
origin_shift = :math.pi() * 6_378_137.0
|
||||
|
||||
# Convert x to longitude
|
||||
lon = x / origin_shift * 180.0
|
||||
|
||||
# Convert y to latitude
|
||||
lat = y / origin_shift * 180.0
|
||||
|
||||
lat =
|
||||
180.0 / :math.pi() *
|
||||
(2.0 * :math.atan(:math.exp(lat * :math.pi() / 180.0)) - :math.pi() / 2.0)
|
||||
|
||||
{lat, lon}
|
||||
end
|
||||
|
||||
@doc """
|
||||
Converts a bounding box from Web Mercator to lat/lng.
|
||||
|
||||
Returns a map with min/max lat/lng values.
|
||||
|
||||
## Examples
|
||||
|
||||
iex> MoundHunters.MoundParser.bounds_web_mercator_to_latlon(
|
||||
...> %{min_x: -9_000_000, max_x: -8_900_000, min_y: 4_900_000, max_y: 5_000_000}
|
||||
...> )
|
||||
%{min_lat: ..., max_lat: ..., min_lng: ..., max_lng: ...}
|
||||
"""
|
||||
@spec bounds_web_mercator_to_latlon(%{
|
||||
min_x: float(),
|
||||
max_x: float(),
|
||||
min_y: float(),
|
||||
max_y: float()
|
||||
}) :: %{
|
||||
min_lat: float(),
|
||||
max_lat: float(),
|
||||
min_lng: float(),
|
||||
max_lng: float()
|
||||
}
|
||||
def bounds_web_mercator_to_latlon(%{min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y}) do
|
||||
{min_lat, min_lng} = web_mercator_to_latlon(min_x, min_y)
|
||||
{max_lat, max_lng} = web_mercator_to_latlon(max_x, max_y)
|
||||
|
||||
%{
|
||||
min_lat: min_lat,
|
||||
max_lat: max_lat,
|
||||
min_lng: min_lng,
|
||||
max_lng: max_lng
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -185,6 +185,8 @@ defmodule MoundHunters.Repo do
|
||||
def update_tile_availability do
|
||||
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||
mound_dir = Path.join(tile_dir, "MOUND")
|
||||
png_dir = Path.join(tile_dir, "PNG")
|
||||
jpg_dir = Path.join(tile_dir, "JPG")
|
||||
|
||||
Logger.info("Scanning tile availability in: #{mound_dir}")
|
||||
|
||||
@@ -199,8 +201,8 @@ defmodule MoundHunters.Repo do
|
||||
tile = tile_to_map(tile_record)
|
||||
tile_id = tile.id
|
||||
|
||||
jpg_path = Path.join(mound_dir, "#{tile_id}.jpg")
|
||||
png_path = Path.join(mound_dir, "#{tile_id}.png")
|
||||
jpg_path = Path.join(jpg_dir, "#{tile_id}.jpg")
|
||||
png_path = Path.join(png_dir, "#{tile_id}.png")
|
||||
|
||||
jpg_available = File.exists?(jpg_path)
|
||||
png_available = File.exists?(png_path)
|
||||
@@ -263,10 +265,13 @@ defmodule MoundHunters.Repo do
|
||||
case MoundHunters.MoundParser.parse_header(file_path) do
|
||||
{:ok, header} ->
|
||||
# Convert Web Mercator coordinates to lat/lng
|
||||
min_lng = header.min_x
|
||||
max_lng = header.max_x
|
||||
min_lat = header.min_y
|
||||
max_lat = header.max_y
|
||||
bounds =
|
||||
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
|
||||
min_x: header.min_x,
|
||||
max_x: header.max_x,
|
||||
min_y: header.min_y,
|
||||
max_y: header.max_y
|
||||
})
|
||||
|
||||
# Check for jpg and png availability
|
||||
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||
@@ -276,10 +281,10 @@ defmodule MoundHunters.Repo do
|
||||
|
||||
attrs = %{
|
||||
id: tile_id,
|
||||
min_lat: min_lat,
|
||||
max_lat: max_lat,
|
||||
min_lng: min_lng,
|
||||
max_lng: max_lng,
|
||||
min_lat: bounds.min_lat,
|
||||
max_lat: bounds.max_lat,
|
||||
min_lng: bounds.min_lng,
|
||||
max_lng: bounds.max_lng,
|
||||
status: :ready,
|
||||
error_message: nil,
|
||||
jpg_available: jpg_available,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
defmodule MoundHunters.TileProcessor do
|
||||
@moduledoc """
|
||||
GenServer that processes tile requests: lookup tile IDs and download/convert tiles.
|
||||
|
||||
|
||||
Processing pipeline:
|
||||
1. :looking_up - Query ArcGIS for tile metadata
|
||||
2. :downloading - Download ZIP from OGRIP
|
||||
@@ -76,10 +76,12 @@ defmodule MoundHunters.TileProcessor do
|
||||
# Ensure temp directory exists
|
||||
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
|
||||
File.mkdir_p!(temp_dir)
|
||||
root_output_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||
mound_dir = Path.join(root_output_dir, "MOUND")
|
||||
|
||||
state = %{
|
||||
las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path),
|
||||
tile_output_dir: Application.get_env(:mound_hunters, :tile_output_dir),
|
||||
tile_output_dir: mound_dir,
|
||||
tile_temp_dir: temp_dir,
|
||||
processing_queue: :queue.new(),
|
||||
current_job: nil
|
||||
@@ -113,35 +115,38 @@ defmodule MoundHunters.TileProcessor do
|
||||
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
|
||||
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
|
||||
|
||||
case OhioLidar.query_tile_info(lng, lat) do
|
||||
{:ok, tile_info} ->
|
||||
tile_name = tile_info.tile_name
|
||||
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
|
||||
# First, check Mnesia to see if we already have a tile at these coordinates
|
||||
case MoundHunters.Repo.get_tile_at_coords(lat, lng) do
|
||||
{:ok, tile} ->
|
||||
# Found in Mnesia - use cached tile_id
|
||||
tile_name = tile.id
|
||||
Logger.info("Found tile #{tile_name} in Mnesia (cached), skipping ArcGIS query")
|
||||
|
||||
# Update lookup table with success
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
|
||||
|
||||
# Check if tile already exists
|
||||
# Check if tile file still exists
|
||||
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
|
||||
|
||||
if File.exists?(output_file) do
|
||||
Logger.info("Tile #{tile_name} already processed, marking as done")
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
||||
else
|
||||
# Queue for processing
|
||||
state = queue_tile_for_processing(state, tile_name, tile_info)
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile}})
|
||||
{:noreply, state}
|
||||
else
|
||||
# Tile in DB but file missing - this shouldn't happen, but handle it
|
||||
Logger.warning("Tile #{tile_name} in Mnesia but file missing, re-querying ArcGIS")
|
||||
query_arcgis_and_process(lookup_id, lat, lng, state)
|
||||
end
|
||||
|
||||
{:error, :no_tile_found} ->
|
||||
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
|
||||
{:noreply, state}
|
||||
{:error, :not_found} ->
|
||||
# Not in Mnesia - query ArcGIS
|
||||
Logger.debug("Tile not in Mnesia, querying ArcGIS")
|
||||
query_arcgis_and_process(lookup_id, lat, lng, state)
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to lookup tile: #{inspect(reason)}")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
|
||||
{:noreply, state}
|
||||
Logger.error("Failed to query Mnesia: #{inspect(reason)}")
|
||||
# Fall back to ArcGIS query
|
||||
query_arcgis_and_process(lookup_id, lat, lng, state)
|
||||
end
|
||||
end
|
||||
|
||||
@@ -162,11 +167,15 @@ defmodule MoundHunters.TileProcessor do
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to download tile #{tile_name}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
|
||||
:ets.insert(
|
||||
:tile_processing,
|
||||
{tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}}
|
||||
)
|
||||
|
||||
# Clean up
|
||||
File.rm(temp_zip)
|
||||
|
||||
|
||||
# Process next in queue
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
@@ -192,23 +201,27 @@ defmodule MoundHunters.TileProcessor do
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to find LAS file in #{extract_dir}: #{reason}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "No LAS file found"}, %{}})
|
||||
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(extract_dir)
|
||||
File.rm(zip_path)
|
||||
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to extract #{zip_path}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
|
||||
:ets.insert(
|
||||
:tile_processing,
|
||||
{tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}}
|
||||
)
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(extract_dir)
|
||||
File.rm(zip_path)
|
||||
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
@@ -225,28 +238,69 @@ defmodule MoundHunters.TileProcessor do
|
||||
case run_las2mound(state.las2mound_script, las_file, output_file) do
|
||||
:ok ->
|
||||
Logger.info("Successfully converted #{tile_name}")
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
||||
|
||||
# Update Mnesia
|
||||
MoundHunters.Repo.upsert_tile(%{
|
||||
id: tile_name,
|
||||
status: :ready,
|
||||
min_lat: nil, # TODO: Extract from tile_info or LAS bounds
|
||||
max_lat: nil,
|
||||
min_lng: nil,
|
||||
max_lng: nil
|
||||
})
|
||||
# Parse header to extract bounds
|
||||
case MoundHunters.MoundParser.parse_header(output_file) do
|
||||
{:ok, header} ->
|
||||
# Convert Web Mercator coordinates to lat/lng
|
||||
bounds =
|
||||
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
|
||||
min_x: header.min_x,
|
||||
max_x: header.max_x,
|
||||
min_y: header.min_y,
|
||||
max_y: header.max_y
|
||||
})
|
||||
|
||||
# Clean up temp files
|
||||
File.rm_rf!(temp_dir)
|
||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
||||
Logger.debug(
|
||||
"Tile #{tile_name} bounds: lat [#{bounds.min_lat}, #{bounds.max_lat}], " <>
|
||||
"lng [#{bounds.min_lng}, #{bounds.max_lng}]"
|
||||
)
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
:ets.insert(
|
||||
:tile_processing,
|
||||
{tile_name, :done, %{tile_info: tile_info, header: header}}
|
||||
)
|
||||
|
||||
# Update Mnesia with extracted bounds
|
||||
MoundHunters.Repo.upsert_tile(%{
|
||||
id: tile_name,
|
||||
status: :ready,
|
||||
min_lat: bounds.min_lat,
|
||||
max_lat: bounds.max_lat,
|
||||
min_lng: bounds.min_lng,
|
||||
max_lng: bounds.max_lng
|
||||
})
|
||||
|
||||
# Clean up temp files
|
||||
File.rm_rf!(temp_dir)
|
||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to parse header for #{tile_name}: #{inspect(reason)}")
|
||||
|
||||
:ets.insert(
|
||||
:tile_processing,
|
||||
{tile_name, {:error, "Header parsing failed: #{inspect(reason)}"}, %{}}
|
||||
)
|
||||
|
||||
# Keep the .mound file for debugging, but clean up temp files
|
||||
File.rm_rf!(temp_dir)
|
||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
||||
|
||||
state = process_next_in_queue(state)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to convert #{tile_name}: #{inspect(reason)}")
|
||||
:ets.insert(:tile_processing, {tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}})
|
||||
|
||||
:ets.insert(
|
||||
:tile_processing,
|
||||
{tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}}
|
||||
)
|
||||
|
||||
# Clean up
|
||||
File.rm_rf!(temp_dir)
|
||||
@@ -259,6 +313,40 @@ defmodule MoundHunters.TileProcessor do
|
||||
|
||||
# Helper functions
|
||||
|
||||
defp query_arcgis_and_process(lookup_id, lat, lng, state) do
|
||||
case OhioLidar.query_tile_info(lng, lat) do
|
||||
{:ok, tile_info} ->
|
||||
tile_name = tile_info.tile_name
|
||||
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
|
||||
|
||||
# Update lookup table with success
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
|
||||
|
||||
# Check if tile already exists
|
||||
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
|
||||
|
||||
if File.exists?(output_file) do
|
||||
Logger.info("Tile #{tile_name} already processed, marking as done")
|
||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
||||
{:noreply, state}
|
||||
else
|
||||
# Queue for processing
|
||||
state = queue_tile_for_processing(state, tile_name, tile_info)
|
||||
{:noreply, state}
|
||||
end
|
||||
|
||||
{:error, :no_tile_found} ->
|
||||
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
|
||||
{:noreply, state}
|
||||
|
||||
{:error, reason} ->
|
||||
Logger.error("Failed to lookup tile: #{inspect(reason)}")
|
||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
|
||||
{:noreply, state}
|
||||
end
|
||||
end
|
||||
|
||||
defp queue_tile_for_processing(state, tile_name, tile_info) do
|
||||
# Check if already in queue or processing
|
||||
case :ets.lookup(:tile_processing, tile_name) do
|
||||
|
||||
@@ -60,10 +60,9 @@ defmodule MoundHuntersWeb.TileController do
|
||||
|
||||
@doc """
|
||||
Serve a tile file.
|
||||
GET /tiles/MOUND/:tile_id.mound
|
||||
GET /tiles/JPG/:tile_id.jpg
|
||||
GET /tiles/PNG/:tile_id.jpng
|
||||
GET /tiles/JSON/:tile_id.json
|
||||
GET /tiles/mound/:tile_id.mound
|
||||
GET /tiles/jpg/:tile_id.jpg
|
||||
GET /tiles/png/:tile_id.jpng
|
||||
"""
|
||||
def serve(conn) do
|
||||
raw_tile_id = conn.path_params["tile_id"]
|
||||
@@ -81,10 +80,9 @@ defmodule MoundHuntersWeb.TileController do
|
||||
|
||||
file_path =
|
||||
case format do
|
||||
"MOUND" -> Path.join(tile_dir, "MOUND/#{tile_id}.mound")
|
||||
"JSON" -> Path.join(tile_dir, "JSON/#{tile_id}.json")
|
||||
"JPG" -> Path.join(tile_dir, "JPG/#{tile_id}.jpg")
|
||||
"PNG" -> Path.join(tile_dir, "PNG/#{tile_id}.png")
|
||||
"mound" -> Path.join(tile_dir, "MOUND/#{tile_id}.mound")
|
||||
"jpg" -> Path.join(tile_dir, "JPG/#{tile_id}.jpg")
|
||||
"png" -> Path.join(tile_dir, "PNG/#{tile_id}.png")
|
||||
end
|
||||
|
||||
Logger.info("Tile request started for #{file_path}")
|
||||
|
||||
1705
ui/src/App.vue
1705
ui/src/App.vue
File diff suppressed because it is too large
Load Diff
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>
|
||||
@@ -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' };
|
||||
}
|
||||
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>
|
||||
197
ui/src/utils/api.js
Normal file
197
ui/src/utils/api.js
Normal 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
73
ui/src/utils/citations.js
Normal 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;
|
||||
}
|
||||
42
ui/src/utils/coordinates.js
Normal file
42
ui/src/utils/coordinates.js
Normal 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
59
ui/src/utils/geometry.js
Normal 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
301
ui/src/utils/tileCache.js
Normal 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
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user