feat(rover): show buildings stacked on terrain in path profile

Each profile sample now carries the tallest building height within 80m
of the path point. The candidate-detail SVG renders these as a red
polygon stacked on top of the terrain so blockage from buildings is
visible alongside ridges. max_obstacle_m now reflects building tops too,
so the clearance label downgrades when buildings sit on the link.
This commit is contained in:
Graham McIntire 2026-04-26 11:47:23 -05:00
parent 3b8d87c356
commit b97af8a5ce
No known key found for this signature in database
GPG key ID: F4ABF488E6029E59
2 changed files with 50 additions and 4 deletions

View file

@ -5,6 +5,7 @@ defmodule Microwaveprop.Rover.CandidateDetail do
along the great-circle path to each selected fixed station. along the great-circle path to each selected fixed station.
""" """
alias Microwaveprop.Buildings.Index, as: BuildingsIndex
alias Microwaveprop.Radio.Maidenhead alias Microwaveprop.Radio.Maidenhead
alias Microwaveprop.Rover.DriveTime alias Microwaveprop.Rover.DriveTime
alias Microwaveprop.Rover.LinkMargin alias Microwaveprop.Rover.LinkMargin
@ -12,6 +13,11 @@ defmodule Microwaveprop.Rover.CandidateDetail do
@sample_count 48 @sample_count 48
# Search radius around each profile sample when checking buildings.
# Slightly tighter than the path-clearance radius (150 m) because the
# profile is a narrower visualization — we want what's "on the line".
@profile_building_radius_m 80
@type station :: %{callsign: String.t(), lat: float(), lon: float()} @type station :: %{callsign: String.t(), lat: float(), lon: float()}
@type link_summary :: %{ @type link_summary :: %{
@ -24,7 +30,7 @@ defmodule Microwaveprop.Rover.CandidateDetail do
station_elev_m: integer() | nil, station_elev_m: integer() | nil,
max_obstacle_m: integer() | nil, max_obstacle_m: integer() | nil,
clearance_m: integer() | nil, clearance_m: integer() | nil,
profile: [%{dist_km: float(), elev: integer()}] profile: [%{dist_km: float(), elev: integer(), building_m: float()}]
} }
@spec summarize(map(), [station()], non_neg_integer(), DateTime.t(), atom()) :: %{ @spec summarize(map(), [station()], non_neg_integer(), DateTime.t(), atom()) :: %{
@ -43,7 +49,7 @@ defmodule Microwaveprop.Rover.CandidateDetail do
max_obstacle = max_obstacle =
profile profile
|> middle_samples() |> middle_samples()
|> Enum.map(& &1.elev) |> Enum.map(fn pt -> pt.elev + round(pt.building_m) end)
|> safe_max() |> safe_max()
clearance = clearance =
@ -93,7 +99,13 @@ defmodule Microwaveprop.Rover.CandidateDetail do
@sample_count @sample_count
) do ) do
{:ok, points} -> {:ok, points} ->
Enum.map(points, fn pt -> %{dist_km: pt.dist_km, elev: pt.elev} end) Enum.map(points, fn pt ->
%{
dist_km: pt.dist_km,
elev: pt.elev,
building_m: BuildingsIndex.max_height_near(pt.lat, pt.lon, @profile_building_radius_m)
}
end)
_ -> _ ->
[] []

View file

@ -791,9 +791,10 @@ defmodule MicrowavepropWeb.RoverLive do
defp profile_to_svg([]), do: nil defp profile_to_svg([]), do: nil
defp profile_to_svg(points) do defp profile_to_svg(points) do
tops = Enum.map(points, fn pt -> pt.elev + round(Map.get(pt, :building_m, 0.0)) end)
elevs = Enum.map(points, & &1.elev) elevs = Enum.map(points, & &1.elev)
min_e = Enum.min(elevs) min_e = Enum.min(elevs)
max_e = Enum.max(elevs) max_e = max(Enum.max(elevs), Enum.max(tops))
span = max(max_e - min_e, 1) span = max(max_e - min_e, 1)
first = List.first(points) first = List.first(points)
last = List.last(points) last = List.last(points)
@ -808,12 +809,38 @@ defmodule MicrowavepropWeb.RoverLive do
fill = "0,60 " <> line <> " 240,60" fill = "0,60 " <> line <> " 240,60"
has_buildings? = Enum.any?(points, fn pt -> Map.get(pt, :building_m, 0.0) > 0.5 end)
building_fill =
if has_buildings? do
building_line =
Enum.map_join(points, " ", fn pt ->
x = pt.dist_km / total_km * 240.0
top = pt.elev + round(Map.get(pt, :building_m, 0.0))
y = 60.0 - (top - min_e) / span * 50.0
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
# Close back along the terrain so the building polygon sits on top.
terrain_back =
points
|> Enum.reverse()
|> Enum.map_join(" ", fn pt ->
x = pt.dist_km / total_km * 240.0
y = 60.0 - (pt.elev - min_e) / span * 50.0
"#{Float.round(x, 2)},#{Float.round(y, 2)}"
end)
building_line <> " " <> terrain_back
end
los_y_start = Float.round(60.0 - (first.elev - min_e) / span * 50.0, 2) los_y_start = Float.round(60.0 - (first.elev - min_e) / span * 50.0, 2)
los_y_end = Float.round(60.0 - (last.elev - min_e) / span * 50.0, 2) los_y_end = Float.round(60.0 - (last.elev - min_e) / span * 50.0, 2)
%{ %{
line: line, line: line,
fill: fill, fill: fill,
building_fill: building_fill,
start_elev_m: first.elev, start_elev_m: first.elev,
end_elev_m: last.elev, end_elev_m: last.elev,
total_km: total_km, total_km: total_km,
@ -1208,6 +1235,13 @@ defmodule MicrowavepropWeb.RoverLive do
~H""" ~H"""
<svg viewBox="0 0 240 64" class="w-full h-12 mt-1" preserveAspectRatio="none"> <svg viewBox="0 0 240 64" class="w-full h-12 mt-1" preserveAspectRatio="none">
<polygon points={@svg.fill} fill="rgb(14 165 233 / 0.25)" /> <polygon points={@svg.fill} fill="rgb(14 165 233 / 0.25)" />
<polygon
:if={@svg.building_fill}
points={@svg.building_fill}
fill="rgb(220 38 38 / 0.55)"
stroke="#dc2626"
stroke-width="0.6"
/>
<polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" /> <polyline points={@svg.line} fill="none" stroke="#0ea5e9" stroke-width="1.2" />
<line <line
x1="0" x1="0"