diff --git a/lib/mound_hunters/application.ex b/lib/mound_hunters/application.ex index cf43adb..8907c48 100644 --- a/lib/mound_hunters/application.ex +++ b/lib/mound_hunters/application.ex @@ -48,7 +48,7 @@ defmodule MoundHunters.Application do :ok -> Logger.info("Created Mnesia schema") - {:error, {_node1, {:already_exists, _node2}}} -> + {:error, {_, {:already_exists, _}}} -> Logger.debug("Mnesia schema already exists") {:error, reason} -> @@ -74,6 +74,8 @@ defmodule MoundHunters.Application do :max_lng, :status, :error_message, + :jpg_available, + :png_available, :created_at, :updated_at ], diff --git a/lib/mound_hunters/mound_parser.ex b/lib/mound_hunters/mound_parser.ex new file mode 100644 index 0000000..c236c3d --- /dev/null +++ b/lib/mound_hunters/mound_parser.ex @@ -0,0 +1,269 @@ +defmodule MoundHunters.MoundParser do + @moduledoc """ + Parser for .mound binary lidar files. + + Supports: + - Header-only parsing + - Full file parsing (header + vertices + indices) + - Batch header parsing for directories + """ + + defmodule Header do + @moduledoc "Represents a .mound file header" + + defstruct [ + :magic, + :version, + :point_count, + :triangle_count, + :min_x, + :min_y, + :min_z, + :max_x, + :max_y, + :max_z + ] + + @type t :: %__MODULE__{ + magic: binary(), + version: non_neg_integer(), + point_count: non_neg_integer(), + triangle_count: non_neg_integer(), + min_x: float(), + min_y: float(), + min_z: float(), + max_x: float(), + max_y: float(), + max_z: float() + } + end + + defmodule MoundFile do + @moduledoc "Represents a complete parsed .mound file" + + defstruct [ + :header, + :vertices, + :indices + ] + + @type vertex :: {float(), float(), float()} + @type triangle :: {non_neg_integer(), non_neg_integer(), non_neg_integer()} + + @type t :: %__MODULE__{ + header: Header.t(), + vertices: [vertex()], + indices: [triangle()] + } + end + + @header_size 64 + @magic "LIDR" + + @doc """ + Parse only the header of a .mound file. + + ## Examples + + iex> MoundHunters.MoundParser.parse_header("path/to/file.mound") + {:ok, %MoundParser.Header{...}} + + iex> MoundHunters.MoundParser.parse_header("nonexistent.mound") + {:error, :enoent} + """ + @spec parse_header(Path.t()) :: {:ok, Header.t()} | {:error, term()} + def parse_header(path) do + with {:ok, file} <- File.open(path, [:read, :binary]), + {:ok, header_data} <- :file.pread(file, 0, @header_size), + :ok <- File.close(file), + {:ok, header} <- decode_header(header_data) do + {:ok, header} + end + end + + @doc """ + Parse a complete .mound file including header, vertices, and indices. + + ## Examples + + iex> MoundHunters.MoundParser.parse_file("path/to/file.mound") + {:ok, %MoundParser.MoundFile{...}} + """ + @spec parse_file(Path.t()) :: {:ok, MoundFile.t()} | {:error, term()} + def parse_file(path) do + with {:ok, data} <- File.read(path), + {:ok, mound_file} <- decode_file(data) do + {:ok, mound_file} + end + end + + @doc """ + Parse headers for all .mound files in a directory. + + Returns a list of tuples with filename and header. + + ## Examples + + iex> MoundHunters.MoundParser.parse_directory_headers("tiles/") + {:ok, [ + {"tile_001.mound", %MoundParser.Header{...}}, + {"tile_002.mound", %MoundParser.Header{...}} + ]} + """ + @spec parse_directory_headers(Path.t()) :: {:ok, [{String.t(), Header.t()}]} | {:error, term()} + def parse_directory_headers(dir_path) do + case File.ls(dir_path) do + {:ok, files} -> + results = + files + |> Enum.filter(&String.ends_with?(&1, ".mound")) + |> Enum.map(fn filename -> + full_path = Path.join(dir_path, filename) + + case parse_header(full_path) do + {:ok, header} -> {:ok, {filename, header}} + {:error, reason} -> {:error, {filename, reason}} + end + end) + + # Separate successes from failures + {successes, failures} = + Enum.split_with(results, fn + {:ok, _} -> true + {:error, _} -> false + end) + + successes = Enum.map(successes, fn {:ok, result} -> result end) + + case failures do + [] -> {:ok, successes} + _ -> {:ok, successes, Enum.map(failures, fn {:error, err} -> err end)} + end + + {:error, reason} -> + {:error, reason} + end + end + + # Private functions + + defp decode_header( + <> + ) do + if magic == @magic do + {:ok, + %Header{ + magic: magic, + version: version, + point_count: point_count, + triangle_count: triangle_count, + min_x: min_x, + min_y: min_y, + min_z: min_z, + max_x: max_x, + max_y: max_y, + max_z: max_z + }} + else + {:error, {:invalid_magic, magic}} + end + end + + defp decode_header(_), do: {:error, :invalid_header} + + defp decode_file(data) when byte_size(data) < @header_size do + {:error, :file_too_small} + end + + defp decode_file(data) do + <> = data + + with {:ok, header} <- decode_header(header_data), + {:ok, vertices, indices_data} <- decode_vertices(rest, header.point_count), + {:ok, indices} <- decode_indices(indices_data, header.triangle_count) do + {:ok, + %MoundFile{ + header: header, + vertices: vertices, + indices: indices + }} + end + end + + defp decode_vertices(data, point_count) do + vertex_size = point_count * 12 + expected_size = point_count * 12 + + if byte_size(data) < expected_size do + {:error, :insufficient_vertex_data} + else + <> = data + vertices = parse_vertices(vertex_data, point_count, []) + {:ok, Enum.reverse(vertices), rest} + end + end + + defp parse_vertices(<<>>, 0, acc), do: acc + + defp parse_vertices( + <>, + count, + acc + ) + when count > 0 do + parse_vertices(rest, count - 1, [{x, y, z} | acc]) + end + + defp decode_indices(data, triangle_count) do + expected_size = triangle_count * 12 + + if byte_size(data) < expected_size do + {:error, :insufficient_index_data} + else + <> = data + indices = parse_indices(index_data, triangle_count, []) + {:ok, Enum.reverse(indices)} + end + end + + defp parse_indices(<<>>, 0, acc), do: acc + + defp parse_indices( + <>, + count, + acc + ) + when count > 0 do + parse_indices(rest, count - 1, [{i1, i2, i3} | acc]) + end + + @doc """ + Get file size information without fully parsing. + + Returns expected file size based on header counts. + """ + @spec expected_file_size(Header.t()) :: non_neg_integer() + def expected_file_size(%Header{point_count: pc, triangle_count: tc}) do + @header_size + pc * 12 + tc * 12 + end + + @doc """ + Validate that a file has the correct size based on its header. + """ + @spec validate_file_size(Path.t()) :: {:ok, :valid} | {:error, term()} + def validate_file_size(path) do + with {:ok, %{size: actual_size}} <- File.stat(path), + {:ok, header} <- parse_header(path) do + expected = expected_file_size(header) + + if actual_size == expected do + {:ok, :valid} + else + {:error, {:size_mismatch, expected: expected, actual: actual_size}} + end + end + end +end diff --git a/lib/mound_hunters/repo.ex b/lib/mound_hunters/repo.ex index f38b16e..a9930e3 100644 --- a/lib/mound_hunters/repo.ex +++ b/lib/mound_hunters/repo.ex @@ -9,15 +9,52 @@ defmodule MoundHunters.Repo do @type tile_status :: :processing | :ready | :error @doc """ - Get a tile by ID from Mnesia. + Get a tile by its ID. + Returns {:ok, tile} or {:error, :not_found} """ - def get_tile(tile_id) do + def get_tile(id) do case :mnesia.transaction(fn -> - :mnesia.read(:tiles, tile_id) + :mnesia.read(:tiles, id) end) do - {:atomic, [tile]} -> {:ok, tile_to_map(tile)} - {:atomic, []} -> {:error, :not_found} - {:aborted, reason} -> {:error, reason} + {:atomic, [tile]} -> + {:ok, tile_to_map(tile)} + + {:atomic, []} -> + {:error, :not_found} + + {:aborted, reason} -> + Logger.error("Failed to read tile #{id}: #{inspect(reason)}") + {:error, :database_error} + end + end + + @doc """ + Get a tile that contains the given lat/lng coordinates. + Returns {:ok, tile} or {:error, :not_found} + + If multiple tiles contain the point, returns the first match. + """ + def get_tile_at_coords(lat, lng) do + match_spec = [ + {{:tiles, :"$1", :"$2", :"$3", :"$4", :"$5", :"$6", :"$7", :"$8", :"$9", :"$10", :"$11"}, + [ + {:andalso, {:"=<", :"$2", lat}, {:"=<", lat, :"$3"}}, + {:andalso, {:"=<", :"$4", lng}, {:"=<", lng, :"$5"}} + ], [:"$_"]} + ] + + case :mnesia.transaction(fn -> + :mnesia.select(:tiles, match_spec) + end) do + {:atomic, [tile | _]} -> + {:ok, tile_to_map(tile)} + + {:atomic, []} -> + {:error, :not_found} + + {:aborted, reason} -> + Logger.error("Failed to query tiles at #{lat}, #{lng}: #{inspect(reason)}") + {:error, :database_error} end end @@ -31,7 +68,8 @@ defmodule MoundHunters.Repo do tile_record = {:tiles, tile_id, Map.get(attrs, :min_lat), Map.get(attrs, :max_lat), Map.get(attrs, :min_lng), Map.get(attrs, :max_lng), Map.get(attrs, :status), - Map.get(attrs, :error_message), Map.get(attrs, :created_at, now), now} + Map.get(attrs, :error_message), Map.get(attrs, :jpg_available, false), + Map.get(attrs, :png_available, false), Map.get(attrs, :created_at, now), now} case :mnesia.transaction(fn -> :mnesia.write(tile_record) @@ -73,8 +111,8 @@ defmodule MoundHunters.Repo do # Convert Mnesia tile record to map defp tile_to_map( - {:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, created_at, - updated_at} + {:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, jpg_available, + png_available, created_at, updated_at} ) do %{ id: id, @@ -84,6 +122,8 @@ defmodule MoundHunters.Repo do max_lng: max_lng, status: status, error_message: error_message, + jpg_available: jpg_available, + png_available: png_available, created_at: created_at, updated_at: updated_at } @@ -98,6 +138,175 @@ defmodule MoundHunters.Repo do } end + @doc """ + Repopulate tiles table from .mound files in the configured directory. + Skips tiles that already exist in the database. + Returns {success_count, skip_count, error_count}. + """ + def repopulate_from_mounds do + tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) + mound_dir = Path.join(tile_dir, "MOUND") + + Logger.info("Scanning for .mound files in: #{mound_dir}") + + case File.ls(mound_dir) do + {:ok, files} -> + results = + files + |> Enum.filter(&String.ends_with?(&1, ".mound")) + |> Enum.map(fn filename -> + # Extract tile_id from filename (e.g., "BS18921654.mound" -> "BS18921654") + tile_id = Path.rootname(filename) + full_path = Path.join(mound_dir, filename) + + process_mound_file(tile_id, full_path) + end) + + successes = Enum.count(results, &(&1 == :ok)) + skips = Enum.count(results, &(&1 == :skipped)) + errors = Enum.count(results, &match?({:error, _}, &1)) + + Logger.info( + "Repopulation complete: #{successes} inserted, #{skips} skipped, #{errors} errors" + ) + + {successes, skips, errors} + + {:error, reason} -> + Logger.error("Failed to list directory #{mound_dir}: #{inspect(reason)}") + {:error, reason} + end + end + + @doc """ + Scan tile directories and update jpg_available and png_available flags. + Returns {updated_count, not_found_count}. + """ + def update_tile_availability do + tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) + mound_dir = Path.join(tile_dir, "MOUND") + + Logger.info("Scanning tile availability in: #{mound_dir}") + + # Get all tile IDs from database + case :mnesia.transaction(fn -> + :mnesia.match_object({:tiles, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_, :_}) + end) do + {:atomic, tiles} -> + results = + tiles + |> Enum.map(fn tile_record -> + 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_available = File.exists?(jpg_path) + png_available = File.exists?(png_path) + + # Only update if availability changed + if jpg_available != tile.jpg_available or png_available != tile.png_available do + attrs = %{ + id: tile_id, + min_lat: tile.min_lat, + max_lat: tile.max_lat, + min_lng: tile.min_lng, + max_lng: tile.max_lng, + status: tile.status, + error_message: tile.error_message, + jpg_available: jpg_available, + png_available: png_available, + created_at: tile.created_at + } + + case upsert_tile(attrs) do + {:ok, _} -> + Logger.debug("Updated #{tile_id}: jpg=#{jpg_available}, png=#{png_available}") + + :updated + + {:error, reason} -> + Logger.error("Failed to update #{tile_id}: #{inspect(reason)}") + {:error, reason} + end + else + :unchanged + end + end) + + updated = Enum.count(results, &(&1 == :updated)) + unchanged = Enum.count(results, &(&1 == :unchanged)) + errors = Enum.count(results, &match?({:error, _}, &1)) + + Logger.info( + "Availability scan complete: #{updated} updated, #{unchanged} unchanged, #{errors} errors" + ) + + {updated, unchanged, errors} + + {:aborted, reason} -> + Logger.error("Failed to read tiles: #{inspect(reason)}") + {:error, reason} + end + end + + defp process_mound_file(tile_id, file_path) do + # Check if tile already exists + case get_tile(tile_id) do + {:ok, _existing} -> + Logger.debug("Skipping existing tile: #{tile_id}") + :skipped + + {:error, :not_found} -> + # Parse header and insert + 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 + + # Check for jpg and png availability + tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) + mound_dir = Path.join(tile_dir, "MOUND") + jpg_available = File.exists?(Path.join(mound_dir, "#{tile_id}.jpg")) + png_available = File.exists?(Path.join(mound_dir, "#{tile_id}.png")) + + attrs = %{ + id: tile_id, + min_lat: min_lat, + max_lat: max_lat, + min_lng: min_lng, + max_lng: max_lng, + status: :ready, + error_message: nil, + jpg_available: jpg_available, + png_available: png_available + } + + case upsert_tile(attrs) do + {:ok, _tile} -> + Logger.debug("Inserted tile: #{tile_id}") + :ok + + {:error, reason} -> + Logger.error("Failed to insert tile #{tile_id}: #{inspect(reason)}") + {:error, reason} + end + + {:error, reason} -> + Logger.error("Failed to parse header for #{tile_id}: #{inspect(reason)}") + {:error, reason} + end + + {:error, reason} -> + Logger.error("Failed to check if tile exists #{tile_id}: #{inspect(reason)}") + {:error, reason} + end + end + # Generate random 8-character alphanumeric ID defp generate_id do :crypto.strong_rand_bytes(6) diff --git a/lib/mound_hunters_web/controllers/api_controller.ex b/lib/mound_hunters_web/controllers/api_controller.ex index 227013c..de2e855 100644 --- a/lib/mound_hunters_web/controllers/api_controller.ex +++ b/lib/mound_hunters_web/controllers/api_controller.ex @@ -63,6 +63,66 @@ defmodule MoundHuntersWeb.ApiController do end end + @doc """ + Get tile metadata by ID. + GET /api/meta/tile/:id + Returns: tile metadata including bounds, status, availability flags + """ + def get_tile(conn) do + tile_id = conn.path_params["id"] + + case MoundHunters.Repo.get_tile(tile_id) do + {:ok, tile} -> + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "public, max-age=60") + |> send_resp(200, Jason.encode!(tile)) + + {:error, :not_found} -> + send_error(conn, 404, "Tile not found") + + {:error, reason} -> + Logger.error("Failed to get tile: #{inspect(reason)}") + send_error(conn, 500, "Failed to retrieve tile") + end + end + + @doc """ + Get tile metadata by coordinates. + GET /api/meta/tile?lat=40.0&lng=-82.5 + Returns: tile metadata for the tile containing the given coordinates + """ + def get_tile_by_coords(conn) do + with lat_str when is_binary(lat_str) <- conn.query_params["lat"], + lng_str when is_binary(lng_str) <- conn.query_params["lng"], + {lat, _} <- Float.parse(lat_str), + {lng, _} <- Float.parse(lng_str) do + case MoundHunters.Repo.get_tile_at_coords(lat, lng) do + {:ok, tile} -> + conn + |> put_resp_content_type("application/json") + |> put_resp_header("cache-control", "public, max-age=60") + |> send_resp(200, Jason.encode!(tile)) + + {:error, :not_found} -> + send_error(conn, 404, "No tile found at coordinates") + + {:error, reason} -> + Logger.error("Failed to get tile by coords: #{inspect(reason)}") + send_error(conn, 500, "Failed to retrieve tile") + end + else + nil -> + send_error(conn, 400, "Missing lat or lng parameter") + + :error -> + send_error(conn, 400, "Invalid lat or lng format") + + _ -> + send_error(conn, 400, "Invalid request") + end + end + defp send_error(conn, status, message) do conn |> put_private(:error_message, message) diff --git a/lib/mound_hunters_web/router.ex b/lib/mound_hunters_web/router.ex index a7db9ad..424cf33 100644 --- a/lib/mound_hunters_web/router.ex +++ b/lib/mound_hunters_web/router.ex @@ -43,6 +43,16 @@ defmodule MoundHuntersWeb.Router do MoundHuntersWeb.ApiController.get_share(conn) end + # Tile metadata API + get "/api/meta/tile/:id" do + conn = put_private(conn, :path_params, %{"id" => id}) + MoundHuntersWeb.ApiController.get_tile(conn) + end + + get "/api/meta/tile" do + MoundHuntersWeb.ApiController.get_tile_by_coords(conn) + end + # Health check get "/health" do send_resp(conn, 200, Jason.encode!(%{status: "ok"})) diff --git a/ui/public/mound b/ui/public/mound deleted file mode 120000 index 26e4c1e..0000000 --- a/ui/public/mound +++ /dev/null @@ -1 +0,0 @@ -/home/mark/projects/moundhunters/data/MOUND \ No newline at end of file diff --git a/ui/public/png b/ui/public/png deleted file mode 120000 index 716ebab..0000000 --- a/ui/public/png +++ /dev/null @@ -1 +0,0 @@ -/home/mark/projects/moundhunters/data/PNG \ No newline at end of file diff --git a/ui/src/App.vue b/ui/src/App.vue index df35111..19fc7ad 100644 --- a/ui/src/App.vue +++ b/ui/src/App.vue @@ -176,6 +176,9 @@ const tileCache = ref({}); // Cached .mound data (for shading sandbox) const loadedPngTiles = ref(new Set()); // Track which PNG tiles are displayed on map const currentTileData = ref(null); +const loadingState = ref({}); // {"-82.448446,40.051780": 'pending'} +// Values: 'looking_up' | 'found' | 'processing' | 'complete' | 'failed' + // Geometry state const drawMode = ref(null); // 'line', 'ray', or null const drawPoints = ref([]); @@ -391,14 +394,12 @@ const BIBLIOGRAPHY = { } }; - // KNOWN_SITES with citations added to descriptions // Citations use \cite{key} format for easy parsing - const KNOWN_SITES = [ { "name": "Newark Octagon Earthworks", - "coordinates": [[-82.4463745, 40.0519828]], + "coordinates": [[-82.444270, 40.054705]], "description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle \\cite{hively_horn_1982}. Connected to a 20-acre Observatory Circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The walls and gateways encode all eight lunar standstill rise and set points \\cite{mickelson_lepper_2007}. The Octagon's eight walls (each approximately 550 feet long) and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world, the other being High Bank Works \\cite{hively_horn_1984}. First comprehensively surveyed by Squier and Davis in the 1840s \\cite{squier_davis_1848}. Inscribed as a UNESCO World Heritage Site in September 2023 \\cite{unesco_2023}.", "type": "earthwork", "tiles": [ @@ -413,7 +414,8 @@ const KNOWN_SITES = [ 'BS19870743', 'BS19860743', 'BS19880743', - 'BS19880742' + 'BS19880742', + 'BS19830746' ], "overlay": [ @@ -442,7 +444,7 @@ const KNOWN_SITES = [ }, { "name": "Van Voorhis Walls", - "coordinates": [[-82.446375, 40.051983], [-82.447, 40.048], [-82.448, 40.045], [-82.45, 40.04]], + "coordinates": [[ -82.459139,40.028334]], "description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek \\cite{lepper_1995}. This confirmed earthwork consists of parallel walls approximately 60 meters (200 feet) apart, aligned on an azimuth of approximately 212° toward Chillicothe \\cite{schwarz_2016}. First documented by James and Charles Salisbury in 1862, who followed the walls for 6 miles through 'tangled swamps and over hills, still keeping their undeviating course' \\cite{salisbury_salisbury_1862}. LiDAR analysis suggests the road was sunken between the walls \\cite{romain_burks_2008}. Test excavations in 2009 revealed a thin layer of white limestone that may have paved the road \\cite{lepper_2024}. Still visible above ground in woodland areas too swampy to farm.", "type": "road_confirmed", "tiles": ['BS19820746', 'BS19820745', 'BS19820743', 'BS19820742', 'BS19860742', 'BS19880742'] @@ -712,7 +714,7 @@ async function loadPngTiles() { for (const tileName of TILE_NAMES) { try { // Fetch metadata JSON for bounds - const metaResponse = await fetch(`/png/${tileName}.json`); + const metaResponse = await fetch(`/tiles/JSON/${tileName}.json`); if (!metaResponse.ok) { console.warn(`No metadata for ${tileName}, skipping`); continue; @@ -720,7 +722,7 @@ async function loadPngTiles() { const meta = await metaResponse.json(); // Check PNG exists - const pngUrl = `/png/${tileName}.png`; + const pngUrl = `/tiles/PNG/${tileName}.png`; const pngResponse = await fetch(pngUrl, { method: 'HEAD' }); if (!pngResponse.ok) { console.warn(`No PNG for ${tileName}, skipping`); @@ -774,7 +776,7 @@ async function loadMoundData(tileName) { console.log(`Loading mound data for ${tileName}...`); try { - const data = await parseMoundFile(`/mound/${tileName}.mound`); + const data = await parseMoundFile(`/tiles/mound/${tileName}.mound`); tileCache.value[tileName] = data; console.log(`Cached mound data for ${tileName}`); return data; @@ -1049,28 +1051,67 @@ function clearAllGeometry() { // Request a tile for a specific location async function requestTile() { const { lng, lat } = contextMenu.value.lngLat; + const stateKey = `${lat},${lng}`; - // TODO: Call backend endpoint - // For now, just log what we would send - const requestData = { - longitude: lng, - latitude: lat, - // Backend will: - // 1. Transform to Ohio State Plane South (EPSG:3735) in US Survey Feet - // 2. Call https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query - // 3. Extract TileName, County, Block - // 4. Return tile info and download URL + // Initialize loading state + loadingState.value[stateKey] = 'looking_up'; + + // Open SSE connection + const eventSource = new EventSource(`/tiles/request?lat=${lat}&lng=${lng}`); + + eventSource.onmessage = (event) => { + const data = JSON.parse(event.data); + + switch (data.status) { + case 'looking_up': + loadingState.value[stateKey] = 'looking_up'; + break; + + case 'found': + loadingState.value[stateKey] = 'found'; + break; + + case 'processing': + loadingState.value[stateKey] = 'processing'; + break; + + case 'ready': + loadingState.value[stateKey] = 'complete'; + // Load the tile data into cache + loadMoundData(data.tile_id); + eventSource.close(); + // Clean up loading state after 10 seconds + setTimeout(() => { + delete loadingState.value[stateKey]; + }, 10000); + break; + + case 'error': + loadingState.value[stateKey] = 'failed'; + console.error('Tile request failed:', data.message); + // TODO: Show toast notification with error message + eventSource.close(); + // Clean up loading state after 10 seconds + setTimeout(() => { + delete loadingState.value[stateKey]; + }, 10000); + break; + } + }; + + eventSource.onerror = (error) => { + loadingState.value[stateKey] = 'failed'; + console.error('SSE connection error:', error); + // TODO: Show toast notification with error message + eventSource.close(); + // Clean up loading state after 10 seconds + setTimeout(() => { + delete loadingState.value[stateKey]; + }, 10000); }; - - console.log('=== Tile Request ==='); - console.log('Would send to backend:', requestData); - console.log('Backend endpoint: POST /api/tiles/request'); // Close the context menu contextMenu.value.visible = false; - - // TODO: Show loading indicator - // TODO: Handle response and update UI } // Load mound data for interactive shading diff --git a/ui/src/App1.vue b/ui/src/App1.vue deleted file mode 100644 index 9cedb4f..0000000 --- a/ui/src/App1.vue +++ /dev/null @@ -1,210 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/src/App2.vue b/ui/src/App2.vue deleted file mode 100644 index da9e4e8..0000000 --- a/ui/src/App2.vue +++ /dev/null @@ -1,398 +0,0 @@ - - - - - \ No newline at end of file diff --git a/ui/vite.config.js b/ui/vite.config.js index 6cc235f..08160bf 100644 --- a/ui/vite.config.js +++ b/ui/vite.config.js @@ -34,7 +34,7 @@ export default defineConfig({ }, server: { proxy: { - '/socket': { + '/tiles': { target: 'http://localhost:4000', changeOrigin: true, secure: false @@ -44,7 +44,7 @@ export default defineConfig({ changeOrigin: true, secure: false }, - '/admin': { + '/health': { target: 'http://localhost:4000', changeOrigin: true, secure: false