diff --git a/assets/js/rover_map_hook.js b/assets/js/rover_map_hook.js index c39701ef..c31411ba 100644 --- a/assets/js/rover_map_hook.js +++ b/assets/js/rover_map_hook.js @@ -1,11 +1,8 @@ export const RoverMap = { mounted() { this.stations = [] - this.stops = [] this.stationMarkers = L.layerGroup() - this.stopMarkers = L.layerGroup() - this.routeLine = null - this.terrainLines = L.layerGroup() + this.coverageLayer = L.layerGroup() const map = L.map(this.el, { center: [33.2, -97.1], @@ -20,30 +17,15 @@ export const RoverMap = { this.map = map this.stationMarkers.addTo(map) - this.stopMarkers.addTo(map) - this.terrainLines.addTo(map) + this.coverageLayer.addTo(map) - // Draw Maidenhead grid overlay - this.drawGridOverlay() - - // Click to add stop - map.on("click", (e) => { - this.pushEvent("add_stop", { lat: e.latlng.lat, lon: e.latlng.lng }) - }) - - // Handle server events this.handleEvent("stations_updated", ({ stations }) => { this.stations = stations this.renderStations() }) - this.handleEvent("stops_updated", ({ stops }) => { - this.stops = stops - this.renderStops() - }) - - this.handleEvent("stop_terrain", ({ index, reachable }) => { - this.renderTerrainLines(index, reachable) + this.handleEvent("coverage_updated", ({ grids }) => { + this.renderCoverage(grids) }) }, @@ -57,7 +39,8 @@ export const RoverMap = { weight: 2, fillColor: "#3b82f6", fillOpacity: 0.9, - interactive: true + interactive: true, + pane: "markerPane" }) marker.bindTooltip(s.label, { @@ -73,121 +56,105 @@ export const RoverMap = { this.fitBounds() }, - renderStops() { - this.stopMarkers.clearLayers() - this.terrainLines.clearLayers() + renderCoverage(grids) { + this.coverageLayer.clearLayers() - if (this.routeLine) { - this.map.removeLayer(this.routeLine) - this.routeLine = null - } + if (!grids || grids.length === 0) return - const routeCoords = [] + const maxScore = Math.max(...grids.map(g => g.coverage_score)) - for (const stop of this.stops) { - routeCoords.push([stop.lat, stop.lon]) + for (const g of grids) { + // 4-char Maidenhead: 2° lon × 1° lat + // Compute grid square bounds from the grid letters + const bounds = this.gridBounds(g.grid) + if (!bounds) continue - const color = stop.score >= 65 ? "#00ffa3" : stop.score >= 50 ? "#ffe566" : stop.score >= 33 ? "#ff9044" : "#ff4f4f" + const normalized = maxScore > 0 ? g.coverage_score / maxScore : 0 + const color = this.coverageColor(normalized) + const opacity = 0.15 + normalized * 0.45 - const marker = L.circleMarker([stop.lat, stop.lon], { - radius: 10, - color: "#fff", - weight: 3, + const rect = L.rectangle(bounds, { + color: color, + weight: 1, + opacity: 0.5, fillColor: color, - fillOpacity: 0.9, + fillOpacity: opacity, interactive: true }) - // Number label + const pctStations = Math.round(g.stations_in_range / g.total_stations * 100) + rect.bindTooltip( + `${g.grid}
` + + `Coverage: ${g.coverage_score}/100
` + + `Stations: ${g.stations_in_range}/${g.total_stations} (${pctStations}%)
` + + `Propagation: ${g.prop_score}/100` + + (g.best_hour ? `
Best: ${g.best_hour} UTC` : ""), + { sticky: true } + ) + + rect.on("click", () => { + this.pushEvent("select_grid", { grid: g.grid }) + }) + + this.coverageLayer.addLayer(rect) + } + + // Add rank labels to top 5 + const top5 = grids.slice(0, 5) + for (let i = 0; i < top5.length; i++) { + const g = top5[i] const icon = L.divIcon({ html: `
${stop.index + 1}
`, + font-weight: bold; font-size: 12px; + box-shadow: 0 2px 4px rgba(0,0,0,0.4); + ">${i + 1}`, className: "", - iconSize: [22, 22], - iconAnchor: [11, 11] + iconSize: [24, 24], + iconAnchor: [12, 12] }) - const iconMarker = L.marker([stop.lat, stop.lon], { icon, interactive: false }) - - iconMarker.bindTooltip(`${stop.grid} — Score: ${stop.score || "?"}, ${stop.reachable_count} workable`, { - direction: "top", - offset: [0, -14] - }) - - this.stopMarkers.addLayer(marker) - this.stopMarkers.addLayer(iconMarker) - } - - // Route line - if (routeCoords.length >= 2) { - this.routeLine = L.polyline(routeCoords, { - color: "#fff", - weight: 3, - opacity: 0.7, - dashArray: "8 6" - }).addTo(this.map) + L.marker([g.lat, g.lon], { icon, interactive: false, pane: "markerPane" }) + .addTo(this.coverageLayer) } }, - renderTerrainLines(stopIndex, reachable) { - const stop = this.stops[stopIndex] - if (!stop) return + gridBounds(grid) { + // Decode 4-char Maidenhead to lat/lon bounds + if (grid.length < 4) return null + const lonField = grid.charCodeAt(0) - 65 // A=0 + const latField = grid.charCodeAt(1) - 65 + const lonSq = parseInt(grid[2]) + const latSq = parseInt(grid[3]) - for (const r of reachable) { - const color = r.workable ? "#00ffa3" : r.verdict === "BLOCKED" ? "#ff4f4f" : "#ffe566" - const line = L.polyline([[stop.lat, stop.lon], [r.lat, r.lon]], { - color, - weight: 1.5, - opacity: 0.5, - dashArray: r.workable ? null : "4 4" - }) + const lon1 = lonField * 20 - 180 + lonSq * 2 + const lat1 = latField * 10 - 90 + latSq * 1 + const lon2 = lon1 + 2 + const lat2 = lat1 + 1 - line.bindTooltip(`${r.label}: ${r.dist_km} km, ${r.verdict || "?"}${r.diffraction_db > 0 ? ` (${r.diffraction_db} dB)` : ""}`) - - this.terrainLines.addLayer(line) - } + return [[lat1, lon1], [lat2, lon2]] }, - drawGridOverlay() { - const gridLayer = L.layerGroup() - - // Draw 4-char Maidenhead grid lines (2° lon x 1° lat) - for (let lat = 25; lat <= 50; lat += 1) { - L.polyline([[lat, -125], [lat, -66]], { - color: "#888", - weight: 0.5, - opacity: 0.3 - }).addTo(gridLayer) - } - for (let lon = -125; lon <= -66; lon += 2) { - L.polyline([[25, lon], [50, lon]], { - color: "#888", - weight: 0.5, - opacity: 0.3 - }).addTo(gridLayer) - } - - gridLayer.addTo(this.map) + coverageColor(normalized) { + // Green (best) → Yellow → Red (worst) + if (normalized >= 0.7) return "#00ffa3" + if (normalized >= 0.5) return "#7dffd4" + if (normalized >= 0.3) return "#ffe566" + if (normalized >= 0.15) return "#ff9044" + return "#ff4f4f" }, fitBounds() { - const allPoints = [ - ...this.stations.map(s => [s.lat, s.lon]), - ...this.stops.map(s => [s.lat, s.lon]) - ] - - if (allPoints.length >= 2) { - this.map.fitBounds(allPoints, { padding: [40, 40], maxZoom: 10 }) - } else if (allPoints.length === 1) { - this.map.setView(allPoints[0], 8) + const points = this.stations.map(s => [s.lat, s.lon]) + if (points.length >= 2) { + this.map.fitBounds(points, { padding: [60, 60], maxZoom: 9 }) + } else if (points.length === 1) { + this.map.setView(points[0], 8) } } } diff --git a/lib/microwaveprop_web/live/rover_live.ex b/lib/microwaveprop_web/live/rover_live.ex index d8326a49..75fbabc1 100644 --- a/lib/microwaveprop_web/live/rover_live.ex +++ b/lib/microwaveprop_web/live/rover_live.ex @@ -1,5 +1,9 @@ defmodule MicrowavepropWeb.RoverLive do - @moduledoc false + @moduledoc """ + Rover planner: enter stationary stations you want to work, select a band, + and the map colors areas by coverage quality — how many stations you can + reach from each point given current terrain and propagation conditions. + """ use MicrowavepropWeb, :live_view alias Microwaveprop.Geo @@ -28,15 +32,37 @@ defmodule MicrowavepropWeb.RoverLive do band: 10_000, stations: [], station_input: "", - stops: [], - selected_stop: nil, - stop_detail: nil, + coverage: [], + selected_grid: nil, resolving: false, - computing_stop: false + computing: false, + url_loaded: false )} end - # ── Station Management ── + @impl true + def handle_params(params, _uri, socket) do + if socket.assigns.url_loaded do + {:noreply, socket} + else + band = if params["band"], do: String.to_integer(params["band"]), else: socket.assigns.band + + socket = assign(socket, band: band, url_loaded: true) + + # Load stations from URL: ?stations=W5ISP,K5TR,EM00cd + case params["stations"] do + nil -> + {:noreply, socket} + + stations_str -> + calls = stations_str |> String.split(",") |> Enum.map(&String.trim/1) |> Enum.reject(&(&1 == "")) + send(self(), {:resolve_station_list, calls}) + {:noreply, assign(socket, resolving: true)} + end + end + end + + # ── Events ── @impl true def handle_event("add_station", %{"callsign" => input}, socket) do @@ -53,134 +79,245 @@ defmodule MicrowavepropWeb.RoverLive do def handle_event("remove_station", %{"index" => idx_str}, socket) do idx = String.to_integer(idx_str) stations = List.delete_at(socket.assigns.stations, idx) - {:noreply, socket |> assign(stations: stations) |> push_stations()} + socket = assign(socket, stations: stations, coverage: [], selected_grid: nil) + if length(stations) >= 2, do: send(self(), :compute_coverage) + socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) + {:noreply, push_url(socket)} end def handle_event("select_band", %{"band" => band_str}, socket) do - {:noreply, socket |> assign(band: String.to_integer(band_str)) |> push_stations()} + socket = assign(socket, band: String.to_integer(band_str), coverage: [], selected_grid: nil) + if length(socket.assigns.stations) >= 2, do: send(self(), :compute_coverage) + {:noreply, push_url(socket)} end - # ── Stop Management (from JS map clicks) ── - - def handle_event("add_stop", %{"lat" => lat, "lon" => lon}, socket) do - grid = Geo.latlon_to_grid4(lat, lon) - {clat, clon} = Geo.maidenhead_center(grid) || {lat, lon} - - stop = %{ - grid: grid, - lat: clat, - lon: clon, - score: nil, - forecast: [], - reachable: [] - } - - # Get propagation score - score_detail = Propagation.point_detail(socket.assigns.band, clat, clon) - forecast = Propagation.point_forecast(socket.assigns.band, clat, clon) - - stop = %{stop | score: score_detail && score_detail.score, forecast: forecast} - - stops = socket.assigns.stops ++ [stop] - - # Async compute terrain to each station - send(self(), {:compute_stop_terrain, length(stops) - 1}) - - {:noreply, socket |> assign(stops: stops, computing_stop: true) |> push_stops()} + def handle_event("select_grid", %{"grid" => grid}, socket) do + detail = Enum.find(socket.assigns.coverage, &(&1.grid == grid)) + {:noreply, assign(socket, selected_grid: detail)} end - def handle_event("remove_stop", %{"index" => idx_str}, socket) do - idx = String.to_integer(idx_str) - stops = List.delete_at(socket.assigns.stops, idx) + # ── Async ── - selected = - if socket.assigns.selected_stop == idx, do: nil, else: socket.assigns.selected_stop + def handle_info({:resolve_station_list, calls}, socket) do + stations = + calls + |> Task.async_stream(&resolve_station/1, max_concurrency: 4, timeout: 10_000) + |> Enum.flat_map(fn + {:ok, {:ok, s}} -> [s] + _ -> [] + end) - {:noreply, socket |> assign(stops: stops, selected_stop: selected, stop_detail: nil) |> push_stops()} + socket = + socket + |> assign(stations: stations, resolving: false) + |> push_event("stations_updated", %{stations: station_data(stations)}) + + if length(stations) >= 2, do: send(self(), :compute_coverage) + {:noreply, socket} end - def handle_event("select_stop", %{"index" => idx_str}, socket) do - idx = String.to_integer(idx_str) - stop = Enum.at(socket.assigns.stops, idx) - - {:noreply, assign(socket, selected_stop: idx, stop_detail: stop)} - end - - # ── Async Handlers ── - @impl true def handle_info({:resolve_station, input}, socket) do - result = resolve_station(input) - - case result do + case resolve_station(input) do {:ok, station} -> stations = socket.assigns.stations ++ [station] - {:noreply, socket |> assign(stations: stations, resolving: false) |> push_stations()} + socket = assign(socket, stations: stations, resolving: false) + socket = push_event(socket, "stations_updated", %{stations: station_data(stations)}) + if length(stations) >= 2, do: send(self(), :compute_coverage) + {:noreply, push_url(socket)} {:error, _reason} -> {:noreply, socket |> assign(resolving: false) |> put_flash(:error, "Could not find: #{input}")} end end - def handle_info({:compute_stop_terrain, stop_idx}, socket) do - stop = Enum.at(socket.assigns.stops, stop_idx) + def handle_info(:compute_coverage, socket) do + stations = socket.assigns.stations + band_mhz = socket.assigns.band + band_config = BandConfig.get(band_mhz) || BandConfig.get(10_000) + max_range = band_config.extended_range_km || 500 - if is_nil(stop) do - {:noreply, assign(socket, computing_stop: false)} + socket = assign(socket, computing: true) + + # Find the bounding box around all stations, expanded by max range + lats = Enum.map(stations, & &1.lat) + lons = Enum.map(stations, & &1.lon) + # ~1 degree ≈ 111 km + range_deg = max_range / 111.0 + + min_lat = max(Enum.min(lats) - range_deg, 25.0) + max_lat = min(Enum.max(lats) + range_deg, 50.0) + min_lon = max(Enum.min(lons) - range_deg, -125.0) + max_lon = min(Enum.max(lons) + range_deg, -66.0) + + # Generate candidate grids (4-char Maidenhead = 2° lon × 1° lat) + for_result = + for lat <- Stream.iterate(Float.ceil(min_lat), &(&1 + 0.5)), + lat <= max_lat, + lon <- Stream.iterate(Float.ceil(min_lon), &(&1 + 1.0)), + lon <= max_lon do + grid = Geo.latlon_to_grid4(lat, lon) + {grid, lat, lon} + end + + candidates = + Enum.uniq_by(for_result, fn {grid, _, _} -> grid end) + + # Score each candidate: how many stations in range × propagation quality + coverage = + candidates + |> Task.async_stream( + fn {grid, lat, lon} -> + score_grid(grid, lat, lon, stations, band_mhz, band_config, max_range) + end, + max_concurrency: System.schedulers_online(), + timeout: 30_000 + ) + |> Enum.flat_map(fn + {:ok, nil} -> [] + {:ok, result} -> [result] + _ -> [] + end) + |> Enum.sort_by(& &1.coverage_score, :desc) + + # Push to JS for rendering + grid_data = + Enum.map(coverage, fn c -> + %{ + grid: c.grid, + lat: c.lat, + lon: c.lon, + coverage_score: c.coverage_score, + stations_in_range: c.stations_in_range, + workable_count: c.workable_count, + total_stations: length(stations), + prop_score: c.prop_score, + best_hour: c.best_hour + } + end) + + socket = + socket + |> assign(coverage: coverage, computing: false, selected_grid: nil) + |> push_event("coverage_updated", %{grids: grid_data}) + + {:noreply, socket} + end + + # ── Coverage Scoring ── + + defp score_grid(grid, lat, lon, stations, band_mhz, _band_config, max_range) do + freq_ghz = band_mhz / 1000 + + # Analyze terrain + distance to each station + station_analyses = + Enum.map(stations, fn s -> + dist = Geo.haversine_km(lat, lon, s.lat, s.lon) + in_range = dist <= max_range + + # Run SRTM terrain analysis for in-range stations + {verdict, diffraction_db} = + if in_range do + case ElevationClient.fetch_elevation_profile(lat, lon, s.lat, s.lon, 64, download: true) do + {:ok, profile} -> + analysis = TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0) + {analysis.verdict, analysis.diffraction_db} + + {:error, _} -> + {nil, 0} + end + else + {nil, 0} + end + + # Path quality combines terrain AND propagation conditions. + # BLOCKED paths are still workable with ducting/enhanced propagation — + # that's the whole point of this app. Higher propagation score at this + # grid means atmospheric conditions can overcome terrain blockage. + path_quality = + case verdict do + "CLEAR" -> 1.0 + "FRESNEL_MINOR" -> 0.9 + "FRESNEL_PARTIAL" -> 0.7 + "BLOCKED" when diffraction_db < 10 -> 0.5 + "BLOCKED" when diffraction_db < 20 -> 0.35 + "BLOCKED" when diffraction_db < 40 -> 0.2 + "BLOCKED" -> 0.1 + _ -> 0.4 + end + + %{ + label: s.label, + lat: s.lat, + lon: s.lon, + dist_km: dist, + in_range: in_range, + verdict: verdict, + diffraction_db: diffraction_db && Float.round(diffraction_db, 1), + path_quality: path_quality + } + end) + + in_range = Enum.filter(station_analyses, & &1.in_range) + + if in_range == [] do + nil else - stations = socket.assigns.stations - band_config = BandConfig.get(socket.assigns.band) || BandConfig.get(10_000) - freq_ghz = socket.assigns.band / 1000 + # Path-quality-weighted station score: combines terrain + atmospheric potential + path_score = Enum.sum(Enum.map(in_range, & &1.path_quality)) / length(stations) - reachable = - stations - |> Task.async_stream( - fn station -> - dist = Geo.haversine_km(stop.lat, stop.lon, station.lat, station.lon) - bearing = Geo.bearing_deg(stop.lat, stop.lon, station.lat, station.lon) + # Distance factor + distance_factor = + in_range + |> Enum.map(fn s -> max(0, 1.0 - s.dist_km / max_range) end) + |> then(&(Enum.sum(&1) / length(stations))) - terrain = - case ElevationClient.fetch_elevation_profile(stop.lat, stop.lon, station.lat, station.lon, 64, - download: true - ) do - {:ok, profile} -> - TerrainAnalysis.analyse(profile, dist, freq_ghz, ant_ht_a: 3.0, ant_ht_b: 10.0) + # Propagation + ducting: best score in next 12 hours from HRRR forecast + forecast = Propagation.point_forecast(band_mhz, lat, lon) - {:error, _} -> - nil - end + {prop_score, best_hour} = + case forecast do + [_ | _] -> + best = Enum.max_by(forecast, & &1.score) + {best.score, Calendar.strftime(best.valid_time, "%H:%M")} - in_range = dist <= (band_config.extended_range_km || 500) + _ -> + {50, nil} + end - %{ - label: station.label, - lat: station.lat, - lon: station.lon, - dist_km: Float.round(dist, 1), - bearing: bearing, - verdict: terrain && terrain.verdict, - diffraction_db: terrain && Float.round(terrain.diffraction_db, 1), - in_range: in_range, - workable: in_range and terrain != nil and terrain.verdict != "BLOCKED" - } - end, - max_concurrency: 4, - timeout: 15_000 + # Combined: path quality (terrain + ducting potential) 30% + # + propagation forecast 30% + # + distance factor 20% + # + station count 20% + # Propagation score amplifies blocked-path viability: score 80+ means + # ducting conditions that can overcome 30+ dB of terrain blockage. + station_pct = length(in_range) / length(stations) + workable_count = Enum.count(in_range, &(&1.path_quality >= 0.2)) + + # Boost path_quality by propagation: good conditions make blocked paths viable + prop_boost = prop_score / 100 + boosted_path_score = min(1.0, path_score + prop_boost * 0.3) + + coverage_score = + round( + boosted_path_score * 30 + + prop_boost * 30 + + distance_factor * 20 + + station_pct * 20 ) - |> Enum.map(fn - {:ok, r} -> r - _ -> nil - end) - |> Enum.reject(&is_nil/1) - stop = %{stop | reachable: reachable} - stops = List.replace_at(socket.assigns.stops, stop_idx, stop) - - {:noreply, - socket - |> assign(stops: stops, computing_stop: false) - |> push_stops() - |> push_event("stop_terrain", %{index: stop_idx, reachable: reachable})} + %{ + grid: grid, + lat: lat, + lon: lon, + coverage_score: coverage_score, + stations_in_range: length(in_range), + workable_count: workable_count, + prop_score: prop_score, + best_hour: best_hour, + station_details: station_analyses, + forecast: forecast + } end end @@ -208,69 +345,39 @@ defmodule MicrowavepropWeb.RoverLive do end end - defp push_stations(socket) do - data = - Enum.map(socket.assigns.stations, fn s -> - %{label: s.label, lat: s.lat, lon: s.lon} + defp push_url(socket) do + labels = Enum.map_join(socket.assigns.stations, ",", & &1.label) + + params = + then(%{"band" => to_string(socket.assigns.band)}, fn p -> + if labels == "", do: p, else: Map.put(p, "stations", labels) end) - push_event(socket, "stations_updated", %{stations: data}) + push_patch(socket, to: ~p"/rover?#{params}", replace: true) end - defp push_stops(socket) do - data = - Enum.with_index(socket.assigns.stops, fn stop, idx -> - %{ - index: idx, - grid: stop.grid, - lat: stop.lat, - lon: stop.lon, - score: stop.score, - reachable_count: Enum.count(stop.reachable, & &1.workable) - } - end) - - push_event(socket, "stops_updated", %{stops: data}) + defp station_data(stations) do + Enum.map(stations, fn s -> %{label: s.label, lat: s.lat, lon: s.lon} end) end - defp total_driving_km(stops) do - stops - |> Enum.chunk_every(2, 1, :discard) - |> Enum.map(fn [a, b] -> Geo.haversine_km(a.lat, a.lon, b.lat, b.lon) end) - |> Enum.sum() - |> Float.round(1) - end - - defp total_workable(stops) do - stops - |> Enum.flat_map(fn stop -> - stop.reachable - |> Enum.filter(& &1.workable) - |> Enum.map(fn r -> {stop.grid, r.label} end) - end) - |> Enum.uniq() - |> length() - end - - defp tier_color(score) when score >= 80, do: "#00ffa3" - defp tier_color(score) when score >= 65, do: "#7dffd4" - defp tier_color(score) when score >= 50, do: "#ffe566" - defp tier_color(score) when score >= 33, do: "#ff9044" - defp tier_color(_), do: "#ff4f4f" - defp verdict_badge("CLEAR"), do: "badge badge-success badge-sm" defp verdict_badge("FRESNEL_MINOR"), do: "badge badge-warning badge-sm" defp verdict_badge("FRESNEL_PARTIAL"), do: "badge badge-warning badge-sm" defp verdict_badge("BLOCKED"), do: "badge badge-error badge-sm" defp verdict_badge(_), do: "badge badge-sm" + defp tier_color(score) when score >= 80, do: "#00ffa3" + defp tier_color(score) when score >= 65, do: "#7dffd4" + defp tier_color(score) when score >= 50, do: "#ffe566" + defp tier_color(score) when score >= 33, do: "#ff9044" + defp tier_color(_), do: "#ff4f4f" + # ── Render ── @impl true def render(assigns) do ~H"""
- <%!-- Map --%>
Rover Planner
- <%!-- Band selector --%> - <%= for {label, value} <- @band_options do %> <% end %> - <%!-- Add station --%>
- <%!-- Station list --%> <%= if @stations != [] do %>
Stationary Stations ({length(@stations)})
@@ -333,58 +433,57 @@ defmodule MicrowavepropWeb.RoverLive do
<% end %> - <%!-- Instructions --%> - <%= if @stations != [] and @stops == [] do %> -
- Click the map to add operating positions + <%= if @computing do %> +
+ +
Computing coverage areas...
<% end %> - <%!-- Route stops --%> - <%= if @stops != [] do %> -
-
- Route ({length(@stops)} stops, {total_driving_km(@stops)} km driving) + <%= if @stations != [] and !@computing and @coverage == [] do %> +
+ Add at least 2 stations to see coverage
+ <% end %> - <%= for {stop, idx} <- Enum.with_index(@stops) do %> + <%!-- Top grids ranked --%> + <%= if @coverage != [] do %> +
+
Best Operating Grids
+ + <%= for {grid, rank} <- @coverage |> Enum.take(10) |> Enum.with_index(1) do %>
- {idx + 1} - {stop.grid} -
-
- <%= if stop.score do %> - - {stop.score} - - <% end %> - + {rank} + + {grid.grid} +
+
+ {grid.coverage_score}
- <%= if stop.reachable != [] do %> -
- {Enum.count(stop.reachable, & &1.workable)}/{length(stop.reachable)} stations workable -
+
+ {grid.workable_count}/{grid.total_stations} workable + Prop: {grid.prop_score} +
+ <%= if grid.best_hour do %> +
Best at {grid.best_hour} UTC
<% end %> - <%= if stop.forecast != [] do %> -
- <%= for point <- stop.forecast do %> + <%= if grid.forecast != [] do %> +
+ <%= for point <- grid.forecast do %>
<% end %> - - <%!-- Route summary --%> -
-
Route Summary
-
Unique grid-station pairs: {total_workable(@stops)}
-
Total driving: {total_driving_km(@stops)} km
-
- Est. drive time: {Float.round(total_driving_km(@stops) * 1.5 / 100, 1)} hrs -
-
<% end %> - <%!-- Selected stop detail --%> - <%= if @stop_detail do %> + <%!-- Selected grid detail --%> + <%= if @selected_grid do %>
- Stop Detail: {@stop_detail.grid} + {@selected_grid.grid} — Station Distances
- <%= if @computing_stop do %> -
- Computing terrain... -
- <% end %> - <%= for r <- @stop_detail.reachable do %> + <%= for s <- Enum.sort_by(@selected_grid.station_details, & &1.dist_km) do %>
- {r.label} - {r.dist_km} km / {r.bearing}° + {s.label} + {Float.round(s.dist_km, 0)} km
-
- <%= if r.verdict do %> - {r.verdict} +
+ <%= if s.verdict do %> + {s.verdict} <% end %> - <%= if r.diffraction_db && r.diffraction_db > 0 do %> - {r.diffraction_db} dB + <%= if s.diffraction_db && s.diffraction_db > 0 do %> + {s.diffraction_db} dB + <% end %> + <%= if !s.in_range do %> + out of range <% end %>