create backend
This commit is contained in:
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
227
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
@@ -0,0 +1,227 @@
|
||||
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
|
||||
Reference in New Issue
Block a user