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