Files
MoundHunters/lib/mound_hunters_web/controllers/tile_controller.ex
2026-01-24 10:23:37 +01:00

228 lines
6.5 KiB
Elixir

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