defmodule MoundHuntersWeb.TileController do @moduledoc """ Handles tile requests via Server-Sent Events and serves tile files. """ import Plug.Conn require Logger @doc """ SSE endpoint for tile requests. GET /tiles/request?lat=40.0&lng=-82.5 Flow: 1. Validate coords (done by BoundsCheck plug) 2. Format lookup_id from lat/lng 3. Request tile processing 4. Poll :tile_lookups until we get tile_id or error 5. Poll :tile_processing for status updates 6. Send SSE events for progress 7. When done, send final event with tile_id and URLs """ def request(conn) do lat = String.to_float(conn.query_params["lat"]) lng = String.to_float(conn.query_params["lng"]) # Start SSE stream conn = conn |> put_resp_header("content-type", "text/event-stream") |> put_resp_header("cache-control", "no-cache") |> put_resp_header("connection", "keep-alive") |> send_chunked(200) # Request tile processing {:ok, lookup_id} = MoundHunters.TileProcessor.request_tile(lat, lng) Logger.info("Tile request started for #{lookup_id}") # Store lookup_id in conn for logging conn = put_private(conn, :lookup_id, lookup_id) # Poll for lookup completion tile_id = case poll_lookup(conn, lookup_id) do {:ok, id} -> id {:error, reason} -> conn = put_private(conn, :error_message, reason) send_sse_event(conn, %{status: "error", message: reason}) nil end # If we got a tile_id, poll for processing status if tile_id do conn = put_private(conn, :tile_id, tile_id) poll_processing(conn, tile_id) end conn end @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 """ def serve(conn) do raw_tile_id = conn.path_params["tile_id"] format = conn.path_params["format"] # Strip extension if present tile_id = raw_tile_id |> Path.rootname() # Store tile_id for logging conn = put_private(conn, :tile_id, tile_id) tile_dir = Application.get_env(:mound_hunters, :tile_output_dir) 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") end Logger.info("Tile request started for #{file_path}") if File.exists?(file_path) do conn |> put_resp_header("content-type", MIME.from_path(file_path)) |> put_resp_header("cache-control", "public, max-age=31536000, immutable") |> send_file(200, file_path) else conn |> put_private(:error_message, "Tile file not found") |> put_resp_content_type("application/json") |> send_resp(404, Jason.encode!(%{error: "Tile file not found"})) end end # Poll :tile_lookups ETS table until we get a tile_id or error defp poll_lookup(conn, lookup_id, attempts \\ 0) do # 60 seconds at 500ms intervals max_attempts = 120 case MoundHunters.TileProcessor.get_lookup_status(lookup_id) do {:ok, :pending} -> if attempts < max_attempts do send_sse_event(conn, %{status: "looking_up", message: "Finding tile..."}) Process.sleep(500) poll_lookup(conn, lookup_id, attempts + 1) else {:error, "Lookup timeout"} end {:ok, {:ok, tile_id}} -> send_sse_event(conn, %{status: "found", tile_id: tile_id}) {:ok, tile_id} {:ok, {:error, reason}} -> {:error, reason} {:error, :not_found} -> # Request hasn't been picked up yet if attempts < max_attempts do Process.sleep(500) poll_lookup(conn, lookup_id, attempts + 1) else {:error, "Lookup not started"} end end end # Poll :tile_processing ETS table for status updates defp poll_processing(conn, tile_id, last_status \\ nil, attempts \\ 0) do # 5 minutes at 500ms intervals max_attempts = 600 case MoundHunters.TileProcessor.get_processing_status(tile_id) do {:ok, status} when status != last_status -> # Status changed, send update event = build_status_event(status, tile_id) send_sse_event(conn, event) case status do :done -> # Processing complete :ok {:error, _reason} -> # Error occurred, stop polling :ok _ -> # Continue polling if attempts < max_attempts do Process.sleep(500) poll_processing(conn, tile_id, status, attempts + 1) else send_sse_event(conn, %{status: "error", message: "Processing timeout"}) end end {:ok, status} -> # Status unchanged, keep polling if attempts < max_attempts do Process.sleep(500) poll_processing(conn, tile_id, status, attempts + 1) else send_sse_event(conn, %{status: "error", message: "Processing timeout"}) end {:error, :not_found} -> # Tile not in processing queue yet if attempts < max_attempts do Process.sleep(500) poll_processing(conn, tile_id, last_status, attempts + 1) else send_sse_event(conn, %{status: "error", message: "Tile not queued"}) end end end defp build_status_event(:queued, _tile_id) do %{status: "processing", message: "Queued for processing..."} end defp build_status_event(:looking_up, _tile_id) do %{status: "processing", message: "Looking up tile information..."} end defp build_status_event(:downloading, _tile_id) do %{status: "processing", message: "Downloading lidar data..."} end defp build_status_event(:extracting, _tile_id) do %{status: "processing", message: "Extracting point cloud..."} end defp build_status_event(:converting, _tile_id) do %{status: "processing", message: "Converting to mound format..."} end defp build_status_event(:done, tile_id) do %{ status: "ready", tile_id: tile_id, urls: %{ mound: "/tiles/#{tile_id}.mound", jpg: "/tiles/#{tile_id}.jpg" } } end defp build_status_event({:error, reason}, _tile_id) do %{status: "error", message: to_string(reason)} end defp send_sse_event(conn, data) do json = Jason.encode!(data) chunk(conn, "data: #{json}\n\n") end end