full frontend refactor
This commit is contained in:
@@ -266,4 +266,70 @@ defmodule MoundHunters.MoundParser do
|
|||||||
end
|
end
|
||||||
end
|
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
|
end
|
||||||
|
|||||||
@@ -185,6 +185,8 @@ defmodule MoundHunters.Repo do
|
|||||||
def update_tile_availability do
|
def update_tile_availability do
|
||||||
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||||
mound_dir = Path.join(tile_dir, "MOUND")
|
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}")
|
Logger.info("Scanning tile availability in: #{mound_dir}")
|
||||||
|
|
||||||
@@ -199,8 +201,8 @@ defmodule MoundHunters.Repo do
|
|||||||
tile = tile_to_map(tile_record)
|
tile = tile_to_map(tile_record)
|
||||||
tile_id = tile.id
|
tile_id = tile.id
|
||||||
|
|
||||||
jpg_path = Path.join(mound_dir, "#{tile_id}.jpg")
|
jpg_path = Path.join(jpg_dir, "#{tile_id}.jpg")
|
||||||
png_path = Path.join(mound_dir, "#{tile_id}.png")
|
png_path = Path.join(png_dir, "#{tile_id}.png")
|
||||||
|
|
||||||
jpg_available = File.exists?(jpg_path)
|
jpg_available = File.exists?(jpg_path)
|
||||||
png_available = File.exists?(png_path)
|
png_available = File.exists?(png_path)
|
||||||
@@ -263,10 +265,13 @@ defmodule MoundHunters.Repo do
|
|||||||
case MoundHunters.MoundParser.parse_header(file_path) do
|
case MoundHunters.MoundParser.parse_header(file_path) do
|
||||||
{:ok, header} ->
|
{:ok, header} ->
|
||||||
# Convert Web Mercator coordinates to lat/lng
|
# Convert Web Mercator coordinates to lat/lng
|
||||||
min_lng = header.min_x
|
bounds =
|
||||||
max_lng = header.max_x
|
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
|
||||||
min_lat = header.min_y
|
min_x: header.min_x,
|
||||||
max_lat = header.max_y
|
max_x: header.max_x,
|
||||||
|
min_y: header.min_y,
|
||||||
|
max_y: header.max_y
|
||||||
|
})
|
||||||
|
|
||||||
# Check for jpg and png availability
|
# Check for jpg and png availability
|
||||||
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
tile_dir = Application.get_env(:mound_hunters, :tile_output_dir)
|
||||||
@@ -276,10 +281,10 @@ defmodule MoundHunters.Repo do
|
|||||||
|
|
||||||
attrs = %{
|
attrs = %{
|
||||||
id: tile_id,
|
id: tile_id,
|
||||||
min_lat: min_lat,
|
min_lat: bounds.min_lat,
|
||||||
max_lat: max_lat,
|
max_lat: bounds.max_lat,
|
||||||
min_lng: min_lng,
|
min_lng: bounds.min_lng,
|
||||||
max_lng: max_lng,
|
max_lng: bounds.max_lng,
|
||||||
status: :ready,
|
status: :ready,
|
||||||
error_message: nil,
|
error_message: nil,
|
||||||
jpg_available: jpg_available,
|
jpg_available: jpg_available,
|
||||||
|
|||||||
@@ -76,10 +76,12 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
# Ensure temp directory exists
|
# Ensure temp directory exists
|
||||||
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
|
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
|
||||||
File.mkdir_p!(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 = %{
|
state = %{
|
||||||
las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path),
|
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,
|
tile_temp_dir: temp_dir,
|
||||||
processing_queue: :queue.new(),
|
processing_queue: :queue.new(),
|
||||||
current_job: nil
|
current_job: nil
|
||||||
@@ -113,35 +115,38 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
|
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
|
||||||
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
|
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
|
||||||
|
|
||||||
case OhioLidar.query_tile_info(lng, lat) do
|
# First, check Mnesia to see if we already have a tile at these coordinates
|
||||||
{:ok, tile_info} ->
|
case MoundHunters.Repo.get_tile_at_coords(lat, lng) do
|
||||||
tile_name = tile_info.tile_name
|
{:ok, tile} ->
|
||||||
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
|
# 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
|
# Update lookup table with success
|
||||||
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
|
: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")
|
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
|
||||||
|
|
||||||
if File.exists?(output_file) do
|
if File.exists?(output_file) do
|
||||||
Logger.info("Tile #{tile_name} already processed, marking as done")
|
Logger.info("Tile #{tile_name} already processed, marking as done")
|
||||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile}})
|
||||||
else
|
|
||||||
# Queue for processing
|
|
||||||
state = queue_tile_for_processing(state, tile_name, tile_info)
|
|
||||||
{:noreply, state}
|
{: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
|
end
|
||||||
|
|
||||||
{:error, :no_tile_found} ->
|
{:error, :not_found} ->
|
||||||
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
|
# Not in Mnesia - query ArcGIS
|
||||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
|
Logger.debug("Tile not in Mnesia, querying ArcGIS")
|
||||||
{:noreply, state}
|
query_arcgis_and_process(lookup_id, lat, lng, state)
|
||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Failed to lookup tile: #{inspect(reason)}")
|
Logger.error("Failed to query Mnesia: #{inspect(reason)}")
|
||||||
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
|
# Fall back to ArcGIS query
|
||||||
{:noreply, state}
|
query_arcgis_and_process(lookup_id, lat, lng, state)
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -162,7 +167,11 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Failed to download tile #{tile_name}: #{inspect(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
|
# Clean up
|
||||||
File.rm(temp_zip)
|
File.rm(temp_zip)
|
||||||
@@ -203,7 +212,11 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
|
|
||||||
{:error, reason} ->
|
{:error, reason} ->
|
||||||
Logger.error("Failed to extract #{zip_path}: #{inspect(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
|
# Clean up
|
||||||
File.rm_rf!(extract_dir)
|
File.rm_rf!(extract_dir)
|
||||||
@@ -225,28 +238,69 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
case run_las2mound(state.las2mound_script, las_file, output_file) do
|
case run_las2mound(state.las2mound_script, las_file, output_file) do
|
||||||
:ok ->
|
:ok ->
|
||||||
Logger.info("Successfully converted #{tile_name}")
|
Logger.info("Successfully converted #{tile_name}")
|
||||||
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
|
|
||||||
|
|
||||||
# Update Mnesia
|
# Parse header to extract bounds
|
||||||
MoundHunters.Repo.upsert_tile(%{
|
case MoundHunters.MoundParser.parse_header(output_file) do
|
||||||
id: tile_name,
|
{:ok, header} ->
|
||||||
status: :ready,
|
# Convert Web Mercator coordinates to lat/lng
|
||||||
min_lat: nil, # TODO: Extract from tile_info or LAS bounds
|
bounds =
|
||||||
max_lat: nil,
|
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
|
||||||
min_lng: nil,
|
min_x: header.min_x,
|
||||||
max_lng: nil
|
max_x: header.max_x,
|
||||||
})
|
min_y: header.min_y,
|
||||||
|
max_y: header.max_y
|
||||||
|
})
|
||||||
|
|
||||||
# Clean up temp files
|
Logger.debug(
|
||||||
File.rm_rf!(temp_dir)
|
"Tile #{tile_name} bounds: lat [#{bounds.min_lat}, #{bounds.max_lat}], " <>
|
||||||
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
|
"lng [#{bounds.min_lng}, #{bounds.max_lng}]"
|
||||||
|
)
|
||||||
|
|
||||||
state = process_next_in_queue(state)
|
:ets.insert(
|
||||||
{:noreply, state}
|
: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} ->
|
{:error, reason} ->
|
||||||
Logger.error("Failed to convert #{tile_name}: #{inspect(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
|
# Clean up
|
||||||
File.rm_rf!(temp_dir)
|
File.rm_rf!(temp_dir)
|
||||||
@@ -259,6 +313,40 @@ defmodule MoundHunters.TileProcessor do
|
|||||||
|
|
||||||
# Helper functions
|
# 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
|
defp queue_tile_for_processing(state, tile_name, tile_info) do
|
||||||
# Check if already in queue or processing
|
# Check if already in queue or processing
|
||||||
case :ets.lookup(:tile_processing, tile_name) do
|
case :ets.lookup(:tile_processing, tile_name) do
|
||||||
|
|||||||
@@ -60,10 +60,9 @@ defmodule MoundHuntersWeb.TileController do
|
|||||||
|
|
||||||
@doc """
|
@doc """
|
||||||
Serve a tile file.
|
Serve a tile file.
|
||||||
GET /tiles/MOUND/:tile_id.mound
|
GET /tiles/mound/:tile_id.mound
|
||||||
GET /tiles/JPG/:tile_id.jpg
|
GET /tiles/jpg/:tile_id.jpg
|
||||||
GET /tiles/PNG/:tile_id.jpng
|
GET /tiles/png/:tile_id.jpng
|
||||||
GET /tiles/JSON/:tile_id.json
|
|
||||||
"""
|
"""
|
||||||
def serve(conn) do
|
def serve(conn) do
|
||||||
raw_tile_id = conn.path_params["tile_id"]
|
raw_tile_id = conn.path_params["tile_id"]
|
||||||
@@ -81,10 +80,9 @@ defmodule MoundHuntersWeb.TileController do
|
|||||||
|
|
||||||
file_path =
|
file_path =
|
||||||
case format do
|
case format do
|
||||||
"MOUND" -> Path.join(tile_dir, "MOUND/#{tile_id}.mound")
|
"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")
|
||||||
"JPG" -> Path.join(tile_dir, "JPG/#{tile_id}.jpg")
|
"png" -> Path.join(tile_dir, "PNG/#{tile_id}.png")
|
||||||
"PNG" -> Path.join(tile_dir, "PNG/#{tile_id}.png")
|
|
||||||
end
|
end
|
||||||
|
|
||||||
Logger.info("Tile request started for #{file_path}")
|
Logger.info("Tile request started for #{file_path}")
|
||||||
|
|||||||
1697
ui/src/App.vue
1697
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 isRendering = ref(false);
|
||||||
const lastRenderedImage = ref(null);
|
const lastRenderedImage = ref(null);
|
||||||
const selectedQuality = ref(1024);
|
const selectedQuality = ref(1024);
|
||||||
|
const tileId = ref(null);
|
||||||
|
|
||||||
// Settings
|
// Settings
|
||||||
const settings = reactive({
|
const settings = reactive({
|
||||||
@@ -304,11 +305,12 @@ const handleResize = () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
// Load tile data
|
// Load tile data
|
||||||
const loadTileData = (tileData) => {
|
const loadTileData = (tileData, newTileId) => {
|
||||||
if (!scene) {
|
if (!scene) {
|
||||||
console.error('Three.js not initialized');
|
console.error('Three.js not initialized');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
tileId.value = newTileId;
|
||||||
|
|
||||||
// Remove old mesh
|
// Remove old mesh
|
||||||
if (mesh) {
|
if (mesh) {
|
||||||
@@ -539,6 +541,7 @@ const renderTile = async () => {
|
|||||||
renderStats.value.lastRenderTime = renderTime;
|
renderStats.value.lastRenderTime = renderTime;
|
||||||
|
|
||||||
emit('renderComplete', {
|
emit('renderComplete', {
|
||||||
|
tileId: tileId.value,
|
||||||
dataURL,
|
dataURL,
|
||||||
settings: { ...settings },
|
settings: { ...settings },
|
||||||
size,
|
size,
|
||||||
@@ -573,7 +576,7 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
|
|||||||
Object.assign(settings, renderSettings);
|
Object.assign(settings, renderSettings);
|
||||||
|
|
||||||
// Load tile
|
// Load tile
|
||||||
const loaded = loadTileData(tileData);
|
const loaded = loadTileData(tileData, null);
|
||||||
if (!loaded) {
|
if (!loaded) {
|
||||||
return { success: false, error: 'Failed to load tile' };
|
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