diff --git a/lib/mound_hunters/mound_parser.ex b/lib/mound_hunters/mound_parser.ex index c236c3d..f905c3c 100644 --- a/lib/mound_hunters/mound_parser.ex +++ b/lib/mound_hunters/mound_parser.ex @@ -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 diff --git a/lib/mound_hunters/repo.ex b/lib/mound_hunters/repo.ex index a9930e3..eba382f 100644 --- a/lib/mound_hunters/repo.ex +++ b/lib/mound_hunters/repo.ex @@ -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, diff --git a/lib/mound_hunters/tile_processor.ex b/lib/mound_hunters/tile_processor.ex index 76ceaee..5421026 100644 --- a/lib/mound_hunters/tile_processor.ex +++ b/lib/mound_hunters/tile_processor.ex @@ -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 diff --git a/lib/mound_hunters_web/controllers/tile_controller.ex b/lib/mound_hunters_web/controllers/tile_controller.ex index 8762b0b..1a3244d 100644 --- a/lib/mound_hunters_web/controllers/tile_controller.ex +++ b/lib/mound_hunters_web/controllers/tile_controller.ex @@ -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}") diff --git a/ui/src/App.vue b/ui/src/App.vue index 19fc7ad..75a5761 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -1,7 +1,13 @@ + + + + + + - - - Base Map: - Street - Satellite - - - - - {{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers - - - - {{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }} - - - - - {{ site.name }} - - 📍 - - - - - - - - Show Lidar - - - Opacity: {{ Math.round(lidarOpacity) }}% - - - - - - - - Show Geometry - - - - Imperial Units - - - - - Open Shading Sandbox - - + + + + - - - 📏 Line - - - ➡️ Ray - - - 🗑️ Clear - - + + + + - - - {{ formatCoordinate(contextMenu.lngLat.lng, 'lng') }}, {{ formatCoordinate(contextMenu.lngLat.lat, 'lat') }} - {{ contextMenu.tileName }} - - 📍 Drop Pin - 📏 Measure from here - 📥 Request Tile - 📦 Load Tile Data - 🔬 Open in Shading Sandbox - + + + + - - - - Pin #{{ popup.feature.properties.number }} - {{ formatCoordinate(popup.feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(popup.feature.geometry.coordinates[1], 'lat') }} - Delete - - - {{ popup.type === 'line' ? 'Line' : 'Ray' }} - Length: {{ formatDistance(popup.feature.properties.length) }} - Bearing: {{ popup.feature.properties.bearing.toFixed(1) }}° - Delete - - - {{ popup.feature.properties.name }} - {{ popup.feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }} - {{ popup.feature.properties.description }} - - - × - + + + + + + + + + + + \ No newline at end of file diff --git a/ui/src/components/ContextMenu.vue b/ui/src/components/ContextMenu.vue new file mode 100644 index 0000000..f3a5f96 --- /dev/null +++ b/ui/src/components/ContextMenu.vue @@ -0,0 +1,169 @@ + + + + + + + {{ formatCoordinate(lngLat.lng, 'lng') }}, {{ formatCoordinate(lngLat.lat, 'lat') }} + {{ tileData.id }} + ⚠️ Lookup failed + + + + + + + + 📍 Drop Pin + 📏 Measure from here + + + + 📥 Request Tile + + + + + 📦 Load Tile Images + + + + + 🔬 Load Interactive Data + + + + + 🔬 Open in Shading Sandbox + + + + + + + \ No newline at end of file diff --git a/ui/src/components/FeaturePopup.vue b/ui/src/components/FeaturePopup.vue new file mode 100644 index 0000000..3a0d9d8 --- /dev/null +++ b/ui/src/components/FeaturePopup.vue @@ -0,0 +1,230 @@ + + + + + + + + + Pin #{{ feature.properties.number }} + {{ formatCoordinate(feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(feature.geometry.coordinates[1], 'lat') }} + Delete + + + + + {{ type === 'line' ? 'Line' : 'Ray' }} + Length: {{ formatDistance(feature.properties.length, imperialUnits) }} + Bearing: {{ formatBearing(feature.properties.bearing) }} + Delete + + + + + {{ feature.properties.name }} + {{ feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }} + + + {{ segment.content }} + [{{ segment.key }}] + + + + + + + + + × + + + + + + \ No newline at end of file diff --git a/ui/src/components/GeometryToolbar.vue b/ui/src/components/GeometryToolbar.vue new file mode 100644 index 0000000..d7354e1 --- /dev/null +++ b/ui/src/components/GeometryToolbar.vue @@ -0,0 +1,108 @@ + + + + + + + 📏 Line + + + + ➡️ Ray + + + + + + + 🗑️ Clear + + + + + + + \ No newline at end of file diff --git a/ui/src/components/MapControls.vue b/ui/src/components/MapControls.vue new file mode 100644 index 0000000..6f57252 --- /dev/null +++ b/ui/src/components/MapControls.vue @@ -0,0 +1,356 @@ + + + + + + + Base Map: + + + Street + + + + Satellite + + + + + + + + + {{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers + + + + {{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }} + + + + + {{ site.name }} + + + 📍 + + + + + + + + + + + + Show Lidar + + + Opacity: {{ Math.round(lidarOpacity) }}% + + + + + + + + + + + Show Geometry + + + + Imperial Units + + + + + + + + + Open Shading Sandbox + + + + + + + $emit('dismissRequest', id)" + /> + + + + + + \ No newline at end of file diff --git a/ui/src/ShadingSandbox.vue b/ui/src/components/ShadingSandbox.vue similarity index 99% rename from ui/src/ShadingSandbox.vue rename to ui/src/components/ShadingSandbox.vue index f934149..47ae5a1 100644 --- a/ui/src/ShadingSandbox.vue +++ b/ui/src/components/ShadingSandbox.vue @@ -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' }; } diff --git a/ui/src/components/TileRequestNotification.vue b/ui/src/components/TileRequestNotification.vue new file mode 100644 index 0000000..9e0ac27 --- /dev/null +++ b/ui/src/components/TileRequestNotification.vue @@ -0,0 +1,224 @@ + + + + + + + Tile Requests + + + + + + + + + {{ formatLocation(request.lat, request.lng) }} + + + {{ getStatusIcon(request.status) }} + {{ getStatusText(request.status) }} + + + {{ request.message }} + + + + + + × + + + + + + + + \ No newline at end of file diff --git a/ui/src/utils/api.js b/ui/src/utils/api.js new file mode 100644 index 0000000..980ede2 --- /dev/null +++ b/ui/src/utils/api.js @@ -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} 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} 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} 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} 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} 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} 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; + } +} \ No newline at end of file diff --git a/ui/src/batch-renderer.js b/ui/src/utils/batch-renderer.js similarity index 100% rename from ui/src/batch-renderer.js rename to ui/src/utils/batch-renderer.js diff --git a/ui/src/utils/citations.js b/ui/src/utils/citations.js new file mode 100644 index 0000000..04c9120 --- /dev/null +++ b/ui/src/utils/citations.js @@ -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; +} \ No newline at end of file diff --git a/ui/src/utils/coordinates.js b/ui/src/utils/coordinates.js new file mode 100644 index 0000000..ebb4887 --- /dev/null +++ b/ui/src/utils/coordinates.js @@ -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)}°`; +} \ No newline at end of file diff --git a/ui/src/utils/geometry.js b/ui/src/utils/geometry.js new file mode 100644 index 0000000..0557c4e --- /dev/null +++ b/ui/src/utils/geometry.js @@ -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]; +} \ No newline at end of file diff --git a/ui/src/utils/tileCache.js b/ui/src/utils/tileCache.js new file mode 100644 index 0000000..7d66bf4 --- /dev/null +++ b/ui/src/utils/tileCache.js @@ -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 + }; +} \ No newline at end of file