228 lines
6.5 KiB
Elixir
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
|