Compare commits

...

10 Commits

44 changed files with 6331 additions and 1517 deletions

13
.gitignore vendored
View File

@@ -23,10 +23,13 @@ erl_crash.dump
/tmp/
# Ignore package tarball (built via "mix hex.build").
docmark-*.tar
/data/
docs/*.pdf
data
# Ignore my stack of pdfs
/docs/
# Ignore application logs
/priv/logs/
# Ignore assets that are produced by build tools.
/priv/static/assets/
@@ -34,6 +37,10 @@ data
/priv/static/*.html
/priv/static/*.css
# Ignore mnesia data
Mnesia*/
/priv/mnesia/
# Ignore digested assets cache.
/priv/static/cache_manifest.json

27
config/config.exs Normal file
View File

@@ -0,0 +1,27 @@
import Config
config :mound_hunters,
python_cli_path: System.get_env("PYTHON_CLI_PATH", "./cli.py"),
las2mound_script_path: System.get_env("LAS2MOUND_PATH", "./tooling/las2mound.py"),
tile_output_dir: "data",
tile_temp_dir: "priv/tmp",
mnesia_dir: ~c"priv/mnesia",
http_port: String.to_integer(System.get_env("PORT", "4000")),
log_dir: "priv/logs"
# Logger configuration
config :logger,
backends: [:console, {LoggerFileBackend, :request_log}]
config :logger, :console,
format: "[$level] $message\n",
metadata: [:request_id]
config :logger, :request_log,
path: "priv/logs/requests.jsonl",
level: :info,
format: "$message\n",
metadata: []
# Import environment specific config
import_config "#{config_env()}.exs"

6
config/dev.exs Normal file
View File

@@ -0,0 +1,6 @@
import Config
# Development-specific configuration
config :logger, :console,
format: "[$level] $message\n",
metadata: [:request_id]

3
config/prod.exs Normal file
View File

@@ -0,0 +1,3 @@
import Config
config :logger, level: :info

7
config/test.exs Normal file
View File

@@ -0,0 +1,7 @@
import Config
config :mound_hunters,
http_port: 4001,
mnesia_dir: ~c"priv/mnesia_test"
config :logger, level: :warning

View File

@@ -0,0 +1,114 @@
defmodule MoundHunters.Application do
@moduledoc false
use Application
require Logger
@impl true
def start(_type, _args) do
# Ensure log directory exists
ensure_log_dir()
# Initialize Mnesia before starting supervision tree
setup_mnesia()
children = [
# Tile processing GenServer
MoundHunters.TileProcessor,
# HTTP server
{Plug.Cowboy, scheme: :http, plug: MoundHuntersWeb.Router, options: [port: http_port()]}
]
opts = [strategy: :one_for_one, name: MoundHunters.Supervisor]
Supervisor.start_link(children, opts)
end
defp ensure_log_dir do
log_dir = Application.get_env(:mound_hunters, :log_dir, "priv/logs")
File.mkdir_p!(log_dir)
end
defp http_port do
Application.get_env(:mound_hunters, :http_port, 4000)
end
defp setup_mnesia do
mnesia_dir = Application.get_env(:mound_hunters, :mnesia_dir)
# Ensure mnesia directory exists
mnesia_dir
|> to_string()
|> File.mkdir_p!()
# Stop mnesia if running
:mnesia.stop()
# Create schema if it doesn't exist
case :mnesia.create_schema([node()]) do
:ok ->
Logger.info("Created Mnesia schema")
{:error, {_, {:already_exists, _}}} ->
Logger.debug("Mnesia schema already exists")
{:error, reason} ->
Logger.warning("Failed to create Mnesia schema: #{inspect(reason)}")
end
# Start Mnesia
:ok = :mnesia.start()
Logger.info("Mnesia started")
# Create tables if they don't exist
create_tables()
end
defp create_tables do
# Tiles table
case :mnesia.create_table(:tiles,
attributes: [
:id,
:min_lat,
:max_lat,
:min_lng,
:max_lng,
:status,
:error_message,
:jpg_available,
:png_available,
:created_at,
:updated_at
],
disc_copies: [node()],
type: :set
) do
{:atomic, :ok} ->
Logger.info("Created :tiles table")
{:aborted, {:already_exists, :tiles}} ->
Logger.debug(":tiles table already exists")
{:aborted, reason} ->
Logger.error("Failed to create :tiles table: #{inspect(reason)}")
end
# Geometries table
case :mnesia.create_table(:geometries,
attributes: [:id, :geojson, :created_at],
disc_copies: [node()],
type: :set
) do
{:atomic, :ok} ->
Logger.info("Created :geometries table")
{:aborted, {:already_exists, :geometries}} ->
Logger.debug(":geometries table already exists")
{:aborted, reason} ->
Logger.error("Failed to create :geometries table: #{inspect(reason)}")
end
# Wait for tables to be ready
:mnesia.wait_for_tables([:tiles, :geometries], 5000)
end
end

View File

@@ -0,0 +1,121 @@
defmodule MoundHunters.Boundary do
@moduledoc """
Ohio state boundary checking using point-in-polygon algorithm.
"""
# Simplified Ohio bounding box for fast preliminary check
# More precise polygon would be loaded from GeoJSON
@ohio_bbox %{
min_lat: 38.403,
max_lat: 42.327,
min_lng: -84.820,
max_lng: -80.519
}
# Ohio state boundary polygon (simplified)
# Source: Github -> PublicaMundi/MappingAPI us-states.json
# Coordinates are [lng, lat] pairs per GeoJSON spec
@ohio_polygon [
{-80.518598, 41.978802},
{-80.518598, 40.636951},
{-80.666475, 40.582182},
{-80.595275, 40.472643},
{-80.600752, 40.319289},
{-80.737675, 40.078303},
{-80.830783, 39.711348},
{-81.219646, 39.388209},
{-81.345616, 39.344393},
{-81.455155, 39.410117},
{-81.570170, 39.267716},
{-81.685186, 39.273193},
{-81.811156, 39.081500},
{-81.783771, 38.966484},
{-81.887833, 38.873376},
{-82.035710, 39.026731},
{-82.221926, 38.785745},
{-82.172634, 38.632391},
{-82.293127, 38.577622},
{-82.331465, 38.446175},
{-82.594358, 38.424267},
{-82.731282, 38.561191},
{-82.846298, 38.588575},
{-82.890113, 38.758361},
{-83.032514, 38.725499},
{-83.142052, 38.626914},
{-83.519961, 38.703591},
{-83.678792, 38.632391},
{-83.903347, 38.769315},
{-84.215533, 38.807653},
{-84.231963, 38.895284},
{-84.434610, 39.103408},
{-84.817996, 39.103408},
{-84.801565, 40.500028},
{-84.807042, 41.694001},
{-83.454238, 41.732339},
{-83.065375, 41.595416},
{-82.933929, 41.513262},
{-82.835344, 41.589939},
{-82.616266, 41.431108},
{-82.479343, 41.381815},
{-82.013803, 41.513262},
{-81.739956, 41.485877},
{-81.444201, 41.672093},
{-81.011523, 41.852832},
{-80.518598, 41.978802},
{-80.518598, 41.978802}
]
@doc """
Check if coordinates are within Ohio boundaries.
Returns :ok or {:error, reason}
"""
def check_bounds(lat, lng) when is_number(lat) and is_number(lng) do
cond do
not in_bounding_box?(lat, lng) ->
{:error, "Coordinates outside Ohio bounding box"}
not in_polygon?(lng, lat, @ohio_polygon) ->
{:error, "Coordinates outside Ohio boundary"}
true ->
:ok
end
end
def check_bounds(_lat, _lng) do
{:error, "Invalid coordinates"}
end
defp in_bounding_box?(lat, lng) do
lat >= @ohio_bbox.min_lat and lat <= @ohio_bbox.max_lat and
lng >= @ohio_bbox.min_lng and lng <= @ohio_bbox.max_lng
end
# Ray casting algorithm for point-in-polygon test
# Returns true if point (x, y) is inside the polygon
defp in_polygon?(x, y, polygon) do
n = length(polygon)
polygon
|> Enum.with_index()
|> Enum.reduce(false, fn {{xi, yi}, i}, inside ->
j = rem(i + n - 1, n)
{xj, yj} = Enum.at(polygon, j)
intersects =
yi > y != yj > y and
x < (xj - xi) * (y - yi) / (yj - yi) + xi
if intersects, do: not inside, else: inside
end)
end
@doc """
Format coordinates to 6 decimal places for consistent lookups.
"""
def format_lookup_id(lat, lng) do
lat_str = :erlang.float_to_binary(lat / 1.0, decimals: 6)
lng_str = :erlang.float_to_binary(lng / 1.0, decimals: 6)
"#{lat_str},#{lng_str}"
end
end

View File

@@ -0,0 +1,335 @@
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(
<<magic::binary-size(4), version::little-unsigned-32, point_count::little-unsigned-32,
triangle_count::little-unsigned-32, min_x::little-float-32, min_y::little-float-32,
min_z::little-float-32, max_x::little-float-32, max_y::little-float-32,
max_z::little-float-32, _reserved::binary-size(24)>>
) 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
<<header_data::binary-size(@header_size), rest::binary>> = 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
<<vertex_data::binary-size(vertex_size), rest::binary>> = 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(
<<x::little-float-32, y::little-float-32, z::little-float-32, rest::binary>>,
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
<<index_data::binary-size(expected_size), _rest::binary>> = data
indices = parse_indices(index_data, triangle_count, [])
{:ok, Enum.reverse(indices)}
end
end
defp parse_indices(<<>>, 0, acc), do: acc
defp parse_indices(
<<i1::little-unsigned-32, i2::little-unsigned-32, i3::little-unsigned-32, rest::binary>>,
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
@doc """
Converts Web Mercator coordinates (EPSG:3857) to WGS84 lat/lng (EPSG:4326).
Web Mercator uses meters from origin, this converts back to decimal degrees.
## Examples
iex> MoundHunters.MoundParser.web_mercator_to_latlon(0, 0)
{0.0, 0.0}
iex> MoundHunters.MoundParser.web_mercator_to_latlon(1113194.91, 5009377.09)
{10.0, 40.0}
"""
@spec web_mercator_to_latlon(float(), float()) :: {lat :: float(), lon :: float()}
def web_mercator_to_latlon(x, y) do
# Web Mercator origin offset (meters)
origin_shift = :math.pi() * 6_378_137.0
# Convert x to longitude
lon = x / origin_shift * 180.0
# Convert y to latitude
lat = y / origin_shift * 180.0
lat =
180.0 / :math.pi() *
(2.0 * :math.atan(:math.exp(lat * :math.pi() / 180.0)) - :math.pi() / 2.0)
{lat, lon}
end
@doc """
Converts a bounding box from Web Mercator to lat/lng.
Returns a map with min/max lat/lng values.
## Examples
iex> MoundHunters.MoundParser.bounds_web_mercator_to_latlon(
...> %{min_x: -9_000_000, max_x: -8_900_000, min_y: 4_900_000, max_y: 5_000_000}
...> )
%{min_lat: ..., max_lat: ..., min_lng: ..., max_lng: ...}
"""
@spec bounds_web_mercator_to_latlon(%{
min_x: float(),
max_x: float(),
min_y: float(),
max_y: float()
}) :: %{
min_lat: float(),
max_lat: float(),
min_lng: float(),
max_lng: float()
}
def bounds_web_mercator_to_latlon(%{min_x: min_x, max_x: max_x, min_y: min_y, max_y: max_y}) do
{min_lat, min_lng} = web_mercator_to_latlon(min_x, min_y)
{max_lat, max_lng} = web_mercator_to_latlon(max_x, max_y)
%{
min_lat: min_lat,
max_lat: max_lat,
min_lng: min_lng,
max_lng: max_lng
}
end
end

View File

@@ -0,0 +1,196 @@
defmodule MoundHunters.OhioLidar do
@moduledoc """
Functions for querying Ohio's ArcGIS tile service and coordinate conversions.
"""
require Logger
# Ohio ArcGIS tile service
@tile_service_url "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query"
# OGRIP download URL template
@download_url_template "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip"
@doc """
Convert lon/lat (WGS84) to Web Mercator (EPSG:3857).
Formula from: https://en.wikipedia.org/wiki/Web_Mercator_projection
"""
def lonlat_to_webmercator(lon, lat) do
# Earth radius in meters
r = 6378137.0
# Convert to radians
lon_rad = lon * :math.pi() / 180.0
lat_rad = lat * :math.pi() / 180.0
# Web Mercator formulas
x = r * lon_rad
y = r * :math.log(:math.tan(:math.pi() / 4.0 + lat_rad / 2.0))
{x, y}
end
@doc """
Query Ohio ArcGIS service for tile information at given coordinates.
Returns {:ok, tile_info} or {:error, reason}
tile_info contains: %{
tile_name: "BS19820747",
county: "LIC",
year: "2020",
block: "4",
note: "..."
}
"""
def query_tile_info(lon, lat) do
{x, y} = lonlat_to_webmercator(lon, lat)
geometry =
Jason.encode!(%{
x: x,
y: y,
spatialReference: %{wkid: 3857}
})
params = %{
"f" => "json",
"returnGeometry" => "false",
"spatialRel" => "esriSpatialRelIntersects",
"geometry" => geometry,
"geometryType" => "esriGeometryPoint",
"inSR" => "3857",
"outFields" => "*",
"outSR" => "3857"
}
Logger.debug("Querying ArcGIS for tile at (#{lon}, #{lat}) -> WebMercator (#{x}, #{y})")
case HTTPoison.get(@tile_service_url, [], params: params, timeout: 10_000) do
{:ok, %HTTPoison.Response{status_code: 200, body: body}} ->
parse_tile_response(body)
{:ok, %HTTPoison.Response{status_code: status}} ->
Logger.error("ArcGIS returned status #{status}")
{:error, "ArcGIS service returned status #{status}"}
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to query ArcGIS: #{inspect(reason)}")
{:error, "Network error: #{inspect(reason)}"}
end
end
defp parse_tile_response(body) do
case Jason.decode(body) do
{:ok, %{"features" => []}} ->
{:error, :no_tile_found}
{:ok, %{"features" => [feature | _]}} ->
attrs = feature["attributes"]
tile_info = %{
tile_name: attrs["TileName"],
county: attrs["County"],
year: attrs["Year"],
block: attrs["Block"],
note: attrs["note"]
}
{:ok, tile_info}
{:ok, _} ->
{:error, :invalid_response}
{:error, _} ->
{:error, :json_parse_error}
end
end
@doc """
Get the download URL for a tile.
"""
def get_download_url(tile_name, county) do
@download_url_template
|> String.replace("{county}", county)
|> String.replace("{tile_name}", tile_name)
end
@doc """
Download a tile ZIP file from OGRIP.
Returns {:ok, file_path} or {:error, reason}
"""
def download_tile(tile_name, county, output_path) do
url = get_download_url(tile_name, county)
Logger.info("Downloading #{tile_name} from #{url}")
# Ensure output directory exists
output_path
|> Path.dirname()
|> File.mkdir_p!()
case HTTPoison.get(url, [], timeout: 60_000, recv_timeout: 60_000, stream_to: self()) do
{:ok, %HTTPoison.AsyncResponse{id: id}} ->
receive_download(id, output_path, 0)
{:error, %HTTPoison.Error{reason: reason}} ->
Logger.error("Failed to start download: #{inspect(reason)}")
{:error, "Download failed: #{inspect(reason)}"}
end
end
defp receive_download(id, output_path, bytes_received) do
receive do
%HTTPoison.AsyncStatus{id: ^id, code: 200} ->
Logger.debug("Download started, status 200")
receive_download(id, output_path, bytes_received)
%HTTPoison.AsyncStatus{id: ^id, code: status} ->
Logger.error("Download failed with status #{status}")
{:error, "HTTP status #{status}"}
%HTTPoison.AsyncHeaders{id: ^id} ->
# Start writing to file
File.open(output_path, [:write, :binary], fn file ->
receive_download_chunks(id, file, bytes_received)
end)
%HTTPoison.AsyncEnd{id: ^id} ->
Logger.error("Download ended prematurely")
{:error, :unexpected_end}
{:error, reason} ->
Logger.error("Download error: #{inspect(reason)}")
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
defp receive_download_chunks(id, file, bytes_received) do
receive do
%HTTPoison.AsyncChunk{id: ^id, chunk: chunk} ->
IO.binwrite(file, chunk)
new_bytes = bytes_received + byte_size(chunk)
# Log progress every 10MB
if div(new_bytes, 10_485_760) > div(bytes_received, 10_485_760) do
Logger.debug("Downloaded #{div(new_bytes, 1_048_576)} MB")
end
receive_download_chunks(id, file, new_bytes)
%HTTPoison.AsyncEnd{id: ^id} ->
size_mb = bytes_received / 1_048_576
Logger.info("Download complete: #{Float.round(size_mb, 2)} MB")
{:ok, bytes_received}
{:error, reason} ->
{:error, reason}
after
70_000 ->
{:error, :timeout}
end
end
end

321
lib/mound_hunters/repo.ex Normal file
View File

@@ -0,0 +1,321 @@
defmodule MoundHunters.Repo do
@moduledoc """
Mnesia database helpers for tiles and geometries.
"""
require Logger
# Tile statuses
@type tile_status :: :processing | :ready | :error
@doc """
Get a tile by its ID.
Returns {:ok, tile} or {:error, :not_found}
"""
def get_tile(id) do
case :mnesia.transaction(fn ->
:mnesia.read(:tiles, id)
end) do
{: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
@doc """
Insert or update a tile record.
"""
def upsert_tile(attrs) do
tile_id = Map.fetch!(attrs, :id)
now = System.system_time(:second)
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, :jpg_available, false),
Map.get(attrs, :png_available, false), Map.get(attrs, :created_at, now), now}
case :mnesia.transaction(fn ->
:mnesia.write(tile_record)
end) do
{:atomic, :ok} -> {:ok, tile_to_map(tile_record)}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Get a shared geometry by ID.
"""
def get_geometry(geometry_id) do
case :mnesia.transaction(fn ->
:mnesia.read(:geometries, geometry_id)
end) do
{:atomic, [geometry]} -> {:ok, geometry_to_map(geometry)}
{:atomic, []} -> {:error, :not_found}
{:aborted, reason} -> {:error, reason}
end
end
@doc """
Create a new shared geometry.
"""
def create_geometry(geojson) do
geometry_id = generate_id()
now = System.system_time(:second)
geometry_record = {:geometries, geometry_id, geojson, now}
case :mnesia.transaction(fn ->
:mnesia.write(geometry_record)
end) do
{:atomic, :ok} -> {:ok, %{id: geometry_id}}
{:aborted, reason} -> {:error, reason}
end
end
# Convert Mnesia tile record to map
defp tile_to_map(
{:tiles, id, min_lat, max_lat, min_lng, max_lng, status, error_message, jpg_available,
png_available, created_at, updated_at}
) do
%{
id: id,
min_lat: min_lat,
max_lat: max_lat,
min_lng: min_lng,
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
}
end
# Convert Mnesia geometry record to map
defp geometry_to_map({:geometries, id, geojson, created_at}) do
%{
id: id,
geojson: geojson,
created_at: created_at
}
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")
png_dir = Path.join(tile_dir, "PNG")
jpg_dir = Path.join(tile_dir, "JPG")
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(jpg_dir, "#{tile_id}.jpg")
png_path = Path.join(png_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
bounds =
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
min_x: header.min_x,
max_x: header.max_x,
min_y: header.min_y,
max_y: 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: bounds.min_lat,
max_lat: bounds.max_lat,
min_lng: bounds.min_lng,
max_lng: bounds.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)
|> Base.url_encode64(padding: false)
|> binary_part(0, 8)
end
end

View File

@@ -0,0 +1,423 @@
defmodule MoundHunters.TileProcessor do
@moduledoc """
GenServer that processes tile requests: lookup tile IDs and download/convert tiles.
Processing pipeline:
1. :looking_up - Query ArcGIS for tile metadata
2. :downloading - Download ZIP from OGRIP
3. :extracting - Unzip the archive
4. :converting - Run las2mound.py to create .mound file
5. :done - Files ready in priv/tiles/
"""
use GenServer
require Logger
alias MoundHunters.OhioLidar
# Processing statuses
@type lookup_status :: :pending | {:ok, String.t()} | {:error, String.t()}
@type processing_status ::
:queued | :downloading | :extracting | :converting | :done | {:error, String.t()}
# Client API
def start_link(opts) do
GenServer.start_link(__MODULE__, opts, name: __MODULE__)
end
@doc """
Request processing for a lat/lng coordinate pair.
Returns the lookup_id that can be used to poll progress.
"""
def request_tile(lat, lng) do
lookup_id = MoundHunters.Boundary.format_lookup_id(lat, lng)
GenServer.cast(__MODULE__, {:request_tile, lookup_id, lat, lng})
{:ok, lookup_id}
end
@doc """
Get the current lookup status for a coordinate pair.
"""
def get_lookup_status(lookup_id) do
case :ets.lookup(:tile_lookups, lookup_id) do
[{^lookup_id, status}] -> {:ok, status}
[] -> {:error, :not_found}
end
end
@doc """
Get the current processing status for a tile.
"""
def get_processing_status(tile_id) do
case :ets.lookup(:tile_processing, tile_id) do
[{^tile_id, status, _metadata}] -> {:ok, status}
[] -> {:error, :not_found}
end
end
@doc """
Get full metadata for a processing tile.
"""
def get_processing_metadata(tile_id) do
case :ets.lookup(:tile_processing, tile_id) do
[{^tile_id, status, metadata}] -> {:ok, status, metadata}
[] -> {:error, :not_found}
end
end
# Server callbacks
@impl true
def init(_opts) do
# Create ETS tables
:ets.new(:tile_lookups, [:set, :public, :named_table])
:ets.new(:tile_processing, [:set, :public, :named_table])
# Ensure temp directory exists
temp_dir = Application.get_env(:mound_hunters, :tile_temp_dir)
File.mkdir_p!(temp_dir)
root_output_dir = Application.get_env(:mound_hunters, :tile_output_dir)
mound_dir = Path.join(root_output_dir, "MOUND")
state = %{
las2mound_script: Application.get_env(:mound_hunters, :las2mound_script_path),
tile_output_dir: mound_dir,
tile_temp_dir: temp_dir,
processing_queue: :queue.new(),
current_job: nil
}
Logger.info("TileProcessor started")
{:ok, state}
end
@impl true
def handle_cast({:request_tile, lookup_id, lat, lng}, state) do
# Check if we already have this lookup in progress or completed
case :ets.lookup(:tile_lookups, lookup_id) do
[] ->
# New request - insert as pending and start lookup
:ets.insert(:tile_lookups, {lookup_id, :pending})
Logger.info("New tile request for #{lookup_id} (#{lat}, #{lng})")
# Start lookup asynchronously
send(self(), {:lookup_tile, lookup_id, lat, lng})
[{^lookup_id, _status}] ->
# Already in progress or completed, ignore duplicate request
Logger.debug("Duplicate tile request for #{lookup_id}, ignoring")
end
{:noreply, state}
end
@impl true
def handle_info({:lookup_tile, lookup_id, lat, lng}, state) do
Logger.info("Looking up tile for coordinates (#{lat}, #{lng})")
# First, check Mnesia to see if we already have a tile at these coordinates
case MoundHunters.Repo.get_tile_at_coords(lat, lng) do
{:ok, tile} ->
# Found in Mnesia - use cached tile_id
tile_name = tile.id
Logger.info("Found tile #{tile_name} in Mnesia (cached), skipping ArcGIS query")
# Update lookup table with success
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
# Check if tile file still exists
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
if File.exists?(output_file) do
Logger.info("Tile #{tile_name} already processed, marking as done")
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile}})
{:noreply, state}
else
# Tile in DB but file missing - this shouldn't happen, but handle it
Logger.warning("Tile #{tile_name} in Mnesia but file missing, re-querying ArcGIS")
query_arcgis_and_process(lookup_id, lat, lng, state)
end
{:error, :not_found} ->
# Not in Mnesia - query ArcGIS
Logger.debug("Tile not in Mnesia, querying ArcGIS")
query_arcgis_and_process(lookup_id, lat, lng, state)
{:error, reason} ->
Logger.error("Failed to query Mnesia: #{inspect(reason)}")
# Fall back to ArcGIS query
query_arcgis_and_process(lookup_id, lat, lng, state)
end
end
@impl true
def handle_info({:process_tile, tile_name, tile_info}, state) do
Logger.info("Starting processing for tile #{tile_name}")
# Update status to downloading
:ets.insert(:tile_processing, {tile_name, :downloading, %{tile_info: tile_info}})
# Download ZIP
temp_zip = Path.join(state.tile_temp_dir, "#{tile_name}.zip")
case OhioLidar.download_tile(tile_name, tile_info.county, temp_zip) do
{:ok, _bytes} ->
send(self(), {:extract_tile, tile_name, tile_info, temp_zip})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to download tile #{tile_name}: #{inspect(reason)}")
:ets.insert(
:tile_processing,
{tile_name, {:error, "Download failed: #{inspect(reason)}"}, %{}}
)
# Clean up
File.rm(temp_zip)
# Process next in queue
state = process_next_in_queue(state)
{:noreply, state}
end
end
@impl true
def handle_info({:extract_tile, tile_name, tile_info, zip_path}, state) do
Logger.info("Extracting tile #{tile_name}")
:ets.insert(:tile_processing, {tile_name, :extracting, %{tile_info: tile_info}})
extract_dir = Path.join(state.tile_temp_dir, tile_name)
File.mkdir_p!(extract_dir)
case unzip_file(zip_path, extract_dir) do
:ok ->
# Find the .las file
case find_las_file(extract_dir) do
{:ok, las_file} ->
send(self(), {:convert_tile, tile_name, tile_info, las_file, extract_dir})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to find LAS file in #{extract_dir}: #{reason}")
:ets.insert(:tile_processing, {tile_name, {:error, "No LAS file found"}, %{}})
# Clean up
File.rm_rf!(extract_dir)
File.rm(zip_path)
state = process_next_in_queue(state)
{:noreply, state}
end
{:error, reason} ->
Logger.error("Failed to extract #{zip_path}: #{inspect(reason)}")
:ets.insert(
:tile_processing,
{tile_name, {:error, "Extraction failed: #{inspect(reason)}"}, %{}}
)
# Clean up
File.rm_rf!(extract_dir)
File.rm(zip_path)
state = process_next_in_queue(state)
{:noreply, state}
end
end
@impl true
def handle_info({:convert_tile, tile_name, tile_info, las_file, temp_dir}, state) do
Logger.info("Converting tile #{tile_name} to mound format")
:ets.insert(:tile_processing, {tile_name, :converting, %{tile_info: tile_info}})
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
File.mkdir_p!(state.tile_output_dir)
case run_las2mound(state.las2mound_script, las_file, output_file) do
:ok ->
Logger.info("Successfully converted #{tile_name}")
# Parse header to extract bounds
case MoundHunters.MoundParser.parse_header(output_file) do
{:ok, header} ->
# Convert Web Mercator coordinates to lat/lng
bounds =
MoundHunters.MoundParser.bounds_web_mercator_to_latlon(%{
min_x: header.min_x,
max_x: header.max_x,
min_y: header.min_y,
max_y: header.max_y
})
Logger.debug(
"Tile #{tile_name} bounds: lat [#{bounds.min_lat}, #{bounds.max_lat}], " <>
"lng [#{bounds.min_lng}, #{bounds.max_lng}]"
)
:ets.insert(
:tile_processing,
{tile_name, :done, %{tile_info: tile_info, header: header}}
)
# Update Mnesia with extracted bounds
MoundHunters.Repo.upsert_tile(%{
id: tile_name,
status: :ready,
min_lat: bounds.min_lat,
max_lat: bounds.max_lat,
min_lng: bounds.min_lng,
max_lng: bounds.max_lng
})
# Clean up temp files
File.rm_rf!(temp_dir)
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
state = process_next_in_queue(state)
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to parse header for #{tile_name}: #{inspect(reason)}")
:ets.insert(
:tile_processing,
{tile_name, {:error, "Header parsing failed: #{inspect(reason)}"}, %{}}
)
# Keep the .mound file for debugging, but clean up temp files
File.rm_rf!(temp_dir)
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
state = process_next_in_queue(state)
{:noreply, state}
end
{:error, reason} ->
Logger.error("Failed to convert #{tile_name}: #{inspect(reason)}")
:ets.insert(
:tile_processing,
{tile_name, {:error, "Conversion failed: #{inspect(reason)}"}, %{}}
)
# Clean up
File.rm_rf!(temp_dir)
File.rm(Path.join(state.tile_temp_dir, "#{tile_name}.zip"))
state = process_next_in_queue(state)
{:noreply, state}
end
end
# Helper functions
defp query_arcgis_and_process(lookup_id, lat, lng, state) do
case OhioLidar.query_tile_info(lng, lat) do
{:ok, tile_info} ->
tile_name = tile_info.tile_name
Logger.info("Found tile: #{tile_name} in #{tile_info.county} county")
# Update lookup table with success
:ets.insert(:tile_lookups, {lookup_id, {:ok, tile_name}})
# Check if tile already exists
output_file = Path.join(state.tile_output_dir, "#{tile_name}.mound")
if File.exists?(output_file) do
Logger.info("Tile #{tile_name} already processed, marking as done")
:ets.insert(:tile_processing, {tile_name, :done, %{tile_info: tile_info}})
{:noreply, state}
else
# Queue for processing
state = queue_tile_for_processing(state, tile_name, tile_info)
{:noreply, state}
end
{:error, :no_tile_found} ->
Logger.warning("No tile found for coordinates (#{lat}, #{lng})")
:ets.insert(:tile_lookups, {lookup_id, {:error, "No tile found at coordinates"}})
{:noreply, state}
{:error, reason} ->
Logger.error("Failed to lookup tile: #{inspect(reason)}")
:ets.insert(:tile_lookups, {lookup_id, {:error, "Lookup failed: #{inspect(reason)}"}})
{:noreply, state}
end
end
defp queue_tile_for_processing(state, tile_name, tile_info) do
# Check if already in queue or processing
case :ets.lookup(:tile_processing, tile_name) do
[] ->
# Add to queue
:ets.insert(:tile_processing, {tile_name, :queued, %{tile_info: tile_info}})
new_queue = :queue.in({tile_name, tile_info}, state.processing_queue)
state = %{state | processing_queue: new_queue}
# If nothing currently processing, start now
if state.current_job == nil do
process_next_in_queue(state)
else
state
end
[{^tile_name, _status, _metadata}] ->
Logger.debug("Tile #{tile_name} already in processing queue")
state
end
end
defp process_next_in_queue(state) do
case :queue.out(state.processing_queue) do
{{:value, {tile_name, tile_info}}, new_queue} ->
Logger.info("Processing next tile in queue: #{tile_name}")
send(self(), {:process_tile, tile_name, tile_info})
%{state | processing_queue: new_queue, current_job: tile_name}
{:empty, _} ->
Logger.debug("Processing queue empty")
%{state | current_job: nil}
end
end
defp unzip_file(zip_path, extract_dir) do
case :zip.unzip(String.to_charlist(zip_path), cwd: String.to_charlist(extract_dir)) do
{:ok, _files} ->
:ok
{:error, reason} ->
{:error, reason}
end
end
defp find_las_file(dir) do
case File.ls(dir) do
{:ok, files} ->
las_files = Enum.filter(files, &String.ends_with?(&1, ".las"))
case las_files do
[las_file | _] -> {:ok, Path.join(dir, las_file)}
[] -> {:error, :no_las_file}
end
{:error, reason} ->
{:error, reason}
end
end
defp run_las2mound(script_path, input_las, output_mound) do
Logger.info("Running: #{script_path} #{input_las} #{output_mound}")
case System.cmd("python3", [script_path, input_las, output_mound], stderr_to_stdout: true) do
{output, 0} ->
Logger.debug("las2mound output: #{output}")
:ok
{output, exit_code} ->
Logger.error("las2mound failed with exit code #{exit_code}: #{output}")
{:error, "Exit code #{exit_code}"}
end
end
end

View File

@@ -0,0 +1,132 @@
defmodule MoundHuntersWeb.ApiController do
@moduledoc """
API endpoints for sharing geometries.
"""
import Plug.Conn
require Logger
@doc """
Create a new shared geometry.
POST /api/share
Body: {"geojson": {...}}
Returns: {"id": "abc12345"}
"""
def create_share(conn) do
with {:ok, body, conn} <- read_body(conn),
{:ok, params} <- Jason.decode(body),
geojson when is_map(geojson) <- params["geojson"],
geojson_str <- Jason.encode!(geojson),
{:ok, %{id: id}} <- MoundHunters.Repo.create_geometry(geojson_str) do
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "no-cache")
|> send_resp(200, Jason.encode!(%{id: id}))
else
{:error, :invalid_json} ->
send_error(conn, 400, "Invalid JSON")
nil ->
send_error(conn, 400, "Missing geojson field")
{:error, reason} ->
Logger.error("Failed to create geometry: #{inspect(reason)}")
send_error(conn, 500, "Failed to create geometry")
_ ->
send_error(conn, 400, "Invalid request")
end
end
@doc """
Get a shared geometry by ID.
GET /api/share/:id
Returns: {"geojson": {...}}
"""
def get_share(conn) do
geometry_id = conn.path_params["id"]
case MoundHunters.Repo.get_geometry(geometry_id) do
{:ok, geometry} ->
geojson = Jason.decode!(geometry.geojson)
conn
|> put_resp_content_type("application/json")
|> put_resp_header("cache-control", "no-cache")
|> send_resp(200, Jason.encode!(%{geojson: geojson}))
{:error, :not_found} ->
send_error(conn, 404, "Geometry not found")
{:error, reason} ->
Logger.error("Failed to get geometry: #{inspect(reason)}")
send_error(conn, 500, "Failed to retrieve geometry")
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)
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
end
end

View File

@@ -0,0 +1,225 @@
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
"""
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")
"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

View File

@@ -0,0 +1,75 @@
defmodule MoundHuntersWeb.Plugs.BoundsCheck do
@moduledoc """
Plug to validate that coordinates are within Ohio boundaries.
Applies to tile requests but not to geometry sharing API.
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
# Skip bounds check for non-tile endpoints
if skip_bounds_check?(conn.request_path) do
conn
else
check_bounds(conn)
end
end
defp skip_bounds_check?(path) do
# Skip bounds check for geometry API and static files
String.starts_with?(path, "/api/share") or
String.starts_with?(path, "/static/") or
path == "/"
end
defp check_bounds(conn) do
cond do
# Check query params for tile request
conn.query_params["lat"] != nil and conn.query_params["lng"] != nil ->
check_query_params(conn)
# For tile file serving, we assume tiles in storage are valid
# (they were validated when created)
String.starts_with?(conn.request_path, "/tiles/") ->
conn
true ->
conn
end
end
defp check_query_params(conn) do
with {:ok, lat} <- parse_float(conn.query_params["lat"]),
{:ok, lng} <- parse_float(conn.query_params["lng"]),
:ok <- MoundHunters.Boundary.check_bounds(lat, lng) do
conn
else
{:error, :invalid_number} ->
send_error(conn, 400, "Invalid coordinate format")
{:error, reason} ->
send_error(conn, 400, reason)
end
end
defp parse_float(str) when is_binary(str) do
case Float.parse(str) do
{num, ""} -> {:ok, num}
_ -> {:error, :invalid_number}
end
end
defp parse_float(_), do: {:error, :invalid_number}
defp send_error(conn, status, message) do
Logger.warning("Bounds check failed: #{message}")
conn
|> put_private(:error_message, message)
|> put_resp_content_type("application/json")
|> send_resp(status, Jason.encode!(%{error: message}))
|> halt()
end
end

View File

@@ -0,0 +1,101 @@
defmodule MoundHuntersWeb.Plugs.RequestLogger do
@moduledoc """
Logs each HTTP request as a single JSON line in combined log format plus custom fields.
Log format includes:
- Standard combined log fields: remote_ip, timestamp, method, path, status, bytes, referer, user_agent
- Custom fields: request_id, duration_ms, query_params, lat, lng, tile_id, lookup_id, error
"""
import Plug.Conn
require Logger
def init(opts), do: opts
def call(conn, _opts) do
start_time = System.monotonic_time(:microsecond)
request_id = generate_request_id()
conn =
conn
|> put_private(:request_start_time, start_time)
|> put_private(:request_id, request_id)
register_before_send(conn, fn conn ->
log_request(conn, start_time, request_id)
conn
end)
end
defp log_request(conn, start_time, request_id) do
end_time = System.monotonic_time(:microsecond)
duration_ms = (end_time - start_time) / 1000.0
log_entry = %{
# Standard combined log format fields
remote_ip: format_remote_ip(conn.remote_ip),
timestamp: DateTime.utc_now() |> DateTime.to_iso8601(),
method: conn.method,
path: conn.request_path,
query_string: conn.query_string,
status: conn.status,
bytes_sent: get_resp_header(conn, "content-length") |> List.first() || "-",
referer: get_req_header(conn, "referer") |> List.first() || "-",
user_agent: get_req_header(conn, "user-agent") |> List.first() || "-",
# Custom fields
request_id: request_id,
duration_ms: Float.round(duration_ms, 2),
# Tile-specific fields (if present)
lat: get_query_param(conn, "lat"),
lng: get_query_param(conn, "lng"),
tile_id: get_private_field(conn, :tile_id),
lookup_id: get_private_field(conn, :lookup_id),
# Error information
error: get_private_field(conn, :error_message)
}
# Remove nil values for cleaner logs
log_entry = Enum.reject(log_entry, fn {_k, v} -> is_nil(v) end) |> Map.new()
# Log to file as JSON line
json_line = Jason.encode!(log_entry)
Logger.info(json_line, logger: :request_log)
# Also log summary to console
console_msg =
"#{conn.method} #{conn.request_path} - #{conn.status} - #{Float.round(duration_ms, 2)}ms"
Logger.info(console_msg)
end
defp format_remote_ip({a, b, c, d}) do
"#{a}.#{b}.#{c}.#{d}"
end
defp format_remote_ip({a, b, c, d, e, f, g, h}) do
parts = [a, b, c, d, e, f, g, h]
parts
|> Enum.map(&Integer.to_string(&1, 16))
|> Enum.join(":")
end
defp get_query_param(conn, key) do
case conn.query_params do
%{^key => value} when value != "" -> value
_ -> nil
end
end
defp get_private_field(conn, key) do
case conn.private do
%{^key => value} -> value
_ -> nil
end
end
defp generate_request_id do
:crypto.strong_rand_bytes(8)
|> Base.url_encode64(padding: false)
end
end

View File

@@ -0,0 +1,168 @@
defmodule MoundHuntersWeb.Router do
@moduledoc """
Main HTTP router for Hopewell Road application.
"""
use Plug.Router
require Logger
# Request logging (must be first to capture timing)
plug(MoundHuntersWeb.Plugs.RequestLogger)
# Parse query params and request body
plug(Plug.Parsers,
parsers: [:urlencoded, :json],
json_decoder: Jason
)
# Bounds checking for Ohio coordinates
plug(MoundHuntersWeb.Plugs.BoundsCheck)
# Match routes
plug(:match)
# Dispatch to matched route
plug(:dispatch)
# Tile endpoints
get "/tiles/request" do
MoundHuntersWeb.TileController.request(conn)
end
match "/tiles/:format/:tile_id", via: [:get, :head] do
conn = put_private(conn, :path_params, %{"tile_id" => tile_id, "format" => format})
MoundHuntersWeb.TileController.serve(conn)
end
# Geometry sharing API
post "/api/share" do
MoundHuntersWeb.ApiController.create_share(conn)
end
get "/api/share/:id" do
conn = put_private(conn, :path_params, %{"id" => id})
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"}))
end
get "/" do
static_dir = Application.app_dir(:mound_hunters, "priv/static")
index_path = Path.join(static_dir, "index.html")
if File.exists?(index_path) do
conn
|> put_resp_header("content-type", "text/html; charset=utf-8")
|> put_resp_header("cache-control", "no-cache")
|> send_file(200, index_path)
else
send_resp(conn, 404, "Not found")
end
end
# Static files (with cache busting support via manifest)
get "/*path" do
serve_static(conn, path)
end
# 404 handler
match _ do
send_resp(conn, 404, Jason.encode!(%{error: "Not found"}))
end
# Serve static files from priv/static
defp serve_static(conn, path) do
static_dir = Application.app_dir(:mound_hunters, "priv/static")
file_path = resolve_static_path(path, static_dir)
cond do
file_path && File.regular?(file_path) ->
# Determine content type
content_type = MIME.from_path(file_path)
# Set cache headers (immutable for hashed files)
cache_control =
if String.contains?(file_path, "-") do
"public, max-age=31536000, immutable"
else
"public, max-age=3600"
end
conn
|> put_resp_header("content-type", content_type)
|> put_resp_header("cache-control", cache_control)
|> send_file(200, file_path)
path == [] or path == [""] ->
# Serve index.html for root
index_path = Path.join(static_dir, "index.html")
if File.exists?(index_path) do
conn
|> put_resp_header("content-type", "text/html")
|> put_resp_header("cache-control", "no-cache")
|> send_file(200, index_path)
else
send_resp(conn, 404, "Not found")
end
true ->
send_resp(conn, 404, "Not found")
end
end
# Resolve static file path, checking manifest for hashed versions
defp resolve_static_path(path, static_dir) do
requested = Path.join(path)
direct_path = Path.join(static_dir, requested)
cond do
File.regular?(direct_path) ->
direct_path
# Check manifest for hashed version
manifest_path = Path.join(static_dir, "manifest.json") ->
case load_manifest(manifest_path) do
{:ok, manifest} ->
hashed = Map.get(manifest, requested)
if hashed do
hashed_path = Path.join(static_dir, hashed)
if File.regular?(hashed_path), do: hashed_path, else: nil
else
nil
end
_ ->
nil
end
true ->
nil
end
end
# Load and cache manifest
defp load_manifest(path) do
if File.exists?(path) do
case File.read(path) do
{:ok, content} -> Jason.decode(content)
_ -> {:error, :read_failed}
end
else
{:error, :not_found}
end
end
end

31
mix.exs Normal file
View File

@@ -0,0 +1,31 @@
defmodule MoundHunters.MixProject do
use Mix.Project
def project do
[
app: :mound_hunters,
version: "0.1.0",
elixir: "~> 1.14",
start_permanent: Mix.env() == :prod,
deps: deps()
]
end
def application do
[
extra_applications: [:logger, :mnesia, :inets, :ssl],
mod: {MoundHunters.Application, []}
]
end
defp deps do
[
{:plug_cowboy, "~> 2.7"},
{:jason, "~> 1.4"},
{:geo, "~> 3.6"},
{:logger_file_backend, "~> 0.0.13"},
{:httpoison, "~> 2.2"},
{:mime, "~> 2.0"}
]
end
end

23
mix.lock Normal file
View File

@@ -0,0 +1,23 @@
%{
"certifi": {:hex, :certifi, "2.15.0", "0e6e882fcdaaa0a5a9f2b3db55b1394dba07e8d6d9bcad08318fb604c6839712", [:rebar3], [], "hexpm", "b147ed22ce71d72eafdad94f055165c1c182f61a2ff49df28bcc71d1d5b94a60"},
"cowboy": {:hex, :cowboy, "2.14.2", "4008be1df6ade45e4f2a4e9e2d22b36d0b5aba4e20b0a0d7049e28d124e34847", [:make, :rebar3], [{:cowlib, ">= 2.16.0 and < 3.0.0", [hex: :cowlib, repo: "hexpm", optional: false]}, {:ranch, ">= 1.8.0 and < 3.0.0", [hex: :ranch, repo: "hexpm", optional: false]}], "hexpm", "569081da046e7b41b5df36aa359be71a0c8874e5b9cff6f747073fc57baf1ab9"},
"cowboy_telemetry": {:hex, :cowboy_telemetry, "0.4.0", "f239f68b588efa7707abce16a84d0d2acf3a0f50571f8bb7f56a15865aae820c", [:rebar3], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:telemetry, "~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "7d98bac1ee4565d31b62d59f8823dfd8356a169e7fcbb83831b8a5397404c9de"},
"cowlib": {:hex, :cowlib, "2.16.0", "54592074ebbbb92ee4746c8a8846e5605052f29309d3a873468d76cdf932076f", [:make, :rebar3], [], "hexpm", "7f478d80d66b747344f0ea7708c187645cfcc08b11aa424632f78e25bf05db51"},
"geo": {:hex, :geo, "3.6.0", "00c9c6338579f67e91cd5950af4ae2eb25cdce0c3398718c232539f61625d0bd", [:mix], [{:jason, "~> 1.4", [hex: :jason, repo: "hexpm", optional: true]}], "hexpm", "1dbdebf617183b54bc3c8ad7a36531a9a76ada8ca93f75f573b0ae94006168da"},
"hackney": {:hex, :hackney, "1.25.0", "390e9b83f31e5b325b9f43b76e1a785cbdb69b5b6cd4e079aa67835ded046867", [:rebar3], [{:certifi, "~> 2.15.0", [hex: :certifi, repo: "hexpm", optional: false]}, {:idna, "~> 6.1.0", [hex: :idna, repo: "hexpm", optional: false]}, {:metrics, "~> 1.0.0", [hex: :metrics, repo: "hexpm", optional: false]}, {:mimerl, "~> 1.4", [hex: :mimerl, repo: "hexpm", optional: false]}, {:parse_trans, "3.4.1", [hex: :parse_trans, repo: "hexpm", optional: false]}, {:ssl_verify_fun, "~> 1.1.0", [hex: :ssl_verify_fun, repo: "hexpm", optional: false]}, {:unicode_util_compat, "~> 0.7.1", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "7209bfd75fd1f42467211ff8f59ea74d6f2a9e81cbcee95a56711ee79fd6b1d4"},
"httpoison": {:hex, :httpoison, "2.3.0", "10eef046405bc44ba77dc5b48957944df8952cc4966364b3cf6aa71dce6de587", [:mix], [{:hackney, "~> 1.21", [hex: :hackney, repo: "hexpm", optional: false]}], "hexpm", "d388ee70be56d31a901e333dbcdab3682d356f651f93cf492ba9f06056436a2c"},
"idna": {:hex, :idna, "6.1.1", "8a63070e9f7d0c62eb9d9fcb360a7de382448200fbbd1b106cc96d3d8099df8d", [:rebar3], [{:unicode_util_compat, "~> 0.7.0", [hex: :unicode_util_compat, repo: "hexpm", optional: false]}], "hexpm", "92376eb7894412ed19ac475e4a86f7b413c1b9fbb5bd16dccd57934157944cea"},
"jason": {:hex, :jason, "1.4.4", "b9226785a9aa77b6857ca22832cffa5d5011a667207eb2a0ad56adb5db443b8a", [:mix], [{:decimal, "~> 1.0 or ~> 2.0", [hex: :decimal, repo: "hexpm", optional: true]}], "hexpm", "c5eb0cab91f094599f94d55bc63409236a8ec69a21a67814529e8d5f6cc90b3b"},
"logger_file_backend": {:hex, :logger_file_backend, "0.0.14", "774bb661f1c3fed51b624d2859180c01e386eb1273dc22de4f4a155ef749a602", [:mix], [], "hexpm", "071354a18196468f3904ef09413af20971d55164267427f6257b52cfba03f9e6"},
"metrics": {:hex, :metrics, "1.0.1", "25f094dea2cda98213cecc3aeff09e940299d950904393b2a29d191c346a8486", [:rebar3], [], "hexpm", "69b09adddc4f74a40716ae54d140f93beb0fb8978d8636eaded0c31b6f099f16"},
"mime": {:hex, :mime, "2.0.7", "b8d739037be7cd402aee1ba0306edfdef982687ee7e9859bee6198c1e7e2f128", [:mix], [], "hexpm", "6171188e399ee16023ffc5b76ce445eb6d9672e2e241d2df6050f3c771e80ccd"},
"mimerl": {:hex, :mimerl, "1.4.0", "3882a5ca67fbbe7117ba8947f27643557adec38fa2307490c4c4207624cb213b", [:rebar3], [], "hexpm", "13af15f9f68c65884ecca3a3891d50a7b57d82152792f3e19d88650aa126b144"},
"parse_trans": {:hex, :parse_trans, "3.4.1", "6e6aa8167cb44cc8f39441d05193be6e6f4e7c2946cb2759f015f8c56b76e5ff", [:rebar3], [], "hexpm", "620a406ce75dada827b82e453c19cf06776be266f5a67cff34e1ef2cbb60e49a"},
"plug": {:hex, :plug, "1.19.1", "09bac17ae7a001a68ae393658aa23c7e38782be5c5c00c80be82901262c394c0", [:mix], [{:mime, "~> 1.0 or ~> 2.0", [hex: :mime, repo: "hexpm", optional: false]}, {:plug_crypto, "~> 1.1.1 or ~> 1.2 or ~> 2.0", [hex: :plug_crypto, repo: "hexpm", optional: false]}, {:telemetry, "~> 0.4.3 or ~> 1.0", [hex: :telemetry, repo: "hexpm", optional: false]}], "hexpm", "560a0017a8f6d5d30146916862aaf9300b7280063651dd7e532b8be168511e62"},
"plug_cowboy": {:hex, :plug_cowboy, "2.7.5", "261f21b67aea8162239b2d6d3b4c31efde4daa22a20d80b19c2c0f21b34b270e", [:mix], [{:cowboy, "~> 2.7", [hex: :cowboy, repo: "hexpm", optional: false]}, {:cowboy_telemetry, "~> 0.3", [hex: :cowboy_telemetry, repo: "hexpm", optional: false]}, {:plug, "~> 1.14", [hex: :plug, repo: "hexpm", optional: false]}], "hexpm", "20884bf58a90ff5a5663420f5d2c368e9e15ed1ad5e911daf0916ea3c57f77ac"},
"plug_crypto": {:hex, :plug_crypto, "2.1.1", "19bda8184399cb24afa10be734f84a16ea0a2bc65054e23a62bb10f06bc89491", [:mix], [], "hexpm", "6470bce6ffe41c8bd497612ffde1a7e4af67f36a15eea5f921af71cf3e11247c"},
"ranch": {:hex, :ranch, "2.2.0", "25528f82bc8d7c6152c57666ca99ec716510fe0925cb188172f41ce93117b1b0", [:make, :rebar3], [], "hexpm", "fa0b99a1780c80218a4197a59ea8d3bdae32fbff7e88527d7d8a4787eff4f8e7"},
"ssl_verify_fun": {:hex, :ssl_verify_fun, "1.1.7", "354c321cf377240c7b8716899e182ce4890c5938111a1296add3ec74cf1715df", [:make, :mix, :rebar3], [], "hexpm", "fe4c190e8f37401d30167c8c405eda19469f34577987c76dde613e838bbc67f8"},
"telemetry": {:hex, :telemetry, "1.3.0", "fedebbae410d715cf8e7062c96a1ef32ec22e764197f70cda73d82778d61e7a2", [:rebar3], [], "hexpm", "7015fc8919dbe63764f4b4b87a95b7c0996bd539e0d499be6ec9d7f3875b79e6"},
"unicode_util_compat": {:hex, :unicode_util_compat, "0.7.1", "a48703a25c170eedadca83b11e88985af08d35f37c6f664d6dcfb106a97782fc", [:rebar3], [], "hexpm", "b3a917854ce3ae233619744ad1e0102e05673136776fb2fa76234f3e03b23642"},
}

View File

@@ -34,6 +34,7 @@ let
pkgs.python3Packages.scipy
pkgs.python3Packages.numpy
pkgs.python3Packages.pyproj
pkgs.python3Packages.requests
];
mkShell = pkgs.mkShell;

View File

@@ -0,0 +1,240 @@
#!/usr/bin/env python3
"""
Process Hopewell Earthworks Sites
Acquires lidar tiles, processes them to mound format, and generates metadata.
"""
import subprocess
import json
import os
from pathlib import Path
from typing import List, Dict, Tuple
# Define all sites of interest
SITES = [
{
"name": "Newark Octagon Earthworks",
"coordinates": [(-82.4463745, 40.0519828)],
"description": "Part of the Newark Earthworks complex, the Octagon is precisely aligned to the 18.6-year lunar cycle. Connected to a 50-acre circle, this geometric earthwork demonstrates sophisticated astronomical knowledge. The Octagon's eight walls and Observatory Circle form one of only two known circle-octagon pairs in the Hopewell world.",
"type": "earthwork",
"significance": "unesco_world_heritage",
"azimuth_alignment": 211,
},
{
"name": "Great Circle Earthworks",
"coordinates": [(-82.4277555, 40.0402671)],
"description": "A nearly perfect circle 1,200 feet in diameter, enclosing 30 acres. The earthen wall is lined by a deep interior ditch. Located in Heath, Ohio, this is one of the largest circular earthworks in the Americas and serves as the site of the Newark Earthworks Museum.",
"type": "earthwork",
"significance": "unesco_world_heritage",
},
{
"name": "Van Voorhis Walls",
"coordinates": [
(-82.446375, 40.051983),
(-82.447, 40.048),
(-82.448, 40.045),
(-82.450, 40.040),
],
"description": "The best-preserved section of the Great Hopewell Road, extending 2.5 miles south from the Newark Octagon to Ramp Creek. This confirmed earthwork consists of parallel walls approximately 50 meters (150-200 feet) apart, aligned on an azimuth of 211°. Still visible above ground in woodland areas too swampy to farm.",
"type": "road_confirmed",
"significance": "confirmed_road_section",
"azimuth_alignment": 211,
},
{
"name": "Mound City Group",
"coordinates": [(-83.0065767, 39.3744923)],
"description": "The headquarters of Hopewell Culture National Historical Park. Contains 23 burial mounds within a nearly square earthen enclosure along the Scioto River. Each mound covered a charnel house where the dead were cremated. Excavations revealed spectacular artifacts including effigy pipes, mica, and copper.",
"type": "earthwork",
"significance": "unesco_world_heritage",
},
{
"name": "Hopeton Earthworks",
"coordinates": [(-82.9809185, 39.3790743)],
"description": "A geometric earthwork complex featuring a circle (320m diameter) and square of similar size, connected by parallel earthen lines aligned to the winter solstice. Located on a high terrace on the east side of the Scioto River, northeast from Mound City.",
"type": "earthwork",
"significance": "unesco_world_heritage",
"astronomical_alignment": "winter_solstice",
},
{
"name": "Hopewell Mound Group",
"coordinates": [(-83.0844809, 39.3608166)],
"description": "The type site for the Hopewell culture, named after former landowner M. Cloud Hopewell. Contains 29 mounds including the largest known Hopewell mound—500 feet long and consisting of three conjoined circles. A semicircular earthwork encloses the main mound and four additional mounds.",
"type": "earthwork",
"significance": "type_site",
},
{
"name": "Seip Earthworks",
"coordinates": [(-83.2214086, 39.2416867)],
"description": "One of the largest Hopewell complexes, featuring two circles and a square enclosing 121 acres. The Seip-Pricer Mound stands 30 feet high. The square measures exactly 27 acres, matching four other nearby Hopewell sites, suggesting a common unit of measurement. Contains evidence of elaborate burials and exotic trade goods.",
"type": "earthwork",
"significance": "unesco_world_heritage",
},
{
"name": "High Bank Works",
"coordinates": [(-82.94, 39.26)],
"description": "Features a circle-octagon pair identical to the Newark Earthworks—both circles are exactly 1,050 feet in diameter. Located 60 miles from Newark on a terrace 75-80 feet above the Scioto River. The octagon is precisely aligned to the northernmost moonrise of the 18.6-year lunar cycle. Currently a research preserve, not open to the public.",
"type": "earthwork",
"significance": "unesco_world_heritage",
"astronomical_alignment": "lunar_standstill",
"paired_with": "Newark Octagon Earthworks",
},
]
def run_command(cmd: List[str], description: str) -> subprocess.CompletedProcess:
"""Run a shell command and return the result."""
print(f"\n{'='*60}")
print(f"{description}")
print(f"{'='*60}")
print(f"Running: {' '.join(cmd)}")
result = subprocess.run(cmd, capture_output=True, text=True)
if result.returncode != 0:
print(f"ERROR: {result.stderr}")
else:
print(f"SUCCESS: {result.stdout}")
return result
def acquire_tiles(site: Dict, output_dir: str) -> List[str]:
"""Acquire lidar tiles for a site."""
coords = site["coordinates"]
# Build the request_tiles.py command
cmd = ["python", "./tooling/request_tiles.py"]
for lon, lat in coords:
cmd.extend(["--coords", str(lon), str(lat)])
cmd.extend(["--output", output_dir])
result = run_command(cmd, f"Acquiring tiles for {site['name']}")
# Parse the output JSON to get tile names
if result.returncode == 0:
try:
data = json.loads(result.stdout)
tiles = [
tile["tile_name"]
for tile in data.get("tiles", [])
if tile.get("status") in ["downloaded", "already_exists"]
]
return tiles
except json.JSONDecodeError:
print(f"Warning: Could not parse JSON output for {site['name']}")
return []
return []
def unzip_tiles(tiles: List[str], lidar_dir: str) -> None:
"""Unzip all downloaded tiles."""
for tile in tiles:
zip_path = os.path.join(lidar_dir, f"{tile}.zip")
extract_dir = os.path.join(lidar_dir, tile)
if not os.path.exists(zip_path):
print(f"Warning: {zip_path} does not exist, skipping")
continue
cmd = ["unzip", "-o", zip_path, "-d", extract_dir]
run_command(cmd, f"Unzipping {tile}")
def convert_to_mound(tiles: List[str], lidar_dir: str, mound_dir: str) -> None:
"""Convert LAS files to mound format."""
for tile in tiles:
las_path = os.path.join(lidar_dir, tile, f"{tile}.las")
mound_path = os.path.join(mound_dir, f"{tile}.mound")
if not os.path.exists(las_path):
print(f"Warning: {las_path} does not exist, skipping")
continue
cmd = ["python", "tooling/las2mound.py", las_path, mound_path]
run_command(cmd, f"Converting {tile} to mound format")
def generate_metadata(sites: List[Dict], output_path: str) -> None:
"""Generate a JSON metadata file with all site information."""
metadata = {
"generated": "2025-01-21",
"description": "Hopewell Ceremonial Earthworks and Great Hopewell Road Sites",
"sites": sites,
"summary": {
"total_sites": len(sites),
"unesco_sites": len([s for s in sites if s.get("significance") == "unesco_world_heritage"]),
"confirmed_road_sections": len([s for s in sites if s.get("type") == "road_confirmed"]),
}
}
with open(output_path, 'w') as f:
json.dump(metadata, f, indent=2)
print(f"\n{'='*60}")
print(f"Metadata written to {output_path}")
print(f"{'='*60}")
def main():
"""Main processing pipeline."""
# Setup directories
base_dir = Path("./data")
lidar_dir = base_dir / "LIDAR"
mound_dir = base_dir / "MOUND"
lidar_dir.mkdir(parents=True, exist_ok=True)
mound_dir.mkdir(parents=True, exist_ok=True)
print(f"""
╔══════════════════════════════════════════════════════════════════════════╗
║ ║
║ HOPEWELL EARTHWORKS LIDAR PROCESSING PIPELINE ║
║ ║
║ Processing {len(SITES)} sites of interest: ║
║ - UNESCO World Heritage Sites ║
║ - Confirmed Great Hopewell Road sections ║
║ - Type sites and major earthwork complexes ║
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
""")
# Process each site
for site in SITES:
print(f"\n{'#'*80}")
print(f"# PROCESSING: {site['name']}")
print(f"# Type: {site['type']}")
print(f"# Coordinates: {len(site['coordinates'])} point(s)")
print(f"{'#'*80}")
# Step 1: Acquire tiles
tiles = acquire_tiles(site, str(lidar_dir))
if not tiles:
print(f"Warning: No tiles acquired for {site['name']}")
site["tiles"] = []
continue
site["tiles"] = tiles
print(f"\nAcquired {len(tiles)} tiles: {', '.join(tiles)}")
# Step 2: Unzip tiles
unzip_tiles(tiles, str(lidar_dir))
# Step 3: Convert to mound format
convert_to_mound(tiles, str(lidar_dir), str(mound_dir))
# Step 4: Generate metadata
metadata_path = base_dir / "hopewell_sites_metadata.json"
generate_metadata(SITES, str(metadata_path))
print(f"""
╔══════════════════════════════════════════════════════════════════════════╗
║ ║
║ PROCESSING COMPLETE ║
║ ║
║ Metadata: {str(metadata_path):55}
║ Lidar data: {str(lidar_dir):58}
║ Mound files: {str(mound_dir):57}
║ ║
╚══════════════════════════════════════════════════════════════════════════╝
""")
if __name__ == "__main__":
main()

401
tooling/request_tiles.py Normal file
View File

@@ -0,0 +1,401 @@
#!/usr/bin/env python3
"""
Request and download Ohio lidar tiles based on coordinates.
Usage:
./request_tiles.py --coords -82.4438 40.0547 --coords -82.4440 40.0548 --output tiles/
echo "-82.4438,40.0547" | ./request_tiles.py --output tiles/
./request_tiles.py --coords-file locations.txt --output tiles/
This tool:
1. Converts lon/lat coordinates to Web Mercator (EPSG:3857)
2. Queries Ohio's ArcGIS tile service to get tile metadata
3. Downloads tiles from OGRIP as .zip files
4. Outputs JSON summary of downloaded tiles to stdout
5. Respects rate limiting between requests
Output format (JSON to stdout):
{
"requested": 5,
"downloaded": 4,
"skipped": 1,
"failed": 0,
"tiles": [
{
"tile_name": "BS19820747",
"county": "LIC",
"year": "2020",
"block": "4",
"url": "https://gis1.oit.ohio.gov/...",
"output_path": "tiles/BS19820747.zip",
"status": "downloaded"
},
...
]
}
"""
import sys
import time
import json
import logging
import argparse
import requests
from pathlib import Path
from typing import List, Tuple, Optional, Dict, Any
try:
from pyproj import Transformer
except ImportError:
print("Error: pyproj not installed. Run: pip install pyproj", file=sys.stderr)
sys.exit(1)
# Configure logging to stderr only (stdout is for JSON output)
logging.basicConfig(
level=logging.INFO,
format='%(asctime)s [%(levelname)s] %(message)s',
stream=sys.stderr
)
logger = logging.getLogger(__name__)
# Ohio ArcGIS tile service
TILE_SERVICE_URL = "https://maps.ohio.gov/arcgis/rest/services/OGRIP/3DepTiles/MapServer/0/query"
# OGRIP download URL template
DOWNLOAD_URL_TEMPLATE = "https://gis1.oit.ohio.gov/ZIPARCHIVES_III/ELEVATION/3DEP/LIDAR/{county}/{tile_name}.zip"
def lonlat_to_webmercator(lon: float, lat: float) -> Tuple[float, float]:
"""Convert lon/lat (WGS84) to Web Mercator (EPSG:3857)."""
transformer = Transformer.from_crs("EPSG:4326", "EPSG:3857", always_xy=True)
x, y = transformer.transform(lon, lat)
return x, y
def query_tile_info(x: float, y: float) -> Optional[Dict[str, Any]]:
"""
Query Ohio ArcGIS service for tile information at given Web Mercator coordinates.
Returns dict with keys: tile_name, county, year, block, note
Returns None if no tile found or on error.
"""
params = {
'f': 'json',
'returnGeometry': 'false',
'spatialRel': 'esriSpatialRelIntersects',
'geometry': json.dumps({
'x': x,
'y': y,
'spatialReference': {'wkid': 3857}
}),
'geometryType': 'esriGeometryPoint',
'inSR': '3857',
'outFields': '*',
'outSR': '3857'
}
try:
response = requests.get(TILE_SERVICE_URL, params=params, timeout=10)
response.raise_for_status()
data = response.json()
if 'features' not in data or len(data['features']) == 0:
return None
# Get first feature (should only be one)
attrs = data['features'][0]['attributes']
return {
'tile_name': attrs.get('TileName'),
'county': attrs.get('County'),
'year': attrs.get('Year'),
'block': attrs.get('Block'),
'note': attrs.get('note')
}
except requests.RequestException as e:
logger.error(f"Failed to query tile service: {e}")
return None
except (KeyError, json.JSONDecodeError) as e:
logger.error(f"Failed to parse tile service response: {e}")
return None
def download_tile(tile_name: str, county: str, output_dir: Path) -> Tuple[bool, Optional[Path]]:
"""
Download a tile zip file from OGRIP.
Returns (success, output_path)
"""
url = DOWNLOAD_URL_TEMPLATE.format(county=county, tile_name=tile_name)
output_path = output_dir / f"{tile_name}.zip"
# Check if already exists
if output_path.exists():
logger.info(f"Tile {tile_name} already exists, skipping download")
return True, output_path
try:
logger.info(f"Downloading {tile_name} from {county}...")
response = requests.get(url, timeout=30, stream=True)
response.raise_for_status()
# Download with progress
output_dir.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
for chunk in response.iter_content(chunk_size=8192):
f.write(chunk)
size_mb = output_path.stat().st_size / (1024 * 1024)
logger.info(f"Downloaded {tile_name} ({size_mb:.2f} MB)")
return True, output_path
except requests.RequestException as e:
logger.error(f"Failed to download {tile_name}: {e}")
# Clean up partial download
if output_path.exists():
output_path.unlink()
return False, None
def process_coordinates(
coords: List[Tuple[float, float]],
output_dir: Path,
min_delay: float
) -> Dict[str, Any]:
"""
Process a list of coordinates and download corresponding tiles.
Returns summary dict with download results.
"""
results = {
'requested': len(coords),
'downloaded': 0,
'skipped': 0,
'failed': 0,
'tiles': []
}
# Track unique tiles to avoid duplicate downloads
seen_tiles = set()
for i, (lon, lat) in enumerate(coords):
logger.info(f"Processing coordinate {i+1}/{len(coords)}: ({lon:.6f}, {lat:.6f})")
# Convert to Web Mercator
x, y = lonlat_to_webmercator(lon, lat)
logger.debug(f"Web Mercator: ({x:.2f}, {y:.2f})")
# Query tile info
tile_info = query_tile_info(x, y)
if not tile_info:
logger.warning(f"No tile found for coordinates ({lon:.6f}, {lat:.6f})")
results['failed'] += 1
results['tiles'].append({
'lon': lon,
'lat': lat,
'status': 'not_found'
})
continue
tile_name = tile_info['tile_name']
county = tile_info['county']
# Check if we've already processed this tile
if tile_name in seen_tiles:
logger.info(f"Tile {tile_name} already processed in this run, skipping")
results['skipped'] += 1
continue
seen_tiles.add(tile_name)
# Download tile
output_path = output_dir / f"{tile_name}.zip"
if output_path.exists():
logger.info(f"Tile {tile_name} already exists on disk")
results['skipped'] += 1
status = 'already_exists'
success = True
else:
success, download_path = download_tile(tile_name, county, output_dir)
if success:
results['downloaded'] += 1
status = 'downloaded'
else:
results['failed'] += 1
status = 'download_failed'
# Add to results
tile_result = {
'tile_name': tile_name,
'county': county,
'year': tile_info.get('year'),
'block': tile_info.get('block'),
'url': DOWNLOAD_URL_TEMPLATE.format(county=county, tile_name=tile_name),
'output_path': str(output_path) if success else None,
'status': status
}
results['tiles'].append(tile_result)
# Rate limiting between downloads (skip if this was already on disk)
if status == 'downloaded' and i < len(coords) - 1:
logger.debug(f"Waiting {min_delay}s before next request...")
time.sleep(min_delay)
return results
def parse_coords_from_stdin() -> List[Tuple[float, float]]:
"""Parse coordinates from stdin (one per line, comma or space separated)."""
coords = []
for line in sys.stdin:
line = line.strip()
if not line or line.startswith('#'):
continue
# Try comma separator first, then space
parts = line.replace(',', ' ').split()
if len(parts) >= 2:
try:
lon = float(parts[0])
lat = float(parts[1])
coords.append((lon, lat))
except ValueError:
logger.warning(f"Skipping invalid line: {line}")
return coords
def parse_coords_from_file(filepath: Path) -> List[Tuple[float, float]]:
"""Parse coordinates from a file."""
coords = []
with open(filepath, 'r') as f:
for line in f:
line = line.strip()
if not line or line.startswith('#'):
continue
parts = line.replace(',', ' ').split()
if len(parts) >= 2:
try:
lon = float(parts[0])
lat = float(parts[1])
coords.append((lon, lat))
except ValueError:
logger.warning(f"Skipping invalid line: {line}")
return coords
def main():
parser = argparse.ArgumentParser(
description='Download Ohio lidar tiles based on coordinates',
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog="""
Examples:
# Download tiles for specific coordinates
%(prog)s --coords -82.4438 40.0547 --coords -82.4440 40.0548 --output tiles/
# Read coordinates from stdin
echo "-82.4438,40.0547" | %(prog)s --output tiles/
# Read from file
%(prog)s --coords-file locations.txt --output tiles/
# Custom rate limiting
%(prog)s --coords -82.4438 40.0547 --output tiles/ --delay 2.0
Coordinate format:
Longitude, Latitude in decimal degrees (WGS84)
Example: -82.4438 40.0547 (Newark Earthworks area)
"""
)
parser.add_argument(
'--coords',
nargs=2,
type=float,
action='append',
metavar=('LON', 'LAT'),
help='Coordinate pair (longitude latitude). Can be specified multiple times.'
)
parser.add_argument(
'--coords-file',
type=Path,
metavar='FILE',
help='File containing coordinates (one pair per line, comma or space separated)'
)
parser.add_argument(
'--output',
type=Path,
required=True,
metavar='DIR',
help='Output directory for downloaded tile zip files'
)
parser.add_argument(
'--delay',
type=float,
default=1.0,
metavar='SECONDS',
help='Minimum delay between tile downloads (default: 1.0 seconds)'
)
parser.add_argument(
'--verbose',
action='store_true',
help='Enable verbose debug logging'
)
args = parser.parse_args()
if args.verbose:
logger.setLevel(logging.DEBUG)
# Collect coordinates from all sources
coords = []
if args.coords:
coords.extend(args.coords)
if args.coords_file:
if not args.coords_file.exists():
logger.error(f"Coordinates file not found: {args.coords_file}")
sys.exit(1)
coords.extend(parse_coords_from_file(args.coords_file))
# Check stdin if no coordinates provided
if not coords and not sys.stdin.isatty():
coords.extend(parse_coords_from_stdin())
if not coords:
logger.error("No coordinates provided. Use --coords, --coords-file, or pipe to stdin.")
parser.print_help(sys.stderr)
sys.exit(1)
logger.info(f"Processing {len(coords)} coordinate(s)")
logger.info(f"Output directory: {args.output}")
logger.info(f"Rate limit: {args.delay}s between downloads")
# Process coordinates and download tiles
results = process_coordinates(coords, args.output, args.delay)
# Output JSON to stdout
print(json.dumps(results, indent=2))
# Log summary to stderr
logger.info("="*50)
logger.info(f"Summary: {results['downloaded']} downloaded, {results['skipped']} skipped, {results['failed']} failed")
if results['failed'] > 0:
logger.error(f"{results['failed']} tile(s) failed to download or locate")
sys.exit(1)
if __name__ == '__main__':
main()

View File

@@ -2,10 +2,10 @@
"imports": {
"@vitejs/plugin-vue": "npm:@vitejs/plugin-vue@^5",
"maplibre-gl": "npm:maplibre-gl@^5.16.0",
"pinia": "npm:pinia@^3.0.4",
"three": "npm:three@^0.182.0",
"vite": "npm:vite@^7.2.1",
"vue": "npm:vue@^3.5.23",
"pinia": "npm:pinia@^3.0.4",
},
"tasks": {
"dev": "deno run -A --node-modules-dir npm:vite",

20
ui/deno.lock generated
View File

@@ -398,14 +398,14 @@
"@vue/shared"
]
},
"@vue/devtools-api@7.7.7": {
"integrity": "sha512-lwOnNBH2e7x1fIIbVT7yF5D+YWhqELm55/4ZKf45R9T8r9dE2AIOy8HKjfqzGsoTHFbWbr337O4E0A0QADnjBg==",
"@vue/devtools-api@7.7.9": {
"integrity": "sha512-kIE8wvwlcZ6TJTbNeU2HQNtaxLx3a84aotTITUuL/4bzfPxzajGBOoqjMhwZJ8L9qFYDU/lAYMEEm11dnZOD6g==",
"dependencies": [
"@vue/devtools-kit"
]
},
"@vue/devtools-kit@7.7.7": {
"integrity": "sha512-wgoZtxcTta65cnZ1Q6MbAfePVFxfM+gq0saaeytoph7nEa7yMXoi6sCPy4ufO111B9msnw0VOWjPEFCXuAKRHA==",
"@vue/devtools-kit@7.7.9": {
"integrity": "sha512-PyQ6odHSgiDVd4hnTP+aDk2X4gl2HmLDfiyEnn3/oV+ckFDuswRs4IbBT7vacMuGdwY/XemxBoh302ctbsptuA==",
"dependencies": [
"@vue/devtools-shared",
"birpc",
@@ -416,8 +416,8 @@
"superjson"
]
},
"@vue/devtools-shared@7.7.7": {
"integrity": "sha512-+udSj47aRl5aKb0memBvcUG9koarqnxNM5yjuREvqwK6T3ap4mn3Zqqc17QrBFTqSMjr3HK1cvStEZpMDpfdyw==",
"@vue/devtools-shared@7.7.9": {
"integrity": "sha512-iWAb0v2WYf0QWmxCGy0seZNDPdO3Sp5+u78ORnyeonS6MT4PC7VPrryX2BpMJrwlDeaZ6BD4vP4XKjK0SZqaeA==",
"dependencies": [
"rfdc"
]
@@ -455,8 +455,8 @@
"@vue/shared@3.5.23": {
"integrity": "sha512-0YZ1DYuC5o/YJPf6pFdt2KYxVGDxkDbH/1NYJnVJWUkzr8ituBEmFVQRNX2gCaAsFEjEDnLkWpgqlZA7htgS/g=="
},
"birpc@2.7.0": {
"integrity": "sha512-tub/wFGH49vNCm0xraykcY3TcRgX/3JsALYq/Lwrtti+bTyFHkCUAWF5wgYoie8P41wYwig2mIKiqoocr1EkEQ=="
"birpc@2.9.0": {
"integrity": "sha512-KrayHS5pBi69Xi9JmvoqrIgYGDkD6mcSe/i6YKi3w5kekCLzrX4+nawcXqrj2tIp50Kw/mT/s3p+GVK0A0sKxw=="
},
"copy-anything@4.0.5": {
"integrity": "sha512-7Vv6asjS4gMOuILabD3l739tsaxFQmC+a7pLZm02zyvs8p977bL3zEgq3yDk5rn9B0PbYgIv++jmHcuUab4RhA==",
@@ -687,8 +687,8 @@
"kdbush"
]
},
"superjson@2.2.5": {
"integrity": "sha512-zWPTX96LVsA/eVYnqOM2+ofcdPqdS1dAF1LN4TS2/MWuUpfitd9ctTa87wt4xrYnZnkLtS69xpBdSxVBP5Rm6w==",
"superjson@2.2.6": {
"integrity": "sha512-H+ue8Zo4vJmV2nRjpx86P35lzwDT3nItnIsocgumgr0hHMQ+ZGq5vrERg9kJBo5AWGmxZDhzDo+WVIJqkB0cGA==",
"dependencies": [
"copy-anything"
]

View File

@@ -1 +0,0 @@
/home/mark/projects/moundhunters/data/MOUND

File diff suppressed because it is too large Load Diff

View File

@@ -1,210 +0,0 @@
<template>
<div id="app">
<canvas ref="canvas" class="canvas"></canvas>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export default {
name: 'App1',
setup() {
const canvas = ref(null);
let scene, camera, renderer, controls;
const parseMoundFile = async (url) => {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
let offset = 0;
const magic = String.fromCharCode(
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++)
);
if (magic !== 'LIDR') {
throw new Error('Invalid .mound file');
}
const version = view.getUint32(offset, true); offset += 4;
const pointCount = view.getUint32(offset, true); offset += 4;
const triangleCount = view.getUint32(offset, true); offset += 4;
const minX = view.getFloat32(offset, true); offset += 4;
const minY = view.getFloat32(offset, true); offset += 4;
const minZ = view.getFloat32(offset, true); offset += 4;
const maxX = view.getFloat32(offset, true); offset += 4;
const maxY = view.getFloat32(offset, true); offset += 4;
const maxZ = view.getFloat32(offset, true); offset += 4;
offset += 24; // Skip reserved
const positions = new Float32Array(buffer, offset, pointCount * 3);
offset += pointCount * 3 * 4;
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
return {
pointCount,
triangleCount,
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
positions,
indices
};
};
const loadLidarTile = async () => {
try {
console.log('Loading BS19820747...');
const data = await parseMoundFile('/tiles/BS19820747.mound');
console.log('Bounds:', data.bounds);
console.log('Points:', data.pointCount);
console.log('Triangles:', data.triangleCount);
const geometry = new THREE.BufferGeometry();
// Calculate center and scale first
const centerX = (data.bounds.minX + data.bounds.maxX) / 2;
const centerY = (data.bounds.minY + data.bounds.maxY) / 2;
const centerZ = (data.bounds.minZ + data.bounds.maxZ) / 2;
const spanX = data.bounds.maxX - data.bounds.minX;
const spanY = data.bounds.maxY - data.bounds.minY;
const spanZ = data.bounds.maxZ - data.bounds.minZ;
const maxSpan = Math.max(spanX, spanY);
console.log('Center:', centerX, centerY, centerZ);
console.log('Span X:', spanX, 'Y:', spanY, 'Z:', spanZ);
console.log('Max span:', maxSpan);
// Transform vertices: center and scale
const normalizeScale = 10 / maxSpan;
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
console.log('Normalize scale:', normalizeScale);
console.log('Z scale:', zScale);
// Create new position array with transformed coordinates
const transformedPositions = new Float32Array(data.positions.length);
for (let i = 0; i < data.positions.length; i += 3) {
transformedPositions[i] = (data.positions[i] - centerX) * normalizeScale; // X
transformedPositions[i + 1] = (data.positions[i + 1] - centerY) * normalizeScale; // Y
transformedPositions[i + 2] = (data.positions[i + 2] - centerZ) * zScale; // Z
}
geometry.setAttribute('position', new THREE.BufferAttribute(transformedPositions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
console.log('First few transformed positions:', transformedPositions.slice(0, 15));
const material = new THREE.MeshLambertMaterial({
color: 0x8B7355,
side: THREE.DoubleSide,
wireframe: true
});
const mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
console.log('Mesh added to scene at origin');
} catch (err) {
console.error('Failed to load tile:', err);
}
};
onMounted(() => {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
// Camera
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, -15, 10);
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({
canvas: canvas.value,
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
// OrbitControls for pan, orbit, zoom
controls = new OrbitControls(camera, canvas.value);
controls.enableDamping = true;
controls.dampingFactor = 0.05;
controls.target.set(0, 0, 0);
// Lights for the terrain
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
const directionalLight = new THREE.DirectionalLight(0xffffff, 0.8);
directionalLight.position.set(5, 5, 10);
scene.add(directionalLight);
// Axes helper
const axesHelper = new THREE.AxesHelper(15);
scene.add(axesHelper);
// Load lidar
loadLidarTile();
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
controls.update();
renderer.render(scene, camera);
};
animate();
// Handle resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
console.log('Three.js initialized');
console.log('Scene:', scene);
console.log('Camera:', camera);
});
return { canvas };
}
};
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
</style>

View File

@@ -1,398 +0,0 @@
<template>
<div id="app">
<canvas ref="canvas" class="canvas"></canvas>
<div class="control-panel">
<div class="control-group">
<label>Light Azimuth (E-W)</label>
<input
v-model.number="controls_state.azimuth"
type="range"
min="0"
max="360"
step="1"
@input="onControlChange"
/>
<span class="value">{{ Math.round(controls_state.azimuth) }}°</span>
</div>
<div class="control-group">
<label>Light Altitude (N-S)</label>
<input
v-model.number="controls_state.altitude"
type="range"
min="0"
max="90"
step="1"
@input="onControlChange"
/>
<span class="value">{{ Math.round(controls_state.altitude) }}°</span>
</div>
<div class="control-group">
<label>Light Intensity</label>
<input
v-model.number="controls_state.intensity"
type="range"
min="0"
max="2"
step="0.1"
@input="onControlChange"
/>
<span class="value">{{ controls_state.intensity.toFixed(1) }}</span>
</div>
<div class="control-group">
<label>Height Exaggeration</label>
<input
v-model.number="controls_state.heightScale"
type="range"
min="0.1"
max="10"
step="0.1"
@input="onControlChange"
/>
<span class="value">{{ controls_state.heightScale.toFixed(1) }}x</span>
</div>
</div>
</div>
</template>
<script>
import { ref, onMounted } from 'vue';
import * as THREE from 'three';
import { OrbitControls } from 'three/examples/jsm/controls/OrbitControls.js';
export default {
name: 'App1',
setup() {
const canvas = ref(null);
let scene, camera, renderer, threeControls, mesh, directionalLight;
const controls_state = ref({
azimuth: 45,
altitude: 45,
intensity: 0.8,
heightScale: 1
});
let geometryCache = null;
let centerX, centerY, centerZ, spanZ, normalizeScale;
const parseMoundFile = async (url) => {
const response = await fetch(url);
const buffer = await response.arrayBuffer();
const view = new DataView(buffer);
let offset = 0;
const magic = String.fromCharCode(
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++),
view.getUint8(offset++)
);
if (magic !== 'LIDR') {
throw new Error('Invalid .mound file');
}
const version = view.getUint32(offset, true); offset += 4;
const pointCount = view.getUint32(offset, true); offset += 4;
const triangleCount = view.getUint32(offset, true); offset += 4;
const minX = view.getFloat32(offset, true); offset += 4;
const minY = view.getFloat32(offset, true); offset += 4;
const minZ = view.getFloat32(offset, true); offset += 4;
const maxX = view.getFloat32(offset, true); offset += 4;
const maxY = view.getFloat32(offset, true); offset += 4;
const maxZ = view.getFloat32(offset, true); offset += 4;
offset += 24; // Skip reserved
const positions = new Float32Array(buffer, offset, pointCount * 3);
offset += pointCount * 3 * 4;
const indices = new Uint32Array(buffer, offset, triangleCount * 3);
return {
pointCount,
triangleCount,
bounds: { minX, minY, minZ, maxX, maxY, maxZ },
positions,
indices
};
};
const loadLidarTile = async () => {
try {
console.log('Loading BS19820747...');
const data = await parseMoundFile('/tiles/BS19820747.mound');
console.log('Bounds:', data.bounds);
console.log('Points:', data.pointCount);
console.log('Triangles:', data.triangleCount);
const geometry = new THREE.BufferGeometry();
// Calculate center and scale first
centerX = (data.bounds.minX + data.bounds.maxX) / 2;
centerY = (data.bounds.minY + data.bounds.maxY) / 2;
centerZ = (data.bounds.minZ + data.bounds.maxZ) / 2;
const spanX = data.bounds.maxX - data.bounds.minX;
const spanY = data.bounds.maxY - data.bounds.minY;
spanZ = data.bounds.maxZ - data.bounds.minZ;
const maxSpan = Math.max(spanX, spanY);
console.log('Center:', centerX, centerY, centerZ);
console.log('Span X:', spanX, 'Y:', spanY, 'Z:', spanZ);
console.log('Max span:', maxSpan);
// Transform vertices: center and scale
normalizeScale = 10 / maxSpan;
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
console.log('Normalize scale:', normalizeScale);
console.log('Z scale:', zScale);
// Create new position array with transformed coordinates
const transformedPositions = new Float32Array(data.positions.length);
for (let i = 0; i < data.positions.length; i += 3) {
transformedPositions[i] = (data.positions[i] - centerX) * normalizeScale; // X
transformedPositions[i + 1] = (data.positions[i + 1] - centerY) * normalizeScale; // Y
transformedPositions[i + 2] = (data.positions[i + 2] - centerZ) * zScale; // Z
}
geometry.setAttribute('position', new THREE.BufferAttribute(transformedPositions, 3));
geometry.setIndex(new THREE.BufferAttribute(data.indices, 1));
geometry.computeVertexNormals();
geometryCache = { geometry, baseZ: new Float32Array(data.positions.map((v, i) => i % 3 === 2 ? (v - centerZ) * zScale : 0)), zScale };
console.log('First few transformed positions:', transformedPositions.slice(0, 15));
const material = new THREE.MeshLambertMaterial({
color: 0x8B7355,
side: THREE.DoubleSide,
wireframe: false
});
mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Apply initial height scale
updateHeightScale();
console.log('Mesh added to scene at origin');
} catch (err) {
console.error('Failed to load tile:', err);
}
};
const updateHeightScale = () => {
if (!geometryCache) return;
const positions = geometryCache.geometry.attributes.position.array;
const exaggeration = controls_state.value.heightScale;
// Only every 3rd value (Z coordinate)
for (let i = 2; i < positions.length; i += 3) {
positions[i] = geometryCache.baseZ[i] * exaggeration;
}
geometryCache.geometry.attributes.position.needsUpdate = true;
geometryCache.geometry.computeVertexNormals();
};
const updateLightPosition = () => {
if (!directionalLight) return;
const azimuth = (controls_state.value.azimuth * Math.PI) / 180;
const altitude = (controls_state.value.altitude * Math.PI) / 180;
const distance = 20;
const x = Math.cos(azimuth) * Math.cos(altitude) * distance;
const y = Math.sin(azimuth) * Math.cos(altitude) * distance;
const z = Math.sin(altitude) * distance;
directionalLight.position.set(x, y, z);
directionalLight.intensity = controls_state.value.intensity;
};
const onControlChange = () => {
updateLightPosition();
updateHeightScale();
};
onMounted(() => {
// Scene
scene = new THREE.Scene();
scene.background = new THREE.Color(0x87CEEB);
// Camera
camera = new THREE.PerspectiveCamera(
75,
window.innerWidth / window.innerHeight,
0.1,
1000
);
camera.position.set(0, -15, 10);
camera.lookAt(0, 0, 0);
// Renderer
renderer = new THREE.WebGLRenderer({
canvas: canvas.value,
antialias: true
});
renderer.setSize(window.innerWidth, window.innerHeight);
// OrbitControls for pan, orbit, zoom
threeControls = new OrbitControls(camera, canvas.value);
threeControls.enableDamping = true;
threeControls.dampingFactor = 0.05;
threeControls.target.set(0, 0, 0);
// Lights for the terrain
const ambientLight = new THREE.AmbientLight(0xffffff, 0.4);
scene.add(ambientLight);
directionalLight = new THREE.DirectionalLight(0xffffff, controls_state.value.intensity);
scene.add(directionalLight);
// Set initial light position
updateLightPosition();
// Axes helper
const axesHelper = new THREE.AxesHelper(15);
scene.add(axesHelper);
// Load lidar
loadLidarTile();
// Animation loop
const animate = () => {
requestAnimationFrame(animate);
threeControls.update();
renderer.render(scene, camera);
};
animate();
// Handle resize
window.addEventListener('resize', () => {
camera.aspect = window.innerWidth / window.innerHeight;
camera.updateProjectionMatrix();
renderer.setSize(window.innerWidth, window.innerHeight);
});
console.log('Three.js initialized');
console.log('Scene:', scene);
console.log('Camera:', camera);
});
return { canvas, controls_state, onControlChange };
}
};
</script>
<style scoped>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
#app {
width: 100vw;
height: 100vh;
overflow: hidden;
position: relative;
}
.canvas {
display: block;
width: 100%;
height: 100%;
}
.control-panel {
position: fixed;
bottom: 20px;
right: 20px;
background: rgba(0, 0, 0, 0.75);
border-radius: 8px;
padding: 16px;
width: 280px;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
color: white;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
z-index: 100;
}
.control-group {
display: flex;
flex-direction: column;
gap: 8px;
margin-bottom: 16px;
}
.control-group:last-child {
margin-bottom: 0;
}
.control-group label {
font-size: 12px;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.5px;
opacity: 0.9;
}
.control-group input[type="range"] {
width: 100%;
height: 4px;
-webkit-appearance: none;
appearance: none;
background: rgba(255, 255, 255, 0.2);
border-radius: 2px;
outline: none;
cursor: pointer;
}
.control-group input[type="range"]::-webkit-slider-thumb {
-webkit-appearance: none;
appearance: none;
width: 14px;
height: 14px;
background: #4A9EFF;
border-radius: 50%;
cursor: pointer;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: background 0.2s;
}
.control-group input[type="range"]::-webkit-slider-thumb:hover {
background: #5BADFF;
}
.control-group input[type="range"]::-moz-range-thumb {
width: 14px;
height: 14px;
background: #4A9EFF;
border-radius: 50%;
cursor: pointer;
border: none;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
transition: background 0.2s;
}
.control-group input[type="range"]::-moz-range-thumb:hover {
background: #5BADFF;
}
.value {
font-size: 11px;
opacity: 0.7;
font-variant-numeric: tabular-nums;
}
</style>

View File

@@ -0,0 +1,279 @@
<template>
<div v-if="visible" class="bibliography-overlay" @click="handleOverlayClick">
<div class="bibliography-modal" @click.stop>
<!-- ============================================= -->
<!-- HEADER -->
<!-- ============================================= -->
<div class="bibliography-header">
<h2>Bibliography</h2>
<button class="close-btn" @click="$emit('close')" title="Close">×</button>
</div>
<!-- ============================================= -->
<!-- CONTENT -->
<!-- ============================================= -->
<div class="bibliography-content">
<div
v-for="(entry, key) in bibliography"
:key="key"
:class="['bibliography-entry', { highlighted: key === highlightedKey }]"
:id="`bib-${key}`"
>
<div class="citation-key">[{{ key }}]</div>
<div class="citation-text">
<template v-if="entry.type === 'article'">
<span class="authors">{{ formatAuthors(entry.author) }}</span>
({{ entry.year }}).
<br/>
"{{ entry.title }}."
<em>{{ entry.journal }}</em><template v-if="entry.volume">, {{ entry.volume }}</template><template v-if="entry.number">({{ entry.number }})</template><template v-if="entry.pages">: {{ entry.pages }}</template>.
</template>
<template v-else-if="entry.type === 'book'">
<span class="authors">{{ formatAuthors(entry.author) }}</span>
({{ entry.year }}).
<br/>
<em>{{ entry.title }}</em>.
<template v-if="entry.series">{{ entry.series }}. </template>
{{ entry.publisher }}.
</template>
<template v-else-if="entry.type === 'incollection'">
<span class="authors">{{ formatAuthors(entry.author) }}</span>
({{ entry.year }}).
<br/>
"{{ entry.title }}."
In <em>{{ entry.booktitle }}</em><template v-if="entry.editor">, edited by {{ formatAuthors(entry.editor) }}</template>.
{{ entry.publisher }}.
</template>
<template v-else-if="entry.type === 'misc'">
<span class="authors">{{ formatAuthors(entry.author) }}</span><template v-if="entry.year"> ({{ entry.year }})</template>.
<br/>
<template v-if="entry.url">
<a :href="entry.url" target="_blank" rel="noopener">{{ entry.title }}</a>
</template>
<template v-else>
<em>{{ entry.title }}</em>
</template>.
</template>
<div v-if="entry.note" class="citation-note">{{ entry.note }}</div>
</div>
</div>
</div>
</div>
</div>
</template>
<script setup>
import { watch, nextTick } from 'vue';
import { BIBLIOGRAPHY } from '../data/bibliography.js';
// ============================================================================
// INTERFACE
// ============================================================================
const props = defineProps({
visible: {
type: Boolean,
required: true
},
highlightedKey: {
type: String,
default: null
}
});
const emit = defineEmits(['close']);
// ============================================================================
// DATA
// ============================================================================
const bibliography = BIBLIOGRAPHY;
// ============================================================================
// METHODS
// ============================================================================
function formatAuthors(authors) {
if (!authors || authors.length === 0) return '';
if (authors.length === 1) {
return authors[0];
} else if (authors.length === 2) {
return `${authors[0]} and ${authors[1]}`;
} else {
return `${authors[0]} et al.`;
}
}
function handleOverlayClick() {
emit('close');
}
// ============================================================================
// LIFECYCLE
// ============================================================================
// Scroll to highlighted entry when it changes
watch(() => props.highlightedKey, (newKey) => {
if (newKey && props.visible) {
nextTick(() => {
const element = document.getElementById(`bib-${newKey}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
});
// Scroll to highlighted entry when modal opens
watch(() => props.visible, (newVisible) => {
if (newVisible && props.highlightedKey) {
nextTick(() => {
const element = document.getElementById(`bib-${props.highlightedKey}`);
if (element) {
element.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
});
}
});
</script>
<style scoped>
/* ============================================= */
/* OVERLAY */
/* ============================================= */
.bibliography-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.7);
display: flex;
align-items: center;
justify-content: center;
z-index: 2000;
padding: 20px;
}
/* ============================================= */
/* MODAL */
/* ============================================= */
.bibliography-modal {
background: white;
border-radius: 8px;
box-shadow: 0 4px 24px rgba(0, 0, 0, 0.3);
max-width: 900px;
width: 100%;
max-height: 80vh;
display: flex;
flex-direction: column;
}
/* ============================================= */
/* HEADER */
/* ============================================= */
.bibliography-header {
padding: 20px 24px;
border-bottom: 2px solid #eee;
display: flex;
align-items: center;
justify-content: space-between;
}
.bibliography-header h2 {
margin: 0;
font-size: 24px;
font-weight: 600;
color: #333;
}
.close-btn {
width: 32px;
height: 32px;
background: none;
border: none;
font-size: 28px;
line-height: 1;
cursor: pointer;
color: #999;
transition: color 0.2s;
padding: 0;
}
.close-btn:hover {
color: #333;
}
/* ============================================= */
/* CONTENT */
/* ============================================= */
.bibliography-content {
padding: 24px;
overflow-y: auto;
flex: 1;
}
.bibliography-entry {
display: flex;
gap: 16px;
margin-bottom: 4px;
padding: 12px;
border-radius: 4px;
transition: background-color 0.3s;
}
.bibliography-entry.highlighted {
background-color: #FFF9C4;
border-left: 4px solid #FBC02D;
padding-left: 8px;
}
.citation-key {
font-family: monospace;
font-size: 12px;
color: #666;
flex-shrink: 0;
min-width: 12rem;
}
.citation-text {
font-size: 14px;
line-height: 1.6;
color: #333;
}
.authors {
font-weight: 600;
}
.citation-note {
margin-top: 8px;
font-size: 13px;
color: #666;
font-style: italic;
padding-left: 16px;
border-left: 2px solid #ddd;
}
.citation-text a {
color: #4A9EFF;
text-decoration: none;
}
.citation-text a:hover {
text-decoration: underline;
}
.citation-text em {
font-style: italic;
}
</style>

View File

@@ -0,0 +1,372 @@
<template>
<div
v-if="visible"
class="context-menu"
:style="{ left: x + 'px', top: y + 'px' }"
>
<!-- ============================================= -->
<!-- HEADER - COORDINATES -->
<!-- ============================================= -->
<div class="context-menu-header">
{{ formatCoordinate(lngLat.lng, 'lng') }}, {{ formatCoordinate(lngLat.lat, 'lat') }}
<span v-if="tileMetadata?.id" class="tile-name">{{ tileMetadata.id }}</span>
<span v-if="metadataError" class="tile-error"> Lookup failed</span>
<span v-if="processingStatus" class="tile-status">{{ processingStatus }}</span>
</div>
<!-- ============================================= -->
<!-- MENU ITEMS -->
<!-- ============================================= -->
<!-- Always available -->
<button @click="handleDropPin" class="context-menu-item">📍 Drop Pin</button>
<button @click="handleStartMeasure" class="context-menu-item">📏 Measure from here</button>
<!-- State A: No tile exists (or error checking) -->
<button
v-if="!tileMetadata && !metadataError && !requestingTile"
@click="handleRequestTile"
class="context-menu-item"
>
📥 Request Tile
</button>
<!-- Show status while requesting -->
<div v-if="requestingTile" class="context-menu-status">
{{ processingStatus || 'Requesting tile...' }}
</div>
<!-- State B: Tile exists but images not loaded -->
<button
v-if="tileMetadata && !imagesOnMap && hasAvailableImages"
@click="handleLoadImages"
class="context-menu-item"
:disabled="loadingImages"
>
{{ loadingImages ? '⏳ Loading...' : '📦 Load Tile Images' }}
</button>
<!-- State C: Images loaded, but mound not loaded -->
<button
v-if="tileMetadata && !hasMoundData"
@click="handleLoadMound"
class="context-menu-item"
:disabled="loadingMound"
>
{{ loadingMound ? '⏳ Loading...' : '🔬 Load Interactive Data' }}
</button>
<!-- State D: Mound loaded, ready to open sandbox -->
<button
v-if="hasMoundData"
@click="handleOpenSandbox"
class="context-menu-item"
>
🔬 Open in Shading Sandbox
</button>
</div>
</template>
<script setup>
import { ref, computed, watch } from 'vue';
import { formatCoordinate } from '../utils/coordinates.js';
import { useTilesStore } from '../stores/tiles.js';
// ============================================================================
// PROPS & EMITS
// ============================================================================
const props = defineProps({
parseMoundBuffer: {
type: Function,
required: true
}
});
const emit = defineEmits([
'dropPin',
'startMeasure',
'addImagesToMap', // Images loaded, ready to add to map
'openSandbox' // Mound loaded, ready to open sandbox
]);
// ============================================================================
// STORE & STATE
// ============================================================================
const tilesStore = useTilesStore();
// Own state
const visible = ref(false);
const x = ref(0);
const y = ref(0);
const lngLat = ref({ lng: 0, lat: 0 });
// Loading/processing states
const loadingImages = ref(false);
const loadingMound = ref(false);
const metadataError = ref(false);
const requestingTile = ref(false);
const processingStatus = ref(null);
// ============================================================================
// COMPUTED - TILE STATE FROM STORE
// ============================================================================
const tileId = computed(() => {
return tilesStore.findTileByCoords(lngLat.value.lat, lngLat.value.lng);
});
const tileMetadata = computed(() => {
return tileId.value ? tilesStore.getMetadata(tileId.value) : null;
});
const imagesOnMap = computed(() => {
return tileId.value ? tilesStore.areImagesOnMap(tileId.value) : false;
});
const hasMoundData = computed(() => {
return tileId.value ? tilesStore.hasMoundData(tileId.value) : false;
});
const hasAvailableImages = computed(() => {
if (!tileMetadata.value) return false;
return tileMetadata.value.jpg_available || tileMetadata.value.png_available;
});
// ============================================================================
// WATCHERS - FETCH METADATA WHEN MENU OPENS
// ============================================================================
watch(visible, async (isVisible) => {
if (isVisible && !tileId.value) {
await fetchMetadata();
}
});
// ============================================================================
// EXPOSED METHODS
// ============================================================================
function show(mouseX, mouseY, coordinates) {
x.value = mouseX;
y.value = mouseY;
lngLat.value = coordinates;
visible.value = true;
// Reset states
metadataError.value = false;
requestingTile.value = false;
processingStatus.value = null;
}
function hide() {
visible.value = false;
}
defineExpose({ show, hide });
// ============================================================================
// ACTIONS - METADATA
// ============================================================================
async function fetchMetadata() {
metadataError.value = false;
try {
await tilesStore.fetchMetadataByCoords(lngLat.value.lat, lngLat.value.lng);
} catch (err) {
console.error('Failed to fetch tile metadata:', err);
metadataError.value = true;
}
}
// ============================================================================
// ACTIONS - TILE OPERATIONS
// ============================================================================
async function handleRequestTile() {
requestingTile.value = true;
processingStatus.value = 'Looking up tile...';
const eventSource = tilesStore.requestTileProcessing(
lngLat.value.lat,
lngLat.value.lng,
async (data) => {
processingStatus.value = data.message || data.status;
// On ready: auto-load mound and open sandbox
if (data.status === 'ready' && data.tile_id) {
try {
await tilesStore.fetchMoundData(data.tile_id, props.parseMoundBuffer);
emit('openSandbox', data.tile_id);
// Close connection and hide menu
eventSource.close();
setTimeout(() => hide(), 500);
} catch (err) {
console.error('Failed to load mound after processing:', err);
processingStatus.value = 'Error loading data';
requestingTile.value = false;
}
}
if (data.status === 'error') {
requestingTile.value = false;
setTimeout(() => {
if (visible.value) {
processingStatus.value = null;
}
}, 5000);
}
},
(error) => {
console.error('Tile processing error:', error);
processingStatus.value = 'Connection failed';
requestingTile.value = false;
setTimeout(() => {
if (visible.value) {
processingStatus.value = null;
}
}, 5000);
}
);
}
async function handleLoadImages() {
if (!tileId.value || loadingImages.value) return;
loadingImages.value = true;
try {
emit('addImagesToMap', tileId.value);
} finally {
loadingImages.value = false;
}
}
async function handleLoadMound() {
if (!tileId.value || loadingMound.value) return;
loadingMound.value = true;
try {
await tilesStore.fetchMoundData(tileId.value, props.parseMoundBuffer);
} catch (err) {
console.error('Failed to load mound data:', err);
} finally {
loadingMound.value = false;
}
}
function handleOpenSandbox() {
if (!tileId.value) return;
emit('openSandbox', tileId.value);
hide();
}
function handleDropPin() {
emit('dropPin', lngLat.value);
hide();
}
function handleStartMeasure() {
emit('startMeasure', lngLat.value);
hide();
}
</script>
<style scoped>
/* ============================================= */
/* CONTEXT MENU CONTAINER */
/* ============================================= */
.context-menu {
position: absolute;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 1000;
min-width: 200px;
overflow: hidden;
}
/* ============================================= */
/* HEADER */
/* ============================================= */
.context-menu-header {
padding: 10px 12px;
background: #f5f5f5;
font-size: 12px;
font-family: monospace;
border-bottom: 1px solid #ddd;
color: #666;
}
.context-menu-header .tile-name {
display: block;
margin-top: 4px;
font-size: 11px;
color: #999;
}
.context-menu-header .tile-error {
display: block;
margin-top: 4px;
font-size: 11px;
color: #f44336;
font-weight: 600;
}
.context-menu-header .tile-status {
display: block;
margin-top: 4px;
font-size: 11px;
color: #2196F3;
font-weight: 500;
}
/* ============================================= */
/* STATUS DISPLAY */
/* ============================================= */
.context-menu-status {
padding: 10px 12px;
font-size: 13px;
color: #666;
background: #f9f9f9;
border-top: 1px solid #eee;
}
/* ============================================= */
/* MENU ITEMS */
/* ============================================= */
.context-menu-item {
display: block;
width: 100%;
padding: 10px 12px;
background: white;
border: none;
text-align: left;
font-size: 14px;
cursor: pointer;
transition: background 0.2s;
}
.context-menu-item:hover {
background: #f0f0f0;
}
.context-menu-item:disabled {
cursor: not-allowed;
opacity: 0.6;
background: #f9f9f9;
}
.context-menu-item:disabled:hover {
background: #f9f9f9;
}
</style>

View File

@@ -0,0 +1,230 @@
<template>
<div
v-if="visible"
class="popup"
:style="{ left: x + 'px', top: y + 'px' }"
>
<!-- ============================================= -->
<!-- POPUP CONTENT -->
<!-- ============================================= -->
<div class="popup-content">
<!-- PIN -->
<div v-if="type === 'pin'">
<strong>Pin #{{ feature.properties.number }}</strong>
<div>{{ formatCoordinate(feature.geometry.coordinates[0], 'lng') }}, {{ formatCoordinate(feature.geometry.coordinates[1], 'lat') }}</div>
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
</div>
<!-- LINE or RAY -->
<div v-else-if="type === 'line' || type === 'ray'">
<strong>{{ type === 'line' ? 'Line' : 'Ray' }}</strong>
<div>Length: {{ formatDistance(feature.properties.length, imperialUnits) }}</div>
<div>Bearing: {{ formatBearing(feature.properties.bearing) }}</div>
<button @click="$emit('delete', feature.id)" class="popup-btn danger">Delete</button>
</div>
<!-- HISTORIC SITE -->
<div v-else-if="type === 'site'" class="site-popup">
<strong>{{ feature.properties.name }}</strong>
<div class="site-type">{{ feature.properties.type === 'road_confirmed' ? 'Confirmed Road Segment' : 'Earthwork' }}</div>
<div class="site-description">
<template v-for="(segment, idx) in parsedDescription" :key="idx">
<template v-if="segment.type === 'text'">{{ segment.content }}</template>
<a
v-else-if="segment.type === 'citation'"
href="#"
@click.prevent="$emit('showBibliography', segment.key)"
class="citation-link"
:title="`View ${segment.key} in bibliography`"
>[{{ segment.key }}]</a>
</template>
</div>
</div>
</div>
<!-- ============================================= -->
<!-- CLOSE BUTTON -->
<!-- ============================================= -->
<button @click="$emit('close')" class="popup-close">×</button>
</div>
</template>
<script setup>
import { computed } from 'vue';
import { formatCoordinate, formatDistance, formatBearing } from '../utils/coordinates.js';
import { parseCitations } from '../utils/citations.js';
// ============================================================================
// INTERFACE
// ============================================================================
const props = defineProps({
visible: {
type: Boolean,
required: true
},
x: {
type: Number,
required: true
},
y: {
type: Number,
required: true
},
type: {
type: String, // 'pin', 'line', 'ray', 'site'
default: null
},
feature: {
type: Object,
default: null
},
imperialUnits: {
type: Boolean,
default: false
}
});
const emit = defineEmits(['close', 'delete', 'showBibliography']);
// ============================================================================
// COMPUTED
// ============================================================================
const parsedDescription = computed(() => {
if (props.type === 'site' && props.feature?.properties?.description) {
return parseCitations(props.feature.properties.description);
}
return [];
});
</script>
<style scoped>
/* ============================================= */
/* POPUP CONTAINER */
/* ============================================= */
.popup {
position: absolute;
background: white;
border-radius: 4px;
box-shadow: 0 2px 12px rgba(0,0,0,0.4);
z-index: 1000;
min-width: 180px;
max-width: 350px;
overflow: hidden;
}
/* ============================================= */
/* POPUP CONTENT */
/* ============================================= */
.popup-content {
padding: 12px;
font-size: 14px;
}
.popup-content strong {
display: block;
margin-bottom: 8px;
color: #333;
}
.popup-content div {
margin: 4px 0;
font-size: 13px;
color: #666;
}
/* ============================================= */
/* SITE-SPECIFIC STYLING */
/* ============================================= */
.site-popup {
max-width: 320px;
}
.site-type {
font-size: 11px !important;
text-transform: uppercase;
letter-spacing: 0.5px;
color: #999 !important;
margin-bottom: 8px !important;
}
.site-description {
line-height: 1.5;
color: #333 !important;
font-size: 13px !important;
}
.citation-link {
color: #4A9EFF;
text-decoration: none;
font-family: monospace;
font-size: 11px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.citation-link:hover {
color: #2E8FE3;
text-decoration: underline;
}
/* ============================================= */
/* POPUP BUTTONS */
/* ============================================= */
.popup-btn {
display: block;
width: 100%;
padding: 8px;
margin-top: 10px;
background: white;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.popup-btn:hover {
background: #f5f5f5;
}
.popup-btn.danger {
color: #d32f2f;
border-color: #d32f2f;
}
.popup-btn.danger:hover {
background: #ffebee;
}
/* ============================================= */
/* CLOSE BUTTON */
/* ============================================= */
.popup-close {
position: absolute;
top: 4px;
right: 4px;
width: 24px;
height: 24px;
background: none;
border: none;
font-size: 20px;
line-height: 1;
cursor: pointer;
color: #999;
transition: color 0.2s;
}
.popup-close:hover {
color: #333;
}
</style>

View File

@@ -0,0 +1,108 @@
<template>
<div v-if="showGeometry" class="geometry-toolbar">
<!-- ============================================= -->
<!-- DRAWING TOOLS -->
<!-- ============================================= -->
<button
:class="['tool-btn', { active: drawMode === 'line' }]"
@click="$emit('setDrawMode', 'line')"
title="Draw Line"
>
📏 Line
</button>
<button
:class="['tool-btn', { active: drawMode === 'ray' }]"
@click="$emit('setDrawMode', 'ray')"
title="Draw Ray"
>
Ray
</button>
<!-- ============================================= -->
<!-- CLEAR BUTTON -->
<!-- ============================================= -->
<button
class="tool-btn danger"
@click="$emit('clearAll')"
title="Clear All Geometry"
>
🗑 Clear
</button>
</div>
</template>
<script setup>
// ============================================================================
// INTERFACE
// ============================================================================
defineProps({
drawMode: {
type: String, // 'line', 'ray', or null
default: null
},
showGeometry: {
type: Boolean,
default: true
}
});
defineEmits(['setDrawMode', 'clearAll']);
</script>
<style scoped>
/* ============================================= */
/* TOOLBAR CONTAINER */
/* ============================================= */
.geometry-toolbar {
position: absolute;
top: 20px;
right: 20px;
background: white;
padding: 10px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
display: flex;
flex-direction: column;
gap: 8px;
z-index: 100;
}
/* ============================================= */
/* TOOL BUTTONS */
/* ============================================= */
.tool-btn {
padding: 10px 15px;
background: white;
border: 2px solid #ddd;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
white-space: nowrap;
}
.tool-btn:hover {
background: #f5f5f5;
border-color: #999;
}
.tool-btn.active {
background: #4A9EFF;
color: white;
border-color: #4A9EFF;
}
.tool-btn.danger {
color: #d32f2f;
}
.tool-btn.danger:hover {
background: #ffebee;
border-color: #d32f2f;
}
</style>

View File

@@ -0,0 +1,356 @@
<template>
<div class="layer-controls">
<!-- ============================================= -->
<!-- BASE MAP SELECTION -->
<!-- ============================================= -->
<div class="control-section">
<label>Base Map:</label>
<label>
<input
type="radio"
value="osm"
:checked="baseLayer === 'osm'"
@change="$emit('update:baseLayer', 'osm')"
>
Street
</label>
<label>
<input
type="radio"
value="satellite"
:checked="baseLayer === 'satellite'"
@change="$emit('update:baseLayer', 'satellite')"
>
Satellite
</label>
</div>
<!-- ============================================= -->
<!-- HISTORIC MARKERS -->
<!-- ============================================= -->
<div class="control-section">
<label
class="section-header"
@click="$emit('update:historicMarkersExpanded', !historicMarkersExpanded)"
style="cursor: pointer;"
>
{{ historicMarkersExpanded ? '▼' : '▶' }} Historic Markers
</label>
<div v-if="historicMarkersExpanded" class="subsection">
<button class="hide-all-btn" @click="$emit('toggleAllSites')">
{{ allHistoricMarkersHidden ? 'Show All' : 'Hide All' }}
</button>
<div v-for="site in sites" :key="site.name" class="site-control">
<label>
<input
type="checkbox"
:checked="visibleSites[site.name]"
@change="$emit('toggleSite', site.name)"
>
{{ site.name }}
</label>
<button
class="jump-btn"
@click="$emit('jumpToSite', site)"
title="Jump to location"
>
📍
</button>
</div>
</div>
</div>
<!-- ============================================= -->
<!-- LIDAR CONTROLS -->
<!-- ============================================= -->
<div class="control-section">
<label>
<input
type="checkbox"
:checked="showLidar"
@change="$emit('update:showLidar', !showLidar)"
>
Show Lidar
</label>
<div v-if="showLidar" class="slider-control">
<label>Opacity: {{ Math.round(lidarOpacity) }}%</label>
<input
type="range"
min="0"
max="100"
:value="lidarOpacity"
@input="$emit('update:lidarOpacity', $event.target.value)"
class="opacity-slider"
>
</div>
</div>
<!-- ============================================= -->
<!-- GEOMETRY & UNITS -->
<!-- ============================================= -->
<div class="control-section">
<label>
<input
type="checkbox"
:checked="showGeometry"
@change="$emit('update:showGeometry', !showGeometry)"
>
Show Geometry
</label>
<label>
<input
type="checkbox"
:checked="imperialUnits"
@change="$emit('update:imperialUnits', !imperialUnits)"
>
Imperial Units
</label>
</div>
<!-- ============================================= -->
<!-- SANDBOX BUTTON -->
<!-- ============================================= -->
<div class="control-section">
<button class="sandbox-btn" @click="$emit('openSandbox')">
Open Shading Sandbox
</button>
</div>
<!-- ============================================= -->
<!-- TILE REQUEST NOTIFICATIONS -->
<!-- ============================================= -->
<TileRequestNotification
:requests="tileRequests"
@dismiss="(id) => $emit('dismissRequest', id)"
/>
</div>
</template>
<script setup>
import { KNOWN_SITES } from '../data/historicSites.js';
import TileRequestNotification from './TileRequestNotification.vue';
// ============================================================================
// INTERFACE
// ============================================================================
defineProps({
baseLayer: {
type: String,
default: 'osm'
},
historicMarkersExpanded: {
type: Boolean,
default: true
},
visibleSites: {
type: Object,
required: true
},
allHistoricMarkersHidden: {
type: Boolean,
default: false
},
showLidar: {
type: Boolean,
default: true
},
lidarOpacity: {
type: Number,
default: 80
},
showGeometry: {
type: Boolean,
default: true
},
imperialUnits: {
type: Boolean,
default: false
},
tileRequests: {
type: Object,
default: () => ({})
}
});
defineEmits([
'update:baseLayer',
'update:historicMarkersExpanded',
'update:showLidar',
'update:lidarOpacity',
'update:showGeometry',
'update:imperialUnits',
'toggleSite',
'jumpToSite',
'toggleAllSites',
'openSandbox',
'dismissRequest'
]);
// ============================================================================
// DATA
// ============================================================================
const sites = KNOWN_SITES;
</script>
<style scoped>
/* ============================================= */
/* CONTROLS CONTAINER */
/* ============================================= */
.layer-controls {
position: absolute;
top: 20px;
left: 20px;
background: white;
padding: 15px;
border-radius: 4px;
box-shadow: 0 2px 8px rgba(0,0,0,0.3);
z-index: 100;
max-width: 250px;
font-size: 14px;
}
/* ============================================= */
/* CONTROL SECTIONS */
/* ============================================= */
.control-section {
margin-bottom: 10px;
}
.control-section:last-child {
margin-bottom: 0;
}
.control-section label {
display: block;
margin: 5px 0;
cursor: pointer;
}
.control-section label:first-child {
font-weight: bold;
margin-bottom: 8px;
cursor: default;
}
.control-section .section-header {
font-weight: bold;
margin-bottom: 8px;
user-select: none;
}
.control-section input[type="radio"],
.control-section input[type="checkbox"] {
margin-right: 8px;
cursor: pointer;
}
/* ============================================= */
/* SUBSECTIONS (Historic Markers) */
/* ============================================= */
.subsection {
margin-left: 10px;
padding-left: 10px;
border-left: 2px solid #ddd;
}
.hide-all-btn {
width: 100%;
padding: 6px 10px;
margin: 8px 0;
background: #f5f5f5;
border: 1px solid #ddd;
border-radius: 3px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.hide-all-btn:hover {
background: #e8e8e8;
border-color: #999;
}
.site-control {
display: flex;
align-items: center;
justify-content: space-between;
margin: 4px 0;
}
.site-control label {
flex: 1;
margin: 0 !important;
font-weight: normal !important;
}
.jump-btn {
padding: 4px 8px;
background: #4A9EFF;
color: white;
border: none;
border-radius: 3px;
font-size: 12px;
cursor: pointer;
transition: all 0.2s;
margin-left: 8px;
}
.jump-btn:hover {
background: #2E8FE3;
}
.jump-btn:active {
transform: scale(0.95);
}
/* ============================================= */
/* SLIDER CONTROLS */
/* ============================================= */
.slider-control {
margin-top: 10px;
margin-left: 20px;
}
.slider-control label {
font-weight: normal !important;
margin-bottom: 5px !important;
}
.opacity-slider {
width: 100%;
cursor: pointer;
}
/* ============================================= */
/* SANDBOX BUTTON */
/* ============================================= */
.sandbox-btn {
width: 100%;
padding: 10px;
margin-top: 10px;
background: #4A9EFF;
color: white;
border: none;
border-radius: 4px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s;
}
.sandbox-btn:hover {
background: #2E8FE3;
}
.sandbox-btn:active {
transform: scale(0.98);
}
</style>

View File

@@ -162,6 +162,7 @@ const tileLoaded = ref(false);
const isRendering = ref(false);
const lastRenderedImage = ref(null);
const selectedQuality = ref(1024);
const tileId = ref(null);
// Settings
const settings = reactive({
@@ -221,7 +222,7 @@ const initThreeJS = () => {
});
const width = canvasRef.value.clientWidth || 800;
const height = canvasRef.value.clientHeight || 600;
const height = canvasRef.value.clientHeight || 800;
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
@@ -239,6 +240,7 @@ const initThreeJS = () => {
// Animation loop control
const startAnimation = () => {
console.log('starting animation')
if (animationId) return; // Already running
const animate = () => {
@@ -256,6 +258,7 @@ const startAnimation = () => {
};
const pauseAnimation = () => {
console.log('pausing animation')
animationPaused = true;
if (animationId) {
cancelAnimationFrame(animationId);
@@ -264,12 +267,14 @@ const pauseAnimation = () => {
};
const resumeAnimation = () => {
console.log('resuming animation')
animationPaused = false;
startAnimation();
};
// Render a single frame (for when animation is paused but we need to update the view)
const renderSingleFrame = () => {
console.log('Rendering single frame')
if (renderer && scene && camera) {
renderer.render(scene, camera);
}
@@ -280,20 +285,21 @@ const handleResize = () => {
if (!canvasRef.value || !renderer || !camera) return;
const width = canvasRef.value.clientWidth || 800;
const height = canvasRef.value.clientHeight || 600;
const height = canvasRef.value.clientHeight || 800;
renderer.setSize(width, height);
renderer.setPixelRatio(window.devicePixelRatio);
// Use exact normalized terrain bounds (no padding = no black bars)
// Use exact tile boundaries (guaranteed square)
if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = geometryCache.normalizedSpanX / 2;
camera.top = geometryCache.normalizedSpanY / 2;
camera.bottom = -geometryCache.normalizedSpanY / 2;
const halfSpan = geometryCache.tileSpan / 2;
camera.left = -halfSpan;
camera.right = halfSpan;
camera.top = halfSpan;
camera.bottom = -halfSpan;
} else {
// Fallback: square frustum if no tile loaded yet
const viewSize = 6;
const viewSize = 500; // Reasonable default in world units
camera.left = -viewSize;
camera.right = viewSize;
camera.top = viewSize;
@@ -304,11 +310,12 @@ const handleResize = () => {
};
// Load tile data
const loadTileData = (tileData) => {
const loadTileData = (tileData, newTileId) => {
if (!scene) {
console.error('Three.js not initialized');
return false;
}
tileId.value = newTileId;
// Remove old mesh
if (mesh) {
@@ -319,41 +326,30 @@ const loadTileData = (tileData) => {
}
try {
// Calculate bounds center
const centerX = (tileData.bounds.minX + tileData.bounds.maxX) / 2;
const centerY = (tileData.bounds.minY + tileData.bounds.maxY) / 2;
const centerZ = (tileData.bounds.minZ + tileData.bounds.maxZ) / 2;
// Tile bounds (guaranteed square, defines camera frustum)
const tileBounds = tileData.tileBounds || tileData.bounds;
const tileCenterX = (tileBounds.minX + tileBounds.maxX) / 2;
const tileCenterY = (tileBounds.minY + tileBounds.maxY) / 2;
const tileSpan = tileBounds.maxX - tileBounds.minX; // Should equal maxY - minY (square)
// Calculate spans
const spanX = tileData.bounds.maxX - tileData.bounds.minX;
const spanY = tileData.bounds.maxY - tileData.bounds.minY;
const spanZ = tileData.bounds.maxZ - tileData.bounds.minZ;
const maxSpan = Math.max(spanX, spanY);
// Mesh bounds (actual point data extent, for Z normalization)
const meshBounds = tileData.bounds;
const meshCenterZ = (meshBounds.minZ + meshBounds.maxZ) / 2;
const meshSpanZ = meshBounds.maxZ - meshBounds.minZ;
// Calculate actual aspect ratio of the tile
const tileAspect = spanX / spanY;
// Normalize XY to fit in view, maintaining actual aspect ratio
const normalizeScale = 10 / maxSpan;
// Z scaling: make Z variation visible but proportional
const zScale = normalizeScale * (maxSpan * 0.1) / spanZ;
console.log('Tile scaling:', {
spanX: spanX.toFixed(2),
spanY: spanY.toFixed(2),
spanZ: spanZ.toFixed(2),
tileAspect: tileAspect.toFixed(3),
normalizeScale: normalizeScale.toFixed(4),
zScale: zScale.toFixed(4)
});
// Z normalization: scale Z to be perceptible relative to tile span
// Use 10% of tile span as base Z scale (adjust this factor as needed for aesthetics)
const zNormalizationFactor = (tileSpan * 0.1) / meshSpanZ;
// Transform positions
const transformedPositions = new Float32Array(tileData.positions.length);
for (let i = 0; i < tileData.positions.length; i += 3) {
transformedPositions[i] = (tileData.positions[i] - centerX) * normalizeScale;
transformedPositions[i + 1] = (tileData.positions[i + 1] - centerY) * normalizeScale;
transformedPositions[i + 2] = (tileData.positions[i + 2] - centerZ) * zScale;
// X/Y: keep world coordinates, just centered on tile center
transformedPositions[i] = tileData.positions[i] - tileCenterX;
transformedPositions[i + 1] = tileData.positions[i + 1] - tileCenterY;
// Z: normalized relative to tile span, then will be height-exaggerated later
transformedPositions[i + 2] = (tileData.positions[i + 2] - meshCenterZ) * zNormalizationFactor;
}
// Create geometry
@@ -370,20 +366,14 @@ const loadTileData = (tileData) => {
baseZ[i + 2] = transformedPositions[i + 2]; // Only Z values
}
// Calculate exact bounds of normalized terrain (no padding = no black bars)
const normalizedSpanX = spanX * normalizeScale;
const normalizedSpanY = spanY * normalizeScale;
geometryCache = {
geometry,
baseZ,
spanZ,
zScale,
tileAspect,
normalizedSpanX,
normalizedSpanY,
// Store original Web Mercator bounds for MapLibre positioning
originalBounds: { ...tileData.bounds }
tileBounds: tileBounds,
tileSpan: tileSpan,
zNormalizationFactor: zNormalizationFactor,
// Store original bounds for reference
originalBounds: { ...meshBounds }
};
// Create material and mesh
@@ -395,14 +385,15 @@ const loadTileData = (tileData) => {
mesh = new THREE.Mesh(geometry, material);
scene.add(mesh);
// Configure camera frustum to match tile aspect ratio
camera.left = -normalizedSpanX / 2;
camera.right = normalizedSpanX / 2;
camera.top = normalizedSpanY / 2;
camera.bottom = -normalizedSpanY / 2;
// Configure camera frustum to match tile boundaries (guaranteed square)
const halfSpan = tileSpan / 2;
camera.left = -halfSpan;
camera.right = halfSpan;
camera.top = halfSpan;
camera.bottom = -halfSpan;
// Adjust near/far to accommodate height exaggeration
const maxZExtent = (spanZ * zScale / 2) * 20; // Max exaggeration
const maxZExtent = (tileSpan * 0.1) * 20; // Max exaggeration (0.1 factor * 20x scale)
camera.near = 0.1;
camera.far = 100 + maxZExtent * 2;
camera.updateProjectionMatrix();
@@ -494,28 +485,21 @@ const renderTile = async () => {
const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio();
// Calculate render dimensions based on tile aspect ratio
let renderWidth = size;
let renderHeight = size;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(size / tileAspect);
} else {
renderWidth = Math.round(size * tileAspect);
}
}
// Tiles are guaranteed square, so render dimensions are always square
const renderWidth = size;
const renderHeight = size;
// Set render size
renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1); // No pixel ratio multiplier for export
// Update camera to match exact tile bounds (no black bars)
// Update camera to match exact tile bounds (guaranteed square)
if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = geometryCache.normalizedSpanX / 2;
camera.top = geometryCache.normalizedSpanY / 2;
camera.bottom = -geometryCache.normalizedSpanY / 2;
const halfSpan = geometryCache.tileSpan / 2;
camera.left = -halfSpan;
camera.right = halfSpan;
camera.top = halfSpan;
camera.bottom = -halfSpan;
}
camera.updateProjectionMatrix();
@@ -539,6 +523,7 @@ const renderTile = async () => {
renderStats.value.lastRenderTime = renderTime;
emit('renderComplete', {
tileId: tileId.value,
dataURL,
settings: { ...settings },
size,
@@ -573,7 +558,7 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
Object.assign(settings, renderSettings);
// Load tile
const loaded = loadTileData(tileData);
const loaded = loadTileData(tileData, null);
if (!loaded) {
return { success: false, error: 'Failed to load tile' };
}
@@ -590,28 +575,21 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
const originalHeight = renderer.domElement.height;
const originalPixelRatio = renderer.getPixelRatio();
// Calculate render dimensions based on tile aspect ratio
let renderWidth = resolution;
let renderHeight = resolution;
if (geometryCache) {
const tileAspect = geometryCache.normalizedSpanX / geometryCache.normalizedSpanY;
if (tileAspect > 1) {
renderHeight = Math.round(resolution / tileAspect);
} else {
renderWidth = Math.round(resolution * tileAspect);
}
}
// Tiles are guaranteed square, so render dimensions are always square
const renderWidth = resolution;
const renderHeight = resolution;
// Set render size
renderer.setSize(renderWidth, renderHeight);
renderer.setPixelRatio(1);
// Update camera to match exact tile bounds (no black bars)
// Update camera to match exact tile bounds (guaranteed square)
if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = geometryCache.normalizedSpanX / 2;
camera.top = geometryCache.normalizedSpanY / 2;
camera.bottom = -geometryCache.normalizedSpanY / 2;
const halfSpan = geometryCache.tileSpan / 2;
camera.left = -halfSpan;
camera.right = halfSpan;
camera.top = halfSpan;
camera.bottom = -halfSpan;
}
camera.updateProjectionMatrix();
@@ -625,12 +603,13 @@ const renderTileWithSettings = async (tileData, renderSettings, resolution = 102
renderer.setSize(originalWidth / originalPixelRatio, originalHeight / originalPixelRatio);
renderer.setPixelRatio(originalPixelRatio);
// Restore camera with exact normalized bounds
// Restore camera with exact tile bounds
if (geometryCache) {
camera.left = -geometryCache.normalizedSpanX / 2;
camera.right = geometryCache.normalizedSpanX / 2;
camera.top = geometryCache.normalizedSpanY / 2;
camera.bottom = -geometryCache.normalizedSpanY / 2;
const halfSpan = geometryCache.tileSpan / 2;
camera.left = -halfSpan;
camera.right = halfSpan;
camera.top = halfSpan;
camera.bottom = -halfSpan;
}
camera.updateProjectionMatrix();
@@ -669,18 +648,45 @@ const downloadLastRender = () => {
// Cleanup
const cleanup = () => {
// Stop animation
pauseAnimation();
// Dispose resize observer
if (resizeObserver) {
resizeObserver.disconnect();
resizeObserver = null;
}
if (renderer) {
renderer.dispose();
}
// Dispose mesh
if (mesh) {
scene?.remove(mesh);
if (mesh.geometry) mesh.geometry.dispose();
if (mesh.material) mesh.material.dispose();
mesh = null;
}
// Dispose renderer
if (renderer) {
renderer.dispose();
renderer = null;
}
// Clear scene
if (scene) {
scene.clear();
scene = null;
}
// Clear other Three.js objects
camera = null;
directionalLight = null;
ambientLight = null;
// Clear geometry cache
geometryCache = null;
// Reset tile state
tileLoaded.value = false;
};
// Lifecycle
@@ -708,6 +714,7 @@ onMounted(() => {
// Watch for visibility changes
watch(() => props.visible, (newVal) => {
if (newVal && !renderer) {
// Coming visible and no renderer exists initialize
nextTick(() => {
initThreeJS();
@@ -725,11 +732,11 @@ watch(() => props.visible, (newVal) => {
}
});
} else if (newVal && !props.offscreen) {
// Component already initialized, just resume animation
// Component already initialized, becoming on-screen just resume animation
resumeAnimation();
} else if (!newVal) {
// Hidden - pause animation
pauseAnimation();
// Becoming invisible full cleanup
cleanup();
}
});

View File

@@ -0,0 +1,224 @@
<template>
<div v-if="activeRequests.length > 0" class="tile-notifications">
<!-- ============================================= -->
<!-- HEADER -->
<!-- ============================================= -->
<div class="notifications-header">
Tile Requests
</div>
<!-- ============================================= -->
<!-- ACTIVE REQUESTS -->
<!-- ============================================= -->
<div
v-for="request in activeRequests"
:key="request.id"
:class="['notification-item', `status-${request.status}`]"
>
<div class="notification-content">
<div class="notification-location">
{{ formatLocation(request.lat, request.lng) }}
</div>
<div class="notification-status">
<span class="status-icon">{{ getStatusIcon(request.status) }}</span>
<span class="status-text">{{ getStatusText(request.status) }}</span>
</div>
<div v-if="request.message" class="notification-message">
{{ request.message }}
</div>
</div>
<!-- Close button for completed/failed requests -->
<button
v-if="request.status === 'ready' || request.status === 'error'"
@click="$emit('dismiss', request.id)"
class="dismiss-btn"
title="Dismiss"
>
×
</button>
</div>
</div>
</template>
<script setup>
import { computed } from 'vue';
// ============================================================================
// INTERFACE
// ============================================================================
const props = defineProps({
requests: {
type: Object, // { requestId: { lat, lng, status, message, tileId } }
required: true
}
});
defineEmits(['dismiss']);
// ============================================================================
// COMPUTED
// ============================================================================
const activeRequests = computed(() => {
return Object.entries(props.requests).map(([id, data]) => ({
id,
...data
}));
});
// ============================================================================
// METHODS
// ============================================================================
function formatLocation(lat, lng) {
return `${lat.toFixed(4)}, ${lng.toFixed(4)}`;
}
function getStatusIcon(status) {
switch (status) {
case 'looking_up': return '🔍';
case 'found': return '✓';
case 'processing': return '⚙️';
case 'ready': return '✅';
case 'error': return '❌';
default: return '•';
}
}
function getStatusText(status) {
switch (status) {
case 'looking_up': return 'Finding tile...';
case 'found': return 'Tile found';
case 'processing': return 'Processing...';
case 'ready': return 'Ready!';
case 'error': return 'Failed';
default: return status;
}
}
</script>
<style scoped>
/* ============================================= */
/* NOTIFICATIONS CONTAINER */
/* ============================================= */
.tile-notifications {
margin-top: 15px;
padding-top: 15px;
border-top: 2px solid #ddd;
}
/* ============================================= */
/* HEADER */
/* ============================================= */
.notifications-header {
font-weight: 600;
font-size: 13px;
color: #666;
margin-bottom: 10px;
text-transform: uppercase;
letter-spacing: 0.5px;
}
/* ============================================= */
/* NOTIFICATION ITEMS */
/* ============================================= */
.notification-item {
background: #f9f9f9;
border-radius: 4px;
padding: 10px;
margin-bottom: 8px;
border-left: 3px solid #ccc;
position: relative;
transition: all 0.3s;
}
.notification-item.status-looking_up {
border-left-color: #4A9EFF;
background: #f0f7ff;
}
.notification-item.status-found {
border-left-color: #4CAF50;
background: #f1f8f4;
}
.notification-item.status-processing {
border-left-color: #FF9800;
background: #fff8f0;
}
.notification-item.status-ready {
border-left-color: #4CAF50;
background: #f1f8f4;
}
.notification-item.status-error {
border-left-color: #f44336;
background: #fff0f0;
}
/* ============================================= */
/* NOTIFICATION CONTENT */
/* ============================================= */
.notification-content {
padding-right: 24px; /* Space for dismiss button */
}
.notification-location {
font-family: monospace;
font-size: 11px;
color: #999;
margin-bottom: 4px;
}
.notification-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 13px;
font-weight: 600;
color: #333;
margin-bottom: 2px;
}
.status-icon {
font-size: 14px;
}
.notification-message {
font-size: 12px;
color: #666;
margin-top: 4px;
font-style: italic;
}
/* ============================================= */
/* DISMISS BUTTON */
/* ============================================= */
.dismiss-btn {
position: absolute;
top: 8px;
right: 8px;
width: 20px;
height: 20px;
background: none;
border: none;
font-size: 18px;
line-height: 1;
cursor: pointer;
color: #999;
transition: color 0.2s;
padding: 0;
}
.dismiss-btn:hover {
color: #333;
}
</style>

108
ui/src/data/bibliography.js Normal file
View File

@@ -0,0 +1,108 @@
// BIBLIOGRAPHY
// BibTeX-inspired JSON format for Hopewell archaeological references
export const BIBLIOGRAPHY = {
"hively_horn_1982": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "Geometry and Astronomy in Prehistoric Ohio",
"journal": "Archaeoastronomy",
"volume": "4",
"pages": "S1-S20",
"year": 1982,
"note": "Foundational paper demonstrating lunar alignments at Newark Octagon Earthworks"
},
"hively_horn_1984": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "Hopewellian Geometry and Astronomy at High Bank",
"journal": "Archaeoastronomy",
"volume": "7",
"pages": "S85-S100",
"year": 1984,
"note": "Extended lunar alignment analysis to High Bank Works"
},
"hively_horn_2006": {
"type": "article",
"author": ["Hively, Ray", "Horn, Robert"],
"title": "A Statistical Study of Lunar Alignments at the Newark Earthworks",
"journal": "Midcontinental Journal of Archaeology",
"year": 2006,
"note": "Monte Carlo analysis showing odds of chance alignments at 1 in 40 million"
},
"squier_davis_1848": {
"type": "book",
"author": ["Squier, Ephraim George", "Davis, Edwin Hamilton"],
"title": "Ancient Monuments of the Mississippi Valley",
"publisher": "Smithsonian Institution",
"series": "Smithsonian Contributions to Knowledge",
"volume": "1",
"year": 1848,
"note": "First comprehensive survey of Ohio earthworks; first Smithsonian publication"
},
"salisbury_salisbury_1862": {
"type": "article",
"author": ["Salisbury, James", "Salisbury, Charles"],
"title": "Accurate Surveys and Descriptions of the Ancient Earthworks at Newark, Ohio",
"journal": "American Journal of Science and Arts",
"series": "2nd series",
"volume": "34",
"pages": "61-71",
"year": 1862,
"note": "First documentation tracing the Great Hopewell Road 6 miles south from Newark"
},
"lepper_1995": {
"type": "article",
"author": ["Lepper, Bradley T."],
"title": "Tracking Ohio's Great Hopewell Road",
"journal": "Archaeology",
"volume": "48",
"number": "6",
"pages": "52-56",
"year": 1995,
"note": "Modern investigation of the Great Hopewell Road hypothesis"
},
"lepper_2006": {
"type": "incollection",
"author": ["Lepper, Bradley T."],
"title": "The Great Hopewell Road and the Role of Pilgrimage in the Hopewell Interaction Sphere",
"booktitle": "Recreating Hopewell",
"editor": ["Charles, Douglas K.", "Buikstra, Jane E."],
"publisher": "University Press of Florida",
"year": 2006
},
"lepper_2024": {
"type": "article",
"author": ["Lepper, Bradley T."],
"title": "The Great Hopewell Road: A Biased Assessment Thirty Years On",
"journal": "Journal of Ohio Archaeology",
"volume": "10",
"year": 2024
},
"magli_lepper_2025": {
"type": "article",
"author": ["Magli, Giulio", "Lepper, Bradley T."],
"title": "Going Straight in a Sacred Landscape: The Great Hopewell Road",
"journal": "Studies in Digital Heritage",
"volume": "9",
"number": "1",
"pages": "37-54",
"year": 2025
},
"schwarz_2016": {
"type": "article",
"author": ["Schwarz, Kevin R."],
"title": "The Great Hopewell Road: New Data, Analysis, and Future Research Prospects",
"journal": "Journal of Ohio Archaeology",
"volume": "4",
"pages": "12-38",
"year": 2016
},
"romain_burks_2008": {
"type": "article",
"author": ["Romain, William F.", "Burks, Jarrod"],
"title": "LiDAR Imaging of the Great Hopewell Road",
"journal": "Ohio Archaeology",
"year": 2008
}
};

View File

@@ -0,0 +1,112 @@
// KNOWN_SITES with citations added to descriptions
// Citations use \cite{key} format for easy parsing
export const KNOWN_SITES = [
{
"name": "Newark Octagon Earthworks",
"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": [
'BS19820747',
'BS19820748',
'BS19830747',
'BS19830748',
'BS19820746',
'BS19810747',
'BS19810746',
'BS19860742',
'BS19870743',
'BS19860743',
'BS19880743',
'BS19880742',
'BS19830746',
],
"overlay": [
{
"type": 'polygon',
"coordinates": [
[-82.44123, 40.05443],
[-82.44260, 40.05309],
[-82.44464, 40.05237],
[-82.44631, 40.05342],
[-82.44728, 40.05500],
[-82.44589, 40.05633],
[-82.44389, 40.05698],
[-82.44216, 40.05595],
[-82.44123, 40.05443]
]
}
]
},
{
"name": "Great Circle Earthworks",
"coordinates": [[-82.4277555, 40.0402671]],
"description": "A nearly perfect circle 1,200 feet in diameter, enclosing approximately 30 acres \\cite{squier_davis_1848}. The earthen wall is lined by a deep interior ditch, a design typical of earlier Adena earthworks \\cite{lynott_2015}. Located in Heath, Ohio, this is one of the largest circular earthworks in the Americas. Eagle Mound at the center covers the remains of a large ceremonial structure \\cite{ohc_newark}. The walls vary from 4 to 14 feet in height at the monumental gateway. Unlike the Octagon, no solar alignments have been confirmed at the Great Circle \\cite{hively_horn_1982}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ["BS19870742", 'BS19870743', 'BS19880743'],
"overlay" : [
{
"type": 'line',
"coordinates": [
[-82.459197, 40.027871],
[-82.458565, 40.028731]
]
}
]
},
{
"name": "Van Voorhis Walls",
"coordinates": [[ -82.458844,40.028344]],
"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": ['BS19780738', 'BS19800738', 'BS19780737', 'BS19800737']
},
{
"name": "Mound City Group",
"coordinates": [[-83.0065767, 39.3744923]],
"description": "The headquarters of Hopewell Culture National Historical Park \\cite{nps_hocu}. Contains 23 burial mounds within a nearly square earthen enclosure (walls approximately 3-4 feet high) along the Scioto River, enclosing over 13 acres \\cite{squier_davis_1848}. Each mound covered a charnel house where the dead were cremated. Excavations by Squier and Davis in the 1840s, and later Ohio Historical Society work (1920-1922), revealed spectacular artifacts including effigy smoking pipes, mica sheets, copper figures, and obsidian from Yellowstone \\cite{lynott_2015}. Much was damaged during Camp Sherman construction in WWI; mounds were reconstructed in the 1920s. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18250500', 'BS18250501', 'BS18260501', 'BS18260500']
},
{
"name": "Hopeton Earthworks",
"coordinates": [[-82.9809185, 39.3790743]],
"description": "A geometric earthwork complex featuring a circle (320m/1,050 ft diameter) and square of similar area, connected by parallel earthen walls \\cite{squier_davis_1848}. The circle has the same diameter as those at four other Hopewell sites, including Newark and High Bank \\cite{hively_horn_1984}. The parallel walls (extending nearly half a mile toward the Scioto River) align with the winter solstice sunset, and the diagonal of the square aligns with the summer solstice sunset \\cite{nps_hocu}. Located on a terrace east of the Scioto River, across from Mound City \\cite{lynott_2015}. Recent magnetometry revealed evidence of a monumental 'woodhenge' with giant posts spaced at 20-foot intervals around the circle. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18320502', 'BS18320503', 'BS18320505', 'BS18330505', 'BS18330503']
},
{
"name": "Hopewell Mound Group",
"coordinates": [[-83.0844809, 39.3608166]],
"description": "The type site for the Hopewell culture, named after former landowner Mordecai Cloud Hopewell \\cite{moorehead_1892}. This 300-acre site contains 29 mounds within a parallelogram enclosure of approximately 111 acres \\cite{squier_davis_1848}. Includes the largest known Hopewell mound—originally 500 feet long, 180 feet wide, and 30 feet tall, consisting of three conjoined mounds within a D-shaped enclosure \\cite{nps_hocu}. More Hopewell artifacts of the highest quality were found here than at any other site, including mica cutouts, copper effigies, and obsidian blades \\cite{lynott_2015}. First excavated by Warren Moorehead in 1891-1892. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS18020495', 'BS18020496', 'BS18010496', 'BS18010495', 'BS18000496', 'BS18000495', 'BS17980496', 'BS17980495']
},
{
"name": "Seip Earthworks",
"coordinates": [[-83.2214086, 39.2416867]],
"description": "One of the largest Hopewell complexes, featuring two circles and a square enclosing approximately 121 acres with over 10,000 feet of embankment walls \\cite{squier_davis_1848}. The Seip-Pricer Mound stands 30 feet high (240 feet long, 160 feet wide), one of the largest burial mounds in the Middle Ohio Valley \\cite{shetrone_greenman_1931}. The square measures exactly 27 acres, matching four other nearby Hopewell sites, suggesting a common unit of measurement \\cite{romain_2000}. Excavations (1925-1928) revealed over 100 burials with artifacts including thousands of freshwater pearls, Isle Royale copper, Carolina mica, and Tennessee River Valley effigy pipes \\cite{mills_1909}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": ['BS17630452', 'BS17630451', 'BS17650451', 'BS17620451', 'BS17620450']
},
// There is something in this area, but I can't confirm it's the 'high banks works'
// Google maps and some facebook boomer do claim so, "highbank park earthworks"
// A historical map puts it near the Scioto river, but that's on the other side of columbus
{
"name": "High Bank Works",
"coordinates": [[-83.028353, 40.139853]],
"description": "Features a circle-octagon pair with the same design principles as Newark Earthworks—both circles are exactly 1,054 feet in diameter \\cite{hively_horn_1984}. Located approximately 60 miles from Newark on a terrace 75-80 feet above the Scioto River. The octagon is aligned to the lunar standstill cycle, with its main axis rotated exactly 90° from Newark's orientation \\cite{hively_horn_2006}. Encodes all eight lunar standstill points plus the four solstices \\cite{romain_2000}. The only other circle-octagon combination built by the Hopewell culture, suggesting intentional pairing with Newark \\cite{magli_lepper_2025}. Currently a research preserve within Hopewell Culture National Historical Park, not routinely open to the public \\cite{nps_hocu}. Part of the Hopewell Ceremonial Earthworks UNESCO World Heritage Site \\cite{unesco_2023}.",
"type": "earthwork",
"tiles": [
"BS821780",
"BS820780",
"BS18210778",
"BS18200778",
"N1820175"
]
}
];
export const TEST_TILES = KNOWN_SITES.flatMap(i => i.tiles);

View File

@@ -1,6 +1,9 @@
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
const pinia = createPinia();
const app = createApp(App)
app.use(pinia)
app.mount('#app')

347
ui/src/stores/tiles.js Normal file
View File

@@ -0,0 +1,347 @@
// ============================================================================
// TILES STORE (Pinia)
// Centralized tile state management with integrated API calls
// ============================================================================
import { defineStore } from 'pinia';
import { getTileByCoordinates, getTileById, getTileMoundData, requestTileProcessing } from '../utils/api.js';
export const useTilesStore = defineStore('tiles', {
// ==========================================================================
// STATE
// ==========================================================================
state: () => ({
// Metadata cache - Map<tileId, metadata>
metadata: new Map(),
// Mound data cache - Map<tileId, { positions, indices, bounds }>
mounds: new Map(),
// Images on map tracker - Set<tileId>
imagesOnMap: new Set(),
// Loading state - Map<tileId, { metadata, mound, jpg, png }>
loading: new Map(),
}),
// ==========================================================================
// GETTERS
// ==========================================================================
getters: {
// ------------------------------------------------------------------------
// Metadata getters
// ------------------------------------------------------------------------
getMetadata: (state) => (tileId) => {
return state.metadata.get(tileId) || null;
},
hasMetadata: (state) => (tileId) => {
return state.metadata.has(tileId);
},
findTileByCoords: (state) => (lat, lng) => {
for (const [tileId, meta] of state.metadata) {
if (lat >= meta.min_lat && lat <= meta.max_lat &&
lng >= meta.min_lng && lng <= meta.max_lng) {
return tileId;
}
}
return null;
},
getAllTileIds: (state) => {
return Array.from(state.metadata.keys());
},
// ------------------------------------------------------------------------
// Mound data getters
// ------------------------------------------------------------------------
getMoundData: (state) => (tileId) => {
return state.mounds.get(tileId) || null;
},
hasMoundData: (state) => (tileId) => {
return state.mounds.has(tileId);
},
getAllTileIdsWithMounds: (state) => {
return Array.from(state.mounds.keys());
},
// ------------------------------------------------------------------------
// Image getters
// ------------------------------------------------------------------------
getImageUrl: () => (tileId, type) => {
const API_BASE = ''; // Same origin
return `${API_BASE}/tiles/${type}/${tileId}.${type}`;
},
areImagesOnMap: (state) => (tileId) => {
return state.imagesOnMap.has(tileId);
},
getAllTileIdsWithImages: (state) => {
return Array.from(state.imagesOnMap);
},
getImageAvailability: (state) => (tileId) => {
const meta = state.metadata.get(tileId);
if (!meta) {
return { jpg: false, png: false };
}
return {
jpg: meta.jpg_available || false,
png: meta.png_available || false
};
},
// ------------------------------------------------------------------------
// Loading state getters
// ------------------------------------------------------------------------
isMetadataLoading: (state) => (tileId) => {
return state.loading.get(tileId)?.metadata || false;
},
isMoundLoading: (state) => (tileId) => {
return state.loading.get(tileId)?.mound || false;
},
isImageLoading: (state) => (tileId, type) => {
return state.loading.get(tileId)?.[type] || false;
},
// ------------------------------------------------------------------------
// Composite state getters
// ------------------------------------------------------------------------
isMetadataLoaded: (state) => (tileId) => {
return state.metadata.has(tileId);
},
isMoundLoaded: (state) => (tileId) => {
return state.mounds.has(tileId);
},
isReadyToRender: (state) => (tileId) => {
return state.metadata.has(tileId) && state.mounds.has(tileId);
},
// ------------------------------------------------------------------------
// Statistics
// ------------------------------------------------------------------------
stats() {
return {
metadataCount: this.metadata.size,
moundCount: this.mounds.size,
imagesOnMapCount: this.imagesOnMap.size,
loadingCount: this.loading.size
};
}
},
// ==========================================================================
// ACTIONS
// ==========================================================================
actions: {
// ------------------------------------------------------------------------
// Metadata actions
// ------------------------------------------------------------------------
/**
* Fetch and cache tile metadata by coordinates
* @param {number} lat
* @param {number} lng
* @returns {Promise<Object|null>} Metadata or null if not found
*/
async fetchMetadataByCoords(lat, lng) {
// Check if we already have it cached
const cachedTileId = this.findTileByCoords(lat, lng);
if (cachedTileId) {
return this.metadata.get(cachedTileId);
}
// Set loading state
const tempId = `temp-${lat}-${lng}`;
this._setLoading(tempId, 'metadata', true);
try {
const metadata = await getTileByCoordinates(lat, lng);
if (metadata) {
this.metadata.set(metadata.id, metadata);
return metadata;
}
return null;
} catch (err) {
console.error('Failed to fetch metadata:', err);
throw err;
} finally {
this._setLoading(tempId, 'metadata', false);
}
},
/**
* Fetch and cache tile metadata by ID
* @param {string} tileId
* @returns {Promise<Object>}
*/
async fetchMetadataById(tileId) {
// Return from cache if available
if (this.metadata.has(tileId)) {
return this.metadata.get(tileId);
}
this._setLoading(tileId, 'metadata', true);
try {
const metadata = await getTileById(tileId);
this.metadata.set(tileId, metadata);
return metadata;
} catch (err) {
console.error('Failed to fetch metadata by ID:', err);
throw err;
} finally {
this._setLoading(tileId, 'metadata', false);
}
},
/**
* Manually set metadata (for cases where you already have it)
* @param {string} tileId
* @param {Object} metadata
*/
setMetadata(tileId, metadata) {
this.metadata.set(tileId, metadata);
},
// ------------------------------------------------------------------------
// Mound data actions
// ------------------------------------------------------------------------
/**
* Fetch and cache mound data
* @param {string} tileId
* @param {Function} parseFunction - Function to parse the mound buffer
* @returns {Promise<Object>} Parsed mound data
*/
async fetchMoundData(tileId, parseFunction) {
// Return from cache if available
if (this.mounds.has(tileId)) {
return this.mounds.get(tileId);
}
this._setLoading(tileId, 'mound', true);
try {
const buffer = await getTileMoundData(tileId);
const moundData = parseFunction(buffer);
this.mounds.set(tileId, moundData);
return moundData;
} catch (err) {
console.error('Failed to fetch mound data:', err);
throw err;
} finally {
this._setLoading(tileId, 'mound', false);
}
},
/**
* Manually set mound data (for cases where you already have it parsed)
* @param {string} tileId
* @param {Object} moundData
*/
setMoundData(tileId, moundData) {
this.mounds.set(tileId, moundData);
},
// ------------------------------------------------------------------------
// Image tracking actions
// ------------------------------------------------------------------------
/**
* Mark images as loaded on map
* @param {string} tileId
*/
markImagesOnMap(tileId) {
this.imagesOnMap.add(tileId);
},
/**
* Remove images from map tracking
* @param {string} tileId
*/
removeImagesFromMap(tileId) {
this.imagesOnMap.delete(tileId);
},
// ------------------------------------------------------------------------
// Tile processing request (SSE)
// ------------------------------------------------------------------------
/**
* Request tile processing with progress updates
* @param {number} lat
* @param {number} lng
* @param {Function} onMessage - Callback for status updates
* @param {Function} onError - Callback for errors
* @returns {EventSource} Connection (call .close() to cancel)
*/
requestTileProcessing(lat, lng, onMessage, onError) {
return requestTileProcessing(lat, lng, onMessage, onError);
},
// ------------------------------------------------------------------------
// Bulk operations
// ------------------------------------------------------------------------
/**
* Clear all caches
*/
clearAll() {
this.metadata.clear();
this.mounds.clear();
this.imagesOnMap.clear();
this.loading.clear();
},
/**
* Remove a specific tile from all caches
* @param {string} tileId
*/
removeTile(tileId) {
this.metadata.delete(tileId);
this.mounds.delete(tileId);
this.imagesOnMap.delete(tileId);
this.loading.delete(tileId);
},
// ------------------------------------------------------------------------
// Internal helpers
// ------------------------------------------------------------------------
/**
* Initialize loading state for a tile if not present
* @private
*/
_initLoadingState(tileId) {
if (!this.loading.has(tileId)) {
this.loading.set(tileId, {
metadata: false,
mound: false,
jpg: false,
png: false
});
}
},
/**
* Set loading state for a specific data type
* @private
*/
_setLoading(tileId, dataType, isLoading) {
this._initLoadingState(tileId);
this.loading.get(tileId)[dataType] = isLoading;
},
}
});

194
ui/src/utils/api.js Normal file
View File

@@ -0,0 +1,194 @@
// ============================================================================
// API UTILITIES
// ============================================================================
const API_BASE = ''; // Same origin
// ============================================================================
// TILE METADATA API
// ============================================================================
/**
* Get tile metadata by coordinates
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @returns {Promise<Object|null>} Tile metadata or null if not found
*/
export async function getTileByCoordinates(lat, lng) {
try {
const response = await fetch(`${API_BASE}/api/meta/tile?lat=${lat}&lng=${lng}`);
if (response.status === 404) {
return null; // Tile doesn't exist
}
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error('Failed to fetch tile metadata:', err);
throw err;
}
}
/**
* Get tile metadata by ID
* @param {string} tileId - Tile identifier
* @returns {Promise<Object>} Tile metadata
*/
export async function getTileById(tileId) {
try {
const response = await fetch(`${API_BASE}/api/meta/tile/${tileId}`);
if (!response.ok) {
throw new Error(`API error: ${response.status}`);
}
return await response.json();
} catch (err) {
console.error('Failed to fetch tile by ID:', err);
throw err;
}
}
// ============================================================================
// TILE REQUEST API (SSE)
// ============================================================================
/**
* Request tile processing via Server-Sent Events
* @param {number} lat - Latitude
* @param {number} lng - Longitude
* @param {Function} onMessage - Callback for status updates
* @param {Function} onError - Callback for errors
* @returns {EventSource} The EventSource connection (call .close() to cancel)
*/
export function requestTileProcessing(lat, lng, onMessage, onError) {
const eventSource = new EventSource(`${API_BASE}/tiles/request?lat=${lat}&lng=${lng}`);
eventSource.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
onMessage(data);
// Auto-close on terminal states
if (data.status === 'ready' || data.status === 'error') {
eventSource.close();
}
} catch (err) {
console.error('Failed to parse SSE message:', err);
onError(err);
eventSource.close();
}
};
eventSource.onerror = (error) => {
console.error('SSE connection error:', error);
onError(error);
eventSource.close();
};
return eventSource;
}
// ============================================================================
// TILE FILE FETCHING
// ============================================================================
/**
* Fetch tile MOUND data (binary point cloud)
* @param {string} tileId - Tile identifier
* @returns {Promise<ArrayBuffer>} Binary mound data
*/
export async function getTileMoundData(tileId) {
try {
const response = await fetch(`${API_BASE}/tiles/mound/${tileId}.mound`);
if (!response.ok) {
throw new Error(`Failed to fetch mound data: ${response.status}`);
}
return await response.arrayBuffer();
} catch (err) {
console.error('Failed to fetch mound data:', err);
throw err;
}
}
// ============================================================================
// GEOMETRY SHARING API
// ============================================================================
/**
* Share geometry (create shareable link)
* @param {Object} geojson - GeoJSON feature
* @returns {Promise<string>} Share ID
*/
export async function shareGeometry(geojson) {
try {
const response = await fetch(`${API_BASE}/api/share`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({ geojson })
});
if (!response.ok) {
throw new Error(`Failed to share geometry: ${response.status}`);
}
const data = await response.json();
return data.id;
} catch (err) {
console.error('Failed to share geometry:', err);
throw err;
}
}
/**
* Get shared geometry by ID
* @param {string} shareId - Share identifier
* @returns {Promise<Object>} GeoJSON feature
*/
export async function getSharedGeometry(shareId) {
try {
const response = await fetch(`${API_BASE}/api/share/${shareId}`);
if (!response.ok) {
throw new Error(`Failed to fetch shared geometry: ${response.status}`);
}
const data = await response.json();
return data.geojson;
} catch (err) {
console.error('Failed to fetch shared geometry:', err);
throw err;
}
}
// ============================================================================
// HEALTH CHECK
// ============================================================================
/**
* Check API health
* @returns {Promise<boolean>} True if API is healthy
*/
export async function checkHealth() {
try {
const response = await fetch(`${API_BASE}/health`);
if (!response.ok) {
return false;
}
const data = await response.json();
return data.status === 'ok';
} catch (err) {
console.error('Health check failed:', err);
return false;
}
}

View File

@@ -0,0 +1,136 @@
/**
* Batch Renderer for Hopewell Lidar Tiles
*
* Usage in dev console with loaded app:
* import { batchRenderTiles } from './batch-renderer.js';
* const app = document.querySelector('#app').__vue_app__;
* const sandboxRef = app._instance.refs.sandboxRef;
* const tileCache = app._instance.data.tileCache;
*
* await batchRenderTiles(sandboxRef, tileCache, tileNames);
*/
/**
* Download a data URL as a file
*/
function downloadDataURL(dataURL, filename) {
const link = document.createElement('a');
link.href = dataURL;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
/**
* Download text content as a file
*/
function downloadText(text, filename) {
const blob = new Blob([text], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename;
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
URL.revokeObjectURL(url);
}
/**
* Batch render tiles using already-loaded app
*
* @param {Object} sandboxRef - Vue ref to ShadingSandbox component
* @param {Object} tileCache - Cache of loaded tile data
* @param {string[]} tileNames - Array of tile names to render
* @param {Object} options - Options
* @param {Object} options.renderSettings - Override render settings
* @param {number} options.renderQuality - Render quality (default: 1024)
*
* @returns {Promise<Object[]>} Array of results
*/
export async function batchRenderTiles(sandboxRef, tileCache, tileNames, options = {}) {
const {
renderSettings = null, // Use sandbox defaults if null
renderQuality = 1024
} = options;
console.log(`[BatchRenderer] Starting batch render of ${tileNames.length} tiles`);
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
const results = [];
const startTime = Date.now();
for (let i = 0; i < tileNames.length; i++) {
const tileName = tileNames[i];
const current = i + 1;
const total = tileNames.length;
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileName}...`);
try {
const tileData = tileCache[tileName];
if (!tileData) {
throw new Error(`Tile ${tileName} not found in cache`);
}
// Render
console.log(`[BatchRenderer] Rendering...`);
const renderResult = await sandboxRef.renderTileWithSettings(
tileData,
renderSettings || sandboxRef.getSettings(),
renderQuality
);
if (!renderResult.success) {
throw new Error(renderResult.error || 'Render failed');
}
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
// Generate metadata
const metadata = {
tileName,
bounds: tileData.bounds,
renderSettings: renderSettings || sandboxRef.getSettings(),
renderQuality,
pointCount: tileData.pointCount,
triangleCount: tileData.triangleCount,
renderedAt: new Date().toISOString()
};
// Download
downloadDataURL(renderResult.dataURL, `${tileName}.png`);
downloadText(JSON.stringify(metadata, null, 2), `${tileName}.json`);
results.push({
tileName,
metadata,
renderTime: renderResult.renderTime,
success: true
});
console.log(`[BatchRenderer] ✓ Complete`);
// Small delay
await new Promise(resolve => setTimeout(resolve, 100));
} catch (err) {
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
results.push({
tileName,
success: false,
error: err.message
});
}
}
const totalTime = Date.now() - startTime;
const successCount = results.filter(r => r.success).length;
const failCount = results.filter(r => !r.success).length;
console.log(`[BatchRenderer] Batch complete in ${(totalTime / 1000).toFixed(1)}s`);
console.log(`[BatchRenderer] Success: ${successCount}, Failed: ${failCount}`);
return results;
}

73
ui/src/utils/citations.js Normal file
View File

@@ -0,0 +1,73 @@
// ============================================================================
// CITATION PARSING UTILITIES
// ============================================================================
/**
* Parse text containing \cite{key} citations and convert to structured format
* Returns array of text segments and citation keys for rendering
*
* Example input: "This is text \\cite{lepper_1995} more text \\cite{schwarz_2016}."
* Example output: [
* { type: 'text', content: 'This is text ' },
* { type: 'citation', key: 'lepper_1995' },
* { type: 'text', content: ' more text ' },
* { type: 'citation', key: 'schwarz_2016' },
* { type: 'text', content: '.' }
* ]
*/
export function parseCitations(text) {
if (!text) return [];
const segments = [];
const citationRegex = /\\cite\{([^}]+)\}/g;
let lastIndex = 0;
let match;
while ((match = citationRegex.exec(text)) !== null) {
// Add text before citation
if (match.index > lastIndex) {
segments.push({
type: 'text',
content: text.substring(lastIndex, match.index)
});
}
// Add citation
segments.push({
type: 'citation',
key: match[1]
});
lastIndex = match.index + match[0].length;
}
// Add remaining text
if (lastIndex < text.length) {
segments.push({
type: 'text',
content: text.substring(lastIndex)
});
}
return segments;
}
/**
* Extract all citation keys from text
* Returns array of unique citation keys
*/
export function extractCitationKeys(text) {
if (!text) return [];
const citationRegex = /\\cite\{([^}]+)\}/g;
const keys = [];
let match;
while ((match = citationRegex.exec(text)) !== null) {
if (!keys.includes(match[1])) {
keys.push(match[1]);
}
}
return keys;
}

View File

@@ -0,0 +1,52 @@
// ============================================================================
// COORDINATE & DISTANCE FORMATTING UTILITIES
// ============================================================================
/**
* Format coordinate to 6 decimal places (±11cm precision)
*/
export function formatCoordinate(value, type) {
const dir = type === 'lat'
? (value >= 0 ? 'N' : 'S')
: (value >= 0 ? 'E' : 'W');
return `${Math.abs(value).toFixed(6)}°${dir}`;
}
/**
* Format distance in meters or feet based on unit preference
*/
export function formatDistance(meters, useImperial = false) {
if (useImperial) {
const feet = meters * 3.28084;
if (feet < 5280) {
return `${feet.toFixed(1)} ft`;
} else {
const miles = feet / 5280;
return `${miles.toFixed(2)} mi`;
}
} else {
if (meters < 1000) {
return `${meters.toFixed(1)} m`;
} else {
const km = meters / 1000;
return `${km.toFixed(2)} km`;
}
}
}
/**
* Format bearing angle
*/
export function formatBearing(degrees) {
return `${degrees.toFixed(1)}°`;
}
/**
* Convert web Mercator to lat/long
*/
export function webMercatorToLonLat(x, y) {
const R = 6378137;
const lon = (x / R) * (180 / Math.PI);
const lat = (2 * Math.atan(Math.exp(y / R)) - Math.PI / 2) * (180 / Math.PI);
return [lon, lat];
}

59
ui/src/utils/geometry.js Normal file
View File

@@ -0,0 +1,59 @@
// ============================================================================
// GEOMETRY CALCULATION UTILITIES
// ============================================================================
/**
* Calculate distance between two points in meters using Haversine formula
*/
export function calculateDistance(lng1, lat1, lng2, lat2) {
const R = 6371000; // Earth's radius in meters
const phi_1 = lat1 * Math.PI / 180;
const phi_2 = lat2 * Math.PI / 180;
const delta_phi = (lat2 - lat1) * Math.PI / 180;
const delta_lambda = (lng2 - lng1) * Math.PI / 180;
const a = Math.sin(delta_phi / 2) * Math.sin(delta_phi / 2) +
Math.cos(phi_1) * Math.cos(phi_2) *
Math.sin(delta_lambda / 2) * Math.sin(delta_lambda / 2);
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
return R * c; // Distance in meters
}
/**
* Calculate bearing between two points in degrees (0-360)
*/
export function calculateBearing(lng1, lat1, lng2, lat2) {
const phi_1 = lat1 * Math.PI / 180;
const phi_2 = lat2 * Math.PI / 180;
const delta_lambda = (lng2 - lng1) * Math.PI / 180;
const y = Math.sin(delta_lambda) * Math.cos(phi_2);
const x = Math.cos(phi_1) * Math.sin(phi_2) -
Math.sin(phi_1) * Math.cos(phi_2) * Math.cos(delta_lambda);
const theta = Math.atan2(y, x);
return (theta * 180 / Math.PI + 360) % 360; // Bearing in degrees
}
/**
* Extend a line from point1 through point2 to a far distance (100km)
* Used for ray drawing
*/
export function extendRay(lng1, lat1, lng2, lat2, bounds) {
const bearing = calculateBearing(lng1, lat1, lng2, lat2);
const bearingRad = bearing * Math.PI / 180;
// Calculate a far point (100km away)
const R = 6371000; // Earth's radius in meters
const d = 100000; // 100km
const phi_1 = lat1 * Math.PI / 180;
const labmda_1 = lng1 * Math.PI / 180;
const phi_2 = Math.asin(Math.sin(phi_1) * Math.cos(d / R) +
Math.cos(phi_1) * Math.sin(d / R) * Math.cos(bearingRad));
const labmda_2 = labmda_1 + Math.atan2(Math.sin(bearingRad) * Math.sin(d / R) * Math.cos(phi_1),
Math.cos(d / R) - Math.sin(phi_1) * Math.sin(phi_2));
return [labmda_2 * 180 / Math.PI, phi_2 * 180 / Math.PI];
}

View File

@@ -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