perf(contact-map): client-side band filtering via per-band layer groups
Band toggles previously roundtripped through LiveView and rebuilt every polyline + popup + hover handler on each click. Now the hook builds one L.LayerGroup per band once after the initial fetch; toggling is just addLayer/removeLayer on the map (O(bands), not O(contacts)). Checkbox and All/None events are handled directly in the hook via delegated document listeners — no LV roundtrip. Callsign filter still roundtrips (debounced) since it's cross-cutting, and now iterates pre-built polylines without recreating them.
This commit is contained in:
parent
2a57074a47
commit
6972feb2c2
2 changed files with 237 additions and 136 deletions
|
|
@ -6,35 +6,53 @@ type ContactTuple = [
|
||||||
]
|
]
|
||||||
|
|
||||||
interface ApplyFilterPayload {
|
interface ApplyFilterPayload {
|
||||||
enabled_bands: number[]
|
|
||||||
callsign: string | null
|
callsign: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LineEntry {
|
||||||
|
line: L.Polyline
|
||||||
|
band: number
|
||||||
|
s1: string
|
||||||
|
s2: string
|
||||||
|
lat1: number
|
||||||
|
lon1: number
|
||||||
|
lat2: number
|
||||||
|
lon2: number
|
||||||
|
}
|
||||||
|
|
||||||
interface ContactsMapHook extends ViewHook {
|
interface ContactsMapHook extends ViewHook {
|
||||||
allContacts: ContactTuple[]
|
allContacts: ContactTuple[]
|
||||||
callsignFilter: string
|
callsignFilter: string
|
||||||
enabledBands: Set<number>
|
enabledBands: Set<number>
|
||||||
lineLayer: L.LayerGroup
|
bandGroups: Map<number, L.LayerGroup>
|
||||||
|
lines: LineEntry[]
|
||||||
dotLayer: L.LayerGroup
|
dotLayer: L.LayerGroup
|
||||||
map: L.Map
|
map: L.Map
|
||||||
|
delegatedChange: (e: Event) => void
|
||||||
|
delegatedClick: (e: Event) => void
|
||||||
initMap(this: ContactsMapHook): void
|
initMap(this: ContactsMapHook): void
|
||||||
rebuildMap(this: ContactsMapHook): void
|
buildLines(this: ContactsMapHook): void
|
||||||
|
syncBandLayers(this: ContactsMapHook): void
|
||||||
|
applyCallsignFilter(this: ContactsMapHook): void
|
||||||
|
rebuildDots(this: ContactsMapHook): void
|
||||||
|
updateCount(this: ContactsMapHook, count: number): void
|
||||||
|
wireControls(this: ContactsMapHook): void
|
||||||
|
unwireControls(this: ContactsMapHook): void
|
||||||
}
|
}
|
||||||
|
|
||||||
// Band MHz -> color mapping
|
|
||||||
const BAND_COLORS: Record<number, string> = {
|
const BAND_COLORS: Record<number, string> = {
|
||||||
1296: "#475569", // slate-600
|
1296: "#475569",
|
||||||
2304: "#7c3aed", // violet-600
|
2304: "#7c3aed",
|
||||||
3400: "#4f46e5", // indigo-600
|
3400: "#4f46e5",
|
||||||
5760: "#2563eb", // blue-600
|
5760: "#2563eb",
|
||||||
10000: "#059669", // emerald-600
|
10000: "#059669",
|
||||||
24000: "#d97706", // amber-600
|
24000: "#d97706",
|
||||||
47000: "#ea580c", // orange-600
|
47000: "#ea580c",
|
||||||
68000: "#dc2626", // red-600
|
68000: "#dc2626",
|
||||||
75000: "#c026d3", // fuchsia-600
|
75000: "#c026d3",
|
||||||
122000: "#db2777", // pink-600
|
122000: "#db2777",
|
||||||
134000: "#e11d48", // rose-600
|
134000: "#e11d48",
|
||||||
241000: "#b91c1c", // red-700
|
241000: "#b91c1c",
|
||||||
}
|
}
|
||||||
|
|
||||||
function bandLabel(mhz: number): string {
|
function bandLabel(mhz: number): string {
|
||||||
|
|
@ -51,44 +69,90 @@ function bandColor(band: number): string {
|
||||||
|
|
||||||
export const ContactsMap = {
|
export const ContactsMap = {
|
||||||
mounted(this: ContactsMapHook) {
|
mounted(this: ContactsMapHook) {
|
||||||
// Contacts payload (~5 MB) comes from a standalone HTTP endpoint that
|
|
||||||
// serves pre-gzipped JSON. This keeps it out of the initial HTML and
|
|
||||||
// lets the browser cache it independently of the LiveView.
|
|
||||||
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
const fetchUrl = this.el.dataset.fetchUrl || "/api/contacts/map"
|
||||||
this.allContacts = []
|
this.allContacts = []
|
||||||
this.callsignFilter = ""
|
this.callsignFilter = ""
|
||||||
this.enabledBands = new Set()
|
this.enabledBands = new Set()
|
||||||
this.lineLayer = L.layerGroup()
|
this.bandGroups = new Map()
|
||||||
|
this.lines = []
|
||||||
this.dotLayer = L.layerGroup()
|
this.dotLayer = L.layerGroup()
|
||||||
|
|
||||||
// Listen for filter changes from LiveView
|
// Callsign filter still roundtrips through LiveView (debounced) — it's
|
||||||
this.handleEvent("apply_filter", ({enabled_bands, callsign}: ApplyFilterPayload) => {
|
// cross-cutting and fires rarely. Band toggles are purely client-side.
|
||||||
this.enabledBands = new Set(enabled_bands)
|
this.handleEvent("apply_filter", ({callsign}: ApplyFilterPayload) => {
|
||||||
this.callsignFilter = (callsign || "").toUpperCase()
|
this.callsignFilter = (callsign || "").toUpperCase()
|
||||||
this.rebuildMap()
|
this.applyCallsignFilter()
|
||||||
|
this.rebuildDots()
|
||||||
})
|
})
|
||||||
|
|
||||||
// Defer map init until container has dimensions
|
this.wireControls()
|
||||||
|
|
||||||
requestAnimationFrame(() => this.initMap())
|
requestAnimationFrame(() => this.initMap())
|
||||||
|
|
||||||
// Fetch contacts in parallel with map init. Once both are done,
|
|
||||||
// `rebuildMap()` draws everything. Starts earlier than a LiveView
|
|
||||||
// push_event because the HTTP request doesn't wait for the socket
|
|
||||||
// handshake.
|
|
||||||
// No explicit Accept header — the :browser pipeline's `plug :accepts`
|
|
||||||
// only whitelists "html", so setting accept: application/json triggers
|
|
||||||
// a 406. The default `*/*` matches fine and we still parse the body
|
|
||||||
// as JSON ourselves.
|
|
||||||
fetch(fetchUrl)
|
fetch(fetchUrl)
|
||||||
.then(resp => resp.json())
|
.then(resp => resp.json())
|
||||||
.then(contacts => {
|
.then(contacts => {
|
||||||
this.allContacts = contacts
|
this.allContacts = contacts
|
||||||
for (const c of contacts) this.enabledBands.add(c[4])
|
for (const c of contacts) this.enabledBands.add(c[4])
|
||||||
if (this.map) this.rebuildMap()
|
if (this.map) {
|
||||||
|
this.buildLines()
|
||||||
|
this.syncBandLayers()
|
||||||
|
this.rebuildDots()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
.catch(err => console.error("Failed to load contacts:", err))
|
.catch(err => console.error("Failed to load contacts:", err))
|
||||||
},
|
},
|
||||||
|
|
||||||
|
destroyed(this: ContactsMapHook) {
|
||||||
|
this.unwireControls()
|
||||||
|
},
|
||||||
|
|
||||||
|
wireControls(this: ContactsMapHook) {
|
||||||
|
// Band checkboxes and All/None buttons live outside this hook's element
|
||||||
|
// (they're in the sidebar), so delegate from the document. The checkboxes
|
||||||
|
// have data-band and the bulk buttons have data-band-action="all"|"none".
|
||||||
|
this.delegatedChange = (e: Event) => {
|
||||||
|
const t = e.target as HTMLInputElement
|
||||||
|
if (!t || !t.matches || !t.matches("input[data-band]")) return
|
||||||
|
const band = parseInt(t.dataset.band || "", 10)
|
||||||
|
if (!Number.isFinite(band)) return
|
||||||
|
if (t.checked) this.enabledBands.add(band)
|
||||||
|
else this.enabledBands.delete(band)
|
||||||
|
this.syncBandLayers()
|
||||||
|
this.rebuildDots()
|
||||||
|
}
|
||||||
|
|
||||||
|
this.delegatedClick = (e: Event) => {
|
||||||
|
const t = e.target as HTMLElement
|
||||||
|
if (!t || !t.matches || !t.matches("[data-band-action]")) return
|
||||||
|
const action = t.dataset.bandAction
|
||||||
|
const inputs = document.querySelectorAll<HTMLInputElement>("input[data-band]")
|
||||||
|
if (action === "all") {
|
||||||
|
this.enabledBands = new Set()
|
||||||
|
for (const inp of inputs) {
|
||||||
|
inp.checked = true
|
||||||
|
const band = parseInt(inp.dataset.band || "", 10)
|
||||||
|
if (Number.isFinite(band)) this.enabledBands.add(band)
|
||||||
|
}
|
||||||
|
} else if (action === "none") {
|
||||||
|
this.enabledBands = new Set()
|
||||||
|
for (const inp of inputs) inp.checked = false
|
||||||
|
} else {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
this.syncBandLayers()
|
||||||
|
this.rebuildDots()
|
||||||
|
}
|
||||||
|
|
||||||
|
document.addEventListener("change", this.delegatedChange)
|
||||||
|
document.addEventListener("click", this.delegatedClick)
|
||||||
|
},
|
||||||
|
|
||||||
|
unwireControls(this: ContactsMapHook) {
|
||||||
|
if (this.delegatedChange) document.removeEventListener("change", this.delegatedChange)
|
||||||
|
if (this.delegatedClick) document.removeEventListener("click", this.delegatedClick)
|
||||||
|
},
|
||||||
|
|
||||||
initMap(this: ContactsMapHook) {
|
initMap(this: ContactsMapHook) {
|
||||||
this.map = L.map(this.el, {
|
this.map = L.map(this.el, {
|
||||||
center: [37.5, -96],
|
center: [37.5, -96],
|
||||||
|
|
@ -103,41 +167,24 @@ export const ContactsMap = {
|
||||||
maxZoom: 19
|
maxZoom: 19
|
||||||
}).addTo(this.map)
|
}).addTo(this.map)
|
||||||
|
|
||||||
this.lineLayer.addTo(this.map)
|
|
||||||
this.dotLayer.addTo(this.map)
|
this.dotLayer.addTo(this.map)
|
||||||
|
|
||||||
// If contacts arrived before the map was ready, draw them now.
|
|
||||||
if (this.allContacts.length > 0) {
|
if (this.allContacts.length > 0) {
|
||||||
for (const c of this.allContacts) this.enabledBands.add(c[4])
|
for (const c of this.allContacts) this.enabledBands.add(c[4])
|
||||||
this.rebuildMap()
|
this.buildLines()
|
||||||
|
this.syncBandLayers()
|
||||||
|
this.rebuildDots()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
|
||||||
rebuildMap(this: ContactsMapHook) {
|
buildLines(this: ContactsMapHook) {
|
||||||
this.lineLayer.clearLayers()
|
// One-time build of every polyline, grouped into per-band LayerGroups.
|
||||||
this.dotLayer.clearLayers()
|
// After this, band toggling is just addLayer/removeLayer on the map —
|
||||||
|
// no polyline recreation ever.
|
||||||
|
this.lines = []
|
||||||
|
this.bandGroups = new Map()
|
||||||
|
|
||||||
const filtered = this.allContacts.filter(c => {
|
for (const c of this.allContacts) {
|
||||||
const [, , , , band, s1, s2] = c
|
|
||||||
if (!this.enabledBands.has(band)) return false
|
|
||||||
if (this.callsignFilter) {
|
|
||||||
const match = this.callsignFilter
|
|
||||||
const st1 = (s1 || "").toUpperCase()
|
|
||||||
const st2 = (s2 || "").toUpperCase()
|
|
||||||
if (!st1.includes(match) && !st2.includes(match)) return false
|
|
||||||
}
|
|
||||||
return true
|
|
||||||
})
|
|
||||||
|
|
||||||
// Update count displays
|
|
||||||
const countEl = document.getElementById("contact-count")
|
|
||||||
if (countEl) countEl.textContent = `${filtered.length.toLocaleString()} contacts`
|
|
||||||
const countMobile = document.getElementById("contact-count-mobile")
|
|
||||||
if (countMobile) countMobile.textContent = `${filtered.length.toLocaleString()} contacts`
|
|
||||||
|
|
||||||
// Draw lines
|
|
||||||
const endpointCounts: Record<string, number> = {}
|
|
||||||
for (const c of filtered) {
|
|
||||||
const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
|
const [lat1, lon1, lat2, lon2, band, s1, s2, mode, dist, ts, id] = c
|
||||||
const color = bandColor(band)
|
const color = bandColor(band)
|
||||||
|
|
||||||
|
|
@ -159,15 +206,64 @@ export const ContactsMap = {
|
||||||
line.on("mouseover", function(this: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) })
|
line.on("mouseover", function(this: L.Polyline) { this.setStyle({weight: 4, opacity: 1}) })
|
||||||
line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) })
|
line.on("mouseout", function(this: L.Polyline) { this.setStyle({weight: 2, opacity: 0.5}) })
|
||||||
|
|
||||||
this.lineLayer.addLayer(line)
|
let group = this.bandGroups.get(band)
|
||||||
|
if (!group) {
|
||||||
|
group = L.layerGroup()
|
||||||
|
this.bandGroups.set(band, group)
|
||||||
|
}
|
||||||
|
group.addLayer(line)
|
||||||
|
|
||||||
const k1 = `${lat1.toFixed(2)},${lon1.toFixed(2)}`
|
this.lines.push({
|
||||||
const k2 = `${lat2.toFixed(2)},${lon2.toFixed(2)}`
|
line,
|
||||||
|
band,
|
||||||
|
s1: (s1 || "").toUpperCase(),
|
||||||
|
s2: (s2 || "").toUpperCase(),
|
||||||
|
lat1, lon1, lat2, lon2
|
||||||
|
})
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
syncBandLayers(this: ContactsMapHook) {
|
||||||
|
// Fast path: flip whole-group on/off. O(bands), not O(contacts).
|
||||||
|
for (const [band, group] of this.bandGroups) {
|
||||||
|
const enabled = this.enabledBands.has(band)
|
||||||
|
const present = this.map.hasLayer(group)
|
||||||
|
if (enabled && !present) this.map.addLayer(group)
|
||||||
|
else if (!enabled && present) this.map.removeLayer(group)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
applyCallsignFilter(this: ContactsMapHook) {
|
||||||
|
// Callsign filter: iterate individual polylines and add/remove them
|
||||||
|
// from their band's group. Cheap compared to rebuild (no polyline
|
||||||
|
// creation). Only runs when the filter string changes.
|
||||||
|
const match = this.callsignFilter
|
||||||
|
for (const entry of this.lines) {
|
||||||
|
const group = this.bandGroups.get(entry.band)
|
||||||
|
if (!group) continue
|
||||||
|
const visible = !match || entry.s1.includes(match) || entry.s2.includes(match)
|
||||||
|
const present = group.hasLayer(entry.line)
|
||||||
|
if (visible && !present) group.addLayer(entry.line)
|
||||||
|
else if (!visible && present) group.removeLayer(entry.line)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
|
||||||
|
rebuildDots(this: ContactsMapHook) {
|
||||||
|
this.dotLayer.clearLayers()
|
||||||
|
const match = this.callsignFilter
|
||||||
|
const endpointCounts: Record<string, number> = {}
|
||||||
|
let lineCount = 0
|
||||||
|
|
||||||
|
for (const entry of this.lines) {
|
||||||
|
if (!this.enabledBands.has(entry.band)) continue
|
||||||
|
if (match && !entry.s1.includes(match) && !entry.s2.includes(match)) continue
|
||||||
|
lineCount++
|
||||||
|
const k1 = `${entry.lat1.toFixed(2)},${entry.lon1.toFixed(2)}`
|
||||||
|
const k2 = `${entry.lat2.toFixed(2)},${entry.lon2.toFixed(2)}`
|
||||||
endpointCounts[k1] = (endpointCounts[k1] || 0) + 1
|
endpointCounts[k1] = (endpointCounts[k1] || 0) + 1
|
||||||
endpointCounts[k2] = (endpointCounts[k2] || 0) + 1
|
endpointCounts[k2] = (endpointCounts[k2] || 0) + 1
|
||||||
}
|
}
|
||||||
|
|
||||||
// Draw endpoint dots
|
|
||||||
for (const [key, count] of Object.entries(endpointCounts)) {
|
for (const [key, count] of Object.entries(endpointCounts)) {
|
||||||
const [lat, lon] = key.split(",").map(Number)
|
const [lat, lon] = key.split(",").map(Number)
|
||||||
const radius = Math.min(2 + Math.log2(count) * 1.5, 10)
|
const radius = Math.min(2 + Math.log2(count) * 1.5, 10)
|
||||||
|
|
@ -178,5 +274,15 @@ export const ContactsMap = {
|
||||||
interactive: false
|
interactive: false
|
||||||
}).addTo(this.dotLayer)
|
}).addTo(this.dotLayer)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
this.updateCount(lineCount)
|
||||||
|
},
|
||||||
|
|
||||||
|
updateCount(this: ContactsMapHook, count: number) {
|
||||||
|
const label = `${count.toLocaleString()} contacts`
|
||||||
|
const countEl = document.getElementById("contact-count")
|
||||||
|
if (countEl) countEl.textContent = label
|
||||||
|
const countMobile = document.getElementById("contact-count-mobile")
|
||||||
|
if (countMobile) countMobile.textContent = label
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -26,6 +26,11 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
# after mount, which streams it as pre-gzipped bytes via the browser's
|
# after mount, which streams it as pre-gzipped bytes via the browser's
|
||||||
# native `Content-Encoding: gzip` handling. This gets the heavy payload
|
# native `Content-Encoding: gzip` handling. This gets the heavy payload
|
||||||
# off the initial HTML entirely.
|
# off the initial HTML entirely.
|
||||||
|
#
|
||||||
|
# Band toggles are handled entirely client-side in the ContactsMap hook
|
||||||
|
# (per-band Leaflet LayerGroups) so there's no enabled_bands state on
|
||||||
|
# the server. Callsign filter still roundtrips (debounced) to push the
|
||||||
|
# filter string to the hook.
|
||||||
%{count: count, bands: bands} = Radio.contact_map_payload()
|
%{count: count, bands: bands} = Radio.contact_map_payload()
|
||||||
|
|
||||||
{:ok,
|
{:ok,
|
||||||
|
|
@ -33,46 +38,20 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
page_title: "Contact Map",
|
page_title: "Contact Map",
|
||||||
contact_count: count,
|
contact_count: count,
|
||||||
all_bands: bands,
|
all_bands: bands,
|
||||||
enabled_bands: MapSet.new(bands),
|
|
||||||
callsign_filter: ""
|
callsign_filter: ""
|
||||||
)}
|
)}
|
||||||
end
|
end
|
||||||
|
|
||||||
@impl true
|
@impl true
|
||||||
def handle_event("toggle_band", %{"band" => band_str}, socket) do
|
|
||||||
band = String.to_integer(band_str)
|
|
||||||
|
|
||||||
enabled =
|
|
||||||
if MapSet.member?(socket.assigns.enabled_bands, band) do
|
|
||||||
MapSet.delete(socket.assigns.enabled_bands, band)
|
|
||||||
else
|
|
||||||
MapSet.put(socket.assigns.enabled_bands, band)
|
|
||||||
end
|
|
||||||
|
|
||||||
socket = assign(socket, enabled_bands: enabled)
|
|
||||||
{:noreply, push_filter(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("bands_all", _params, socket) do
|
|
||||||
socket = assign(socket, enabled_bands: MapSet.new(socket.assigns.all_bands))
|
|
||||||
{:noreply, push_filter(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("bands_none", _params, socket) do
|
|
||||||
socket = assign(socket, enabled_bands: MapSet.new())
|
|
||||||
{:noreply, push_filter(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
|
def handle_event("filter_callsign", %{"callsign" => callsign}, socket) do
|
||||||
socket = assign(socket, callsign_filter: String.trim(callsign))
|
trimmed = String.trim(callsign)
|
||||||
{:noreply, push_filter(socket)}
|
|
||||||
end
|
|
||||||
|
|
||||||
defp push_filter(socket) do
|
socket =
|
||||||
push_event(socket, "apply_filter", %{
|
socket
|
||||||
enabled_bands: MapSet.to_list(socket.assigns.enabled_bands),
|
|> assign(callsign_filter: trimmed)
|
||||||
callsign: socket.assigns.callsign_filter
|
|> push_event("apply_filter", %{callsign: trimmed})
|
||||||
})
|
|
||||||
|
{:noreply, socket}
|
||||||
end
|
end
|
||||||
|
|
||||||
defp band_label(mhz) when mhz >= 1000 do
|
defp band_label(mhz) when mhz >= 1000 do
|
||||||
|
|
@ -140,30 +119,41 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-xs font-bold">Bands</span>
|
<span class="text-xs font-bold">Bands</span>
|
||||||
<span class="flex gap-2">
|
<span class="flex gap-2">
|
||||||
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
<button
|
||||||
<button phx-click="bands_none" class="text-[10px] underline opacity-70">
|
type="button"
|
||||||
|
data-band-action="all"
|
||||||
|
class="text-[10px] underline opacity-70"
|
||||||
|
>
|
||||||
|
All
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
data-band-action="none"
|
||||||
|
class="text-[10px] underline opacity-70"
|
||||||
|
>
|
||||||
None
|
None
|
||||||
</button>
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= for band <- @all_bands do %>
|
<div id="band-checkboxes-mobile" phx-update="ignore">
|
||||||
<label class="flex items-center gap-2 cursor-pointer text-xs">
|
<%= for band <- @all_bands do %>
|
||||||
<input
|
<label class="flex items-center gap-2 cursor-pointer text-xs">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={MapSet.member?(@enabled_bands, band)}
|
type="checkbox"
|
||||||
phx-click="toggle_band"
|
checked
|
||||||
phx-value-band={band}
|
data-band={band}
|
||||||
style={"accent-color:#{band_color(band)}"}
|
style={"accent-color:#{band_color(band)}"}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-3 h-0.5 rounded-sm shrink-0"
|
class="inline-block w-3 h-0.5 rounded-sm shrink-0"
|
||||||
style={"background:#{band_color(band)}"}
|
style={"background:#{band_color(band)}"}
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span>{band_label(band)}</span>
|
<span>{band_label(band)}</span>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
<div class="flex flex-col gap-1 border-t border-base-300 pt-2">
|
||||||
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
<.link navigate="/map" class="btn btn-xs btn-ghost justify-start">
|
||||||
|
|
@ -221,28 +211,33 @@ defmodule MicrowavepropWeb.ContactMapLive do
|
||||||
<div class="flex items-center justify-between px-1">
|
<div class="flex items-center justify-between px-1">
|
||||||
<span class="text-xs font-bold">Bands</span>
|
<span class="text-xs font-bold">Bands</span>
|
||||||
<span class="flex gap-2">
|
<span class="flex gap-2">
|
||||||
<button phx-click="bands_all" class="text-[10px] underline opacity-70">All</button>
|
<button type="button" data-band-action="all" class="text-[10px] underline opacity-70">
|
||||||
<button phx-click="bands_none" class="text-[10px] underline opacity-70">None</button>
|
All
|
||||||
|
</button>
|
||||||
|
<button type="button" data-band-action="none" class="text-[10px] underline opacity-70">
|
||||||
|
None
|
||||||
|
</button>
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<%= for band <- @all_bands do %>
|
<div id="band-checkboxes" class="flex flex-col" phx-update="ignore">
|
||||||
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
|
<%= for band <- @all_bands do %>
|
||||||
<input
|
<label class="flex items-center gap-2 cursor-pointer px-1 text-sm leading-relaxed">
|
||||||
type="checkbox"
|
<input
|
||||||
checked={MapSet.member?(@enabled_bands, band)}
|
type="checkbox"
|
||||||
phx-click="toggle_band"
|
checked
|
||||||
phx-value-band={band}
|
data-band={band}
|
||||||
style={"accent-color:#{band_color(band)}"}
|
style={"accent-color:#{band_color(band)}"}
|
||||||
/>
|
/>
|
||||||
<span
|
<span
|
||||||
class="inline-block w-3.5 h-0.5 rounded-sm shrink-0"
|
class="inline-block w-3.5 h-0.5 rounded-sm shrink-0"
|
||||||
style={"background:#{band_color(band)}"}
|
style={"background:#{band_color(band)}"}
|
||||||
>
|
>
|
||||||
</span>
|
</span>
|
||||||
<span>{band_label(band)}</span>
|
<span>{band_label(band)}</span>
|
||||||
</label>
|
</label>
|
||||||
<% end %>
|
<% end %>
|
||||||
|
</div>
|
||||||
|
|
||||||
<%!-- Navigation --%>
|
<%!-- Navigation --%>
|
||||||
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
<ul class="menu menu-xs border-t border-base-300 pt-2 px-0">
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue