Compare commits
12 Commits
08fe8ebc7f
...
master
| Author | SHA1 | Date | |
|---|---|---|---|
| dbbf69566e | |||
| 11bdb7009a | |||
| d97e26d881 | |||
| 6300de22f0 | |||
| bd5a59d827 | |||
| 4cec4b1bb4 | |||
| 559a4c3e9f | |||
| 317ee96ba3 | |||
| f059b54936 | |||
| 73625bf6a5 | |||
| e3f7e1bd70 | |||
| 9f591b1bdc |
14
.gitignore
vendored
14
.gitignore
vendored
@@ -23,17 +23,25 @@ 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/
|
||||
/priv/static/chunks/
|
||||
/priv/static/*.js
|
||||
/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
27
config/config.exs
Normal 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
6
config/dev.exs
Normal 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
3
config/prod.exs
Normal file
@@ -0,0 +1,3 @@
|
||||
import Config
|
||||
|
||||
config :logger, level: :info
|
||||
7
config/test.exs
Normal file
7
config/test.exs
Normal file
@@ -0,0 +1,7 @@
|
||||
import Config
|
||||
|
||||
config :mound_hunters,
|
||||
http_port: 4001,
|
||||
mnesia_dir: ~c"priv/mnesia_test"
|
||||
|
||||
config :logger, level: :warning
|
||||
114
lib/mound_hunters/application.ex
Normal file
114
lib/mound_hunters/application.ex
Normal 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
|
||||
121
lib/mound_hunters/boundary.ex
Normal file
121
lib/mound_hunters/boundary.ex
Normal 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
|
||||
335
lib/mound_hunters/mound_parser.ex
Normal file
335
lib/mound_hunters/mound_parser.ex
Normal 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
|
||||
196
lib/mound_hunters/ohio_lidar.ex
Normal file
196
lib/mound_hunters/ohio_lidar.ex
Normal 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
321
lib/mound_hunters/repo.ex
Normal 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
|
||||
423
lib/mound_hunters/tile_processor.ex
Normal file
423
lib/mound_hunters/tile_processor.ex
Normal 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
|
||||
132
lib/mound_hunters_web/controllers/api_controller.ex
Normal file
132
lib/mound_hunters_web/controllers/api_controller.ex
Normal 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
|
||||
225
lib/mound_hunters_web/controllers/tile_controller.ex
Normal file
225
lib/mound_hunters_web/controllers/tile_controller.ex
Normal 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
|
||||
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal file
75
lib/mound_hunters_web/plugs/bounds_check.ex
Normal 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
|
||||
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal file
101
lib/mound_hunters_web/plugs/request_logger.ex
Normal 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
|
||||
168
lib/mound_hunters_web/router.ex
Normal file
168
lib/mound_hunters_web/router.ex
Normal 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, must-revalidate")
|
||||
|> 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
31
mix.exs
Normal 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
23
mix.lock
Normal 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"},
|
||||
}
|
||||
@@ -34,6 +34,7 @@ let
|
||||
pkgs.python3Packages.scipy
|
||||
pkgs.python3Packages.numpy
|
||||
pkgs.python3Packages.pyproj
|
||||
pkgs.python3Packages.requests
|
||||
];
|
||||
|
||||
mkShell = pkgs.mkShell;
|
||||
|
||||
240
tooling/process_hopewell_sites.py
Normal file
240
tooling/process_hopewell_sites.py
Normal 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
401
tooling/request_tiles.py
Normal 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()
|
||||
@@ -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
20
ui/deno.lock
generated
@@ -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"
|
||||
]
|
||||
|
||||
@@ -1 +0,0 @@
|
||||
/home/mark/projects/moundhunters/data/MOUND
|
||||
1715
ui/src/App.vue
1715
ui/src/App.vue
File diff suppressed because it is too large
Load Diff
210
ui/src/App1.vue
210
ui/src/App1.vue
@@ -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>
|
||||
398
ui/src/App2.vue
398
ui/src/App2.vue
@@ -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>
|
||||
358
ui/src/components/AboutModal.vue
Normal file
358
ui/src/components/AboutModal.vue
Normal file
@@ -0,0 +1,358 @@
|
||||
<template>
|
||||
<Teleport to="body">
|
||||
<Transition name="modal">
|
||||
<div v-if="isOpen" class="modal-backdrop" @click="closeModal">
|
||||
<div class="modal-content" @click.stop>
|
||||
<button class="close-button" @click="closeModal" aria-label="Close">
|
||||
×
|
||||
</button>
|
||||
|
||||
<div class="modal-body">
|
||||
<h2>About This Project</h2>
|
||||
|
||||
<section class="intro">
|
||||
<p>
|
||||
Around 2,000 years ago, the Hopewell culture built a 60-mile ceremonial road
|
||||
connecting Newark to Chillicothe, Ohio. Most of it has been destroyed by farming,
|
||||
but tiny remnants might still be hiding in the landscape—parallel earthen walls
|
||||
just 50cm tall, visible only as subtle shadows in the right light.
|
||||
</p>
|
||||
<p>
|
||||
There's way too much terrain for archaeologists to analyze alone. That's where
|
||||
you come in! This tool lets you explore high-resolution lidar data and help
|
||||
search for lost sections of the Great Hopewell Road.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="video-section">
|
||||
<h3>Learn More About the Road</h3>
|
||||
<div class="video-container">
|
||||
<iframe
|
||||
src="https://www.youtube.com/embed/Ltu2hJwqId8"
|
||||
title="The Great Hopewell Road"
|
||||
frameborder="0"
|
||||
allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture"
|
||||
allowfullscreen
|
||||
></iframe>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<section class="instructions">
|
||||
<h3>How to Use This Tool</h3>
|
||||
|
||||
<p>
|
||||
<strong>Start exploring:</strong> Check out the known archaeological sites on the map.
|
||||
Click the pin icon next to each site name to fly there and see the crisp lidar imagery.
|
||||
Click on any of the pins on the map to learn more about the cite, and follow up on
|
||||
the cited references to dive further down the rabbit hole.
|
||||
Use the sidebar controls to customize what you see.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Hunt for the road:</strong> Right-click anywhere on the map (within Ohio) to
|
||||
request a tile. If someone's already requested it, you can load the interactive data
|
||||
instantly. Otherwise, you'll wait a bit for the processing pipeline.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Adjust the lighting:</strong> Once a tile loads, the shading sandbox opens.
|
||||
Change the lighting direction and exaggerate the terrain to increase contrast—this
|
||||
makes subtle features pop out. Click "Render Tile" to add it to the map.
|
||||
</p>
|
||||
|
||||
<p>
|
||||
<strong>Mark your finds:</strong> Found something interesting? Drop a pin using the
|
||||
geometry tools in the top right. Then share it on
|
||||
<a href="http://discord.gg/miniminuteclan" target="_blank" rel="noopener noreferrer">
|
||||
Discord
|
||||
</a>
|
||||
with coordinates and a screenshot!
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="about-author">
|
||||
<h3>About me</h3>
|
||||
<p>
|
||||
I'm Mark Kalsbeek, a web developer and researcher from the Netherlands.
|
||||
I was inspired to build this after watching Milo's video above. If you want to
|
||||
reach me, @ me on
|
||||
<a href="http://discord.gg/miniminuteclan" target="_blank" rel="noopener noreferrer">
|
||||
Milo's Discord
|
||||
</a>:
|
||||
<span v-if="!usernameRevealed" class="username-reveal" @click="revealUsername">
|
||||
reveal
|
||||
</span>
|
||||
<span v-else class="username">{{ username }}</span>
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section class="thanks">
|
||||
<h3>Thanks</h3>
|
||||
<p>
|
||||
Special thanks to <a href="https://gis1.oit.ohio.gov/geodatadownload/" target="_blank" rel="noopener noreferrer">Ohio OGRIP</a>
|
||||
for making high-quality lidar data freely available with an easy-to-reverse API.
|
||||
</p>
|
||||
<p>
|
||||
Map tiles from <a href="https://www.openstreetmap.org/" target="_blank" rel="noopener noreferrer">OpenStreetMap</a> contributors
|
||||
and satellite imagery from <a href="https://www.esri.com/" target="_blank" rel="noopener noreferrer">Esri</a>.
|
||||
</p>
|
||||
<p class="tech-stack">
|
||||
Built with: Elixir (plug_cowboy, jason, geo, logger_file_backend, httpoison, mime),
|
||||
Python (laspy, scipy, numpy, pyproj),
|
||||
Deno (Vue, Vite, Three.js, MapLibre GL, Pinia),
|
||||
and of course Claude for infinite amounts of grunt work.
|
||||
</p>
|
||||
</section>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch, onMounted } from 'vue';
|
||||
|
||||
const MODAL_VERSION = '1.0';
|
||||
const STORAGE_KEY = 'aboutModalVersion';
|
||||
const OBFUSCATED_USERNAME = '404d61726b6b313136';
|
||||
|
||||
const isOpen = ref(false);
|
||||
const usernameRevealed = ref(false);
|
||||
const username = ref('');
|
||||
const seenRecentVersion = ref(true);
|
||||
|
||||
const emit = defineEmits(['update:modelValue']);
|
||||
|
||||
const props = defineProps({
|
||||
modelValue: {
|
||||
type: Boolean,
|
||||
default: false
|
||||
}
|
||||
});
|
||||
|
||||
const revealUsername = () => {
|
||||
username.value = OBFUSCATED_USERNAME.match(/.{2}/g)
|
||||
.map(hex => String.fromCharCode(parseInt(hex, 16)))
|
||||
.join('');
|
||||
usernameRevealed.value = true;
|
||||
};
|
||||
|
||||
watch(() => props.modelValue, (newVal) => {
|
||||
isOpen.value = newVal;
|
||||
});
|
||||
|
||||
watch(isOpen, (newVal) => {
|
||||
emit('update:modelValue', newVal);
|
||||
if (!newVal) {
|
||||
// Mark as seen when closed
|
||||
localStorage.setItem(STORAGE_KEY, MODAL_VERSION);
|
||||
seenRecentVersion.value = true;
|
||||
}
|
||||
});
|
||||
|
||||
const closeModal = () => {
|
||||
isOpen.value = false;
|
||||
};
|
||||
|
||||
// Check if should show on mount
|
||||
onMounted(() => {
|
||||
const seenVersion = localStorage.getItem(STORAGE_KEY);
|
||||
if (seenVersion !== MODAL_VERSION) {
|
||||
seenRecentVersion.value = false;
|
||||
}
|
||||
});
|
||||
|
||||
// Expose methods for parent component
|
||||
defineExpose({
|
||||
open: () => { isOpen.value = true; },
|
||||
seenRecentVersion,
|
||||
close: closeModal
|
||||
});
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.modal-backdrop {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100vw;
|
||||
height: 100vh;
|
||||
background: rgba(0, 0, 0, 0.7);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
padding: 1rem;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
position: relative;
|
||||
background: white;
|
||||
border-radius: 8px;
|
||||
box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1);
|
||||
max-width: 60vw;
|
||||
max-height: 80vh;
|
||||
width: 100%;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.modal-content {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
}
|
||||
}
|
||||
|
||||
.close-button {
|
||||
position: absolute;
|
||||
top: 1rem;
|
||||
right: 1rem;
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 2rem;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
color: #666;
|
||||
padding: 0;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: color 0.2s;
|
||||
z-index: 1;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: #000;
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: 2rem;
|
||||
padding-top: 3rem;
|
||||
}
|
||||
|
||||
h2 {
|
||||
margin-top: 0;
|
||||
margin-bottom: 1.5rem;
|
||||
font-size: 1.8rem;
|
||||
color: #333;
|
||||
}
|
||||
|
||||
h3 {
|
||||
margin-top: 2rem;
|
||||
margin-bottom: 1rem;
|
||||
font-size: 1.3rem;
|
||||
color: #444;
|
||||
}
|
||||
|
||||
section {
|
||||
margin-bottom: 2rem;
|
||||
}
|
||||
|
||||
section:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
p {
|
||||
line-height: 1.6;
|
||||
color: #555;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
p:last-child {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.intro p:first-child {
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
|
||||
.video-section {
|
||||
margin: 2rem 0;
|
||||
}
|
||||
|
||||
.video-container {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
padding-bottom: 56.25%; /* 16:9 aspect ratio */
|
||||
margin: 1rem 0;
|
||||
background: #000;
|
||||
border-radius: 4px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.video-container iframe {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.instructions p {
|
||||
margin-bottom: 1.2rem;
|
||||
}
|
||||
|
||||
.instructions strong {
|
||||
color: #333;
|
||||
}
|
||||
|
||||
a {
|
||||
color: #0066cc;
|
||||
text-decoration: none;
|
||||
}
|
||||
|
||||
a:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
|
||||
.username-reveal {
|
||||
display: inline-block;
|
||||
background: #333;
|
||||
color: white;
|
||||
padding: 0.1rem 0.5rem;
|
||||
border-radius: 3px;
|
||||
cursor: pointer;
|
||||
font-size: 0.9rem;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.username-reveal:hover {
|
||||
background: #444;
|
||||
}
|
||||
|
||||
.username {
|
||||
font-family: monospace;
|
||||
color: #333;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.tech-stack {
|
||||
font-size: 0.85rem;
|
||||
color: #666;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Modal transitions */
|
||||
.modal-enter-active,
|
||||
.modal-leave-active {
|
||||
transition: opacity 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from,
|
||||
.modal-leave-to {
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.modal-enter-active .modal-content,
|
||||
.modal-leave-active .modal-content {
|
||||
transition: transform 0.3s ease;
|
||||
}
|
||||
|
||||
.modal-enter-from .modal-content,
|
||||
.modal-leave-to .modal-content {
|
||||
transform: scale(0.9);
|
||||
}
|
||||
</style>
|
||||
279
ui/src/components/Bibliography.vue
Normal file
279
ui/src/components/Bibliography.vue
Normal 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>
|
||||
372
ui/src/components/ContextMenu.vue
Normal file
372
ui/src/components/ContextMenu.vue
Normal 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>
|
||||
237
ui/src/components/FeaturePopup.vue
Normal file
237
ui/src/components/FeaturePopup.vue
Normal file
@@ -0,0 +1,237 @@
|
||||
<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 -->
|
||||
<div v-else-if="type === 'line'">
|
||||
<strong>Line</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>
|
||||
|
||||
<!-- RAY -->
|
||||
<div v-else-if="type === 'line' || type === 'ray'">
|
||||
<strong>Ray</strong>
|
||||
<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>
|
||||
108
ui/src/components/GeometryToolbar.vue
Normal file
108
ui/src/components/GeometryToolbar.vue
Normal 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>
|
||||
386
ui/src/components/MapControls.vue
Normal file
386
ui/src/components/MapControls.vue
Normal file
@@ -0,0 +1,386 @@
|
||||
<template>
|
||||
<div class="layer-controls">
|
||||
<button
|
||||
class="about-button"
|
||||
:class="{ 'pulse-highlight': !aboutRef?.seenRecentVersion }"
|
||||
@click="aboutRef.open"
|
||||
>
|
||||
About this App
|
||||
</button>
|
||||
<AboutModal ref="aboutRef"/>
|
||||
<!-- ============================================= -->
|
||||
<!-- 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>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref } from 'vue';
|
||||
import { KNOWN_SITES } from '../data/historicSites.js';
|
||||
import AboutModal from './AboutModal.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: () => ({})
|
||||
}
|
||||
});
|
||||
|
||||
const aboutRef = ref(null);
|
||||
|
||||
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;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* About button */
|
||||
/* ============================================= */
|
||||
|
||||
.about-button {
|
||||
padding: 6px 14px;
|
||||
border-radius: 6px;
|
||||
border: none;
|
||||
color: #fff;
|
||||
background: linear-gradient(180deg, #4B91F7 0%, #367AF6 100%);
|
||||
background-origin: border-box;
|
||||
box-shadow: 0px 0.5px 1.5px rgba(54, 122, 246, 0.25), inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2);
|
||||
user-select: none;
|
||||
-webkit-user-select: none;
|
||||
touch-action: manipulation;
|
||||
}
|
||||
|
||||
.about-button:focus {
|
||||
box-shadow: inset 0px 0.8px 0px -0.25px rgba(255, 255, 255, 0.2), 0px 0.5px 1.5px rgba(54, 122, 246, 0.25), 0px 0px 0px 3.5px rgba(58, 108, 217, 0.5);
|
||||
outline: 0;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 100% {
|
||||
box-shadow: 0 0 0 0 rgba(59, 130, 246, 0.7);
|
||||
}
|
||||
50% {
|
||||
box-shadow: 0 0 0 20px rgba(59, 130, 246, 0);
|
||||
}
|
||||
}
|
||||
|
||||
.pulse-highlight {
|
||||
animation: pulse 2s infinite;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
/* ============================================= */
|
||||
/* 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>
|
||||
@@ -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);
|
||||
|
||||
@@ -280,20 +281,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 +306,11 @@ const handleResize = () => {
|
||||
};
|
||||
|
||||
// Load tile data
|
||||
const loadTileData = (tileData) => {
|
||||
const loadTileData = (tileData, newTileId) => {
|
||||
if (!scene) {
|
||||
console.error('Three.js not initialized');
|
||||
return false;
|
||||
initThreeJS();
|
||||
}
|
||||
tileId.value = newTileId;
|
||||
|
||||
// Remove old mesh
|
||||
if (mesh) {
|
||||
@@ -319,41 +321,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 +361,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 +380,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 +480,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 +518,7 @@ const renderTile = async () => {
|
||||
renderStats.value.lastRenderTime = renderTime;
|
||||
|
||||
emit('renderComplete', {
|
||||
tileId: tileId.value,
|
||||
dataURL,
|
||||
settings: { ...settings },
|
||||
size,
|
||||
@@ -562,8 +542,7 @@ const renderTile = async () => {
|
||||
// Designed to be called repeatedly without setup/teardown overhead
|
||||
const renderTileWithSettings = async (tileData, renderSettings, resolution = 1024) => {
|
||||
if (!renderer || !scene || !camera) {
|
||||
console.error('Renderer not initialized');
|
||||
return null;
|
||||
initThreeJS();
|
||||
}
|
||||
|
||||
const startTime = performance.now();
|
||||
@@ -573,7 +552,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 +569,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 +597,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 +642,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 +708,7 @@ onMounted(() => {
|
||||
// Watch for visibility changes
|
||||
watch(() => props.visible, (newVal) => {
|
||||
if (newVal && !renderer) {
|
||||
// Coming visible and no renderer exists → initialize
|
||||
nextTick(() => {
|
||||
initThreeJS();
|
||||
|
||||
@@ -725,11 +726,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();
|
||||
}
|
||||
});
|
||||
|
||||
108
ui/src/data/bibliography.js
Normal file
108
ui/src/data/bibliography.js
Normal 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
|
||||
}
|
||||
};
|
||||
119
ui/src/data/historicSites.js
Normal file
119
ui/src/data/historicSites.js
Normal file
@@ -0,0 +1,119 @@
|
||||
// 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']
|
||||
},
|
||||
{
|
||||
"name": "Southernmost identfied Section",
|
||||
"description":"Southernmost identfied GHR Section according to \\cite{lepper_2024}",
|
||||
"coordinates": [[-82.52056,39.95528]],
|
||||
"type": 'road_confirmed',
|
||||
"tiles": ['BS19620711',"BS19610711"]
|
||||
},
|
||||
// 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);
|
||||
@@ -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
347
ui/src/stores/tiles.js
Normal 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
194
ui/src/utils/api.js
Normal 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;
|
||||
}
|
||||
}
|
||||
118
ui/src/utils/batch-renderer.js
Normal file
118
ui/src/utils/batch-renderer.js
Normal file
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* Batch Renderer for Hopewell Lidar Tiles
|
||||
*
|
||||
* Usage:
|
||||
* import { batchRenderTiles } from './batch-renderer.js';
|
||||
*
|
||||
* // Get refs from your app
|
||||
* const tilesStore = useTilesStore();
|
||||
* const sandboxRef = ref(null); // ref to ShadingSandbox component
|
||||
*
|
||||
* // Render tiles
|
||||
* const results = await batchRenderTiles(
|
||||
* sandboxRef.value,
|
||||
* tilesStore,
|
||||
* ['tile-id-1', 'tile-id-2', 'tile-id-3'],
|
||||
* { renderQuality: 1024 }
|
||||
* );
|
||||
*/
|
||||
|
||||
/**
|
||||
* 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);
|
||||
}
|
||||
|
||||
/**
|
||||
* Batch render tiles using offscreen ShadingSandbox
|
||||
*
|
||||
* @param {Object} sandboxComponent - Vue component instance (sandboxRef.value)
|
||||
* @param {Object} tilesStore - Pinia tiles store instance
|
||||
* @param {string[]} tileIds - Array of tile IDs to render (must be pre-loaded in store)
|
||||
* @param {Object} options - Options
|
||||
* @param {number} options.renderQuality - Render quality in pixels (default: 1024)
|
||||
*
|
||||
* @returns {Promise<Object[]>} Array of results
|
||||
*/
|
||||
export async function batchRenderTiles(sandboxComponent, tilesStore, tileIds, options = {}) {
|
||||
const {
|
||||
renderQuality = 1024
|
||||
} = options;
|
||||
|
||||
console.log(`[BatchRenderer] Starting batch render of ${tileIds.length} tiles`);
|
||||
console.log(`[BatchRenderer] Quality: ${renderQuality}px`);
|
||||
|
||||
const results = [];
|
||||
const startTime = Date.now();
|
||||
sandboxComponent.offscreen = true;
|
||||
|
||||
for (let i = 0; i < tileIds.length; i++) {
|
||||
const tileId = tileIds[i];
|
||||
const current = i + 1;
|
||||
const total = tileIds.length;
|
||||
|
||||
console.log(`[BatchRenderer] [${current}/${total}] Processing ${tileId}...`);
|
||||
|
||||
try {
|
||||
// Get tile data from store
|
||||
const metadata = tilesStore.getMetadata(tileId);
|
||||
const moundData = tilesStore.getMoundData(tileId);
|
||||
|
||||
if (!metadata) {
|
||||
throw new Error(`Metadata not found in store for ${tileId}`);
|
||||
}
|
||||
|
||||
if (!moundData) {
|
||||
throw new Error(`Mound data not found in store for ${tileId}`);
|
||||
}
|
||||
|
||||
// Render with current settings
|
||||
console.log(`[BatchRenderer] Rendering at ${renderQuality}px...`);
|
||||
const renderResult = await sandboxComponent.renderTileWithSettings(
|
||||
moundData,
|
||||
sandboxComponent.getSettings(),
|
||||
renderQuality
|
||||
);
|
||||
|
||||
if (!renderResult.success) {
|
||||
throw new Error(renderResult.error || 'Render failed');
|
||||
}
|
||||
|
||||
console.log(`[BatchRenderer] Rendered in ${renderResult.renderTime}ms`);
|
||||
|
||||
// Download PNG
|
||||
downloadDataURL(renderResult.dataURL, `${tileId}.png`);
|
||||
|
||||
results.push({
|
||||
tileId,
|
||||
renderTime: renderResult.renderTime,
|
||||
success: true
|
||||
});
|
||||
|
||||
console.log(`[BatchRenderer] ✓ Complete`);
|
||||
|
||||
} catch (err) {
|
||||
console.error(`[BatchRenderer] ✗ Failed: ${err.message}`);
|
||||
results.push({
|
||||
tileId,
|
||||
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
73
ui/src/utils/citations.js
Normal 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;
|
||||
}
|
||||
52
ui/src/utils/coordinates.js
Normal file
52
ui/src/utils/coordinates.js
Normal 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(2)}°`;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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];
|
||||
}
|
||||
82
ui/src/utils/geometry.js
Normal file
82
ui/src/utils/geometry.js
Normal file
@@ -0,0 +1,82 @@
|
||||
// ============================================================================
|
||||
// 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) {
|
||||
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];
|
||||
}
|
||||
|
||||
/**
|
||||
* Generate a densely-sampled ray from pt1 through pt2
|
||||
* Creates many intermediate points so the line appears straight in Mercator projection
|
||||
*/
|
||||
export function generateRayCoordinates(lng1, lat1, lng2, lat2, numSegments = 1000) {
|
||||
const dLng = lng2 - lng1;
|
||||
const dLat = lat2 - lat1;
|
||||
|
||||
// Extend the ray far beyond point 2
|
||||
const extensionFactor = 100;
|
||||
|
||||
const coords = [];
|
||||
for (let i = 0; i <= numSegments; i++) {
|
||||
const t = (i / numSegments) * extensionFactor;
|
||||
coords.push([
|
||||
lng1 + dLng * t,
|
||||
lat1 + dLat * t
|
||||
]);
|
||||
}
|
||||
|
||||
return coords;
|
||||
}
|
||||
@@ -20,9 +20,14 @@ export default defineConfig({
|
||||
main: resolve(__dirname, 'index.html'),
|
||||
},
|
||||
output: {
|
||||
entryFileNames: 'app.js',
|
||||
chunkFileNames: 'chunks/[name].js',
|
||||
assetFileNames: 'assets/[name].[ext]'
|
||||
entryFileNames: 'app-[hash].js',
|
||||
chunkFileNames: 'chunks/[name]-[hash].js',
|
||||
assetFileNames: 'assets/[name]-[hash].[ext]',
|
||||
manualChunks: {
|
||||
'vendor': ['vue', 'pinia'],
|
||||
'three': ['three'],
|
||||
'maplibre': ['maplibre-gl']
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@@ -34,7 +39,7 @@ export default defineConfig({
|
||||
},
|
||||
server: {
|
||||
proxy: {
|
||||
'/socket': {
|
||||
'/tiles': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
@@ -44,7 +49,7 @@ export default defineConfig({
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
},
|
||||
'/admin': {
|
||||
'/health': {
|
||||
target: 'http://localhost:4000',
|
||||
changeOrigin: true,
|
||||
secure: false
|
||||
|
||||
Reference in New Issue
Block a user