Put the live data on the map, and say what it is

31a0a77 · Gabriel Morell · 2026-09-01 14:33

48 files +1705 -43
Message
{commit_body(@commit)}

Files changed

modified apps/room_gbfs/lib/room_gbfs/gbfs_worker.ex
+14 −0
@@ -61,6 +61,20 @@ defmodule RoomGbfs.Worker do
61 61 |> GenServer.cast(:update_realtime)
62 62 end
63 63
64 + # A station query answers with the dock; an area query answers with whatever
65 + # is loose within its radius. Same source, same query struct, different
66 + # question -- see RoomSanctum.Configuration.Queries.GBFS.
67 + def query_stop(id, %{mode: :area} = query) do
68 + bikes = Storage.free_bikes_near_foci(id, query.foci_id, query.radius)
69 +
70 + # Docks second, so a mixed answer still leads with what is loose nearby --
71 + # which is the thing an area query was asked about.
72 + case Map.get(query, :include_docks) do
73 + true -> bikes ++ Storage.stations_near_foci(id, query.foci_id, query.radius)
74 + _ -> bikes
75 + end
76 + end
77 +
64 78 def query_stop(id, query) do
65 79 [Storage.get_current_information_for_bikestop(id, query.stop_id)]
66 80 end
modified apps/room_sanctum/assets/js/leaflet/basemap.js
+15 −0
@@ -150,6 +150,21 @@ function watchTheme() {
150 150 window.addEventListener('phx:set-theme', () => setTimeout(repaint, 0))
151 151 }
152 152
153 +/**
154 + * Run `fn` whenever the theme changes, and return an unsubscribe.
155 + *
156 + * The tint keeps a registry of repaint callbacks and watches the document for
157 + * both the attribute write and the picker's own event; anything else that has
158 + * to restyle on a theme flip -- markers shaded by a value, whose ramp is
159 + * anchored to the surface they sit on -- wants exactly the same signal rather
160 + * than a second observer beside it.
161 + */
162 +export function onThemeChange(fn) {
163 + registry.add(fn)
164 + watchTheme()
165 + return () => registry.delete(fn)
166 +}
167 +
153 168 /**
154 169 * Add a basemap to `map` and wash it in the current theme colour.
155 170 *
modified apps/room_sanctum/assets/js/leaflet/leaflet-map.js
+187 −10
@@ -4,7 +4,7 @@ import L from 'leaflet'
4 4 // document cannot reach -- without this Leaflet's tiles have no positioning
5 5 // rules and lay themselves out as a grid of loose images.
6 6 import leafletCss from 'leaflet/dist/leaflet.css'
7 −import { addBasemap } from './basemap'
7 +import { addBasemap, isDarkTheme, onThemeChange } from './basemap'
8 8 import { addFullscreenControl } from './fullscreen'
9 9
10 10 const template = document.createElement('template');
@@ -45,6 +45,113 @@ template.innerHTML = `
45 45 `
46 46
47 47
48 +// How full a bike is, as depth of green.
49 +//
50 +// Sequential encoding: one hue, monotone lightness, so the reader sees "more"
51 +// without having to learn a key -- and green stays the bikes' own hue, which is
52 +// what tells them from the blue docks and amber queries beside them. Four steps
53 +// rather than a continuous scale: a shade is read by comparison with its
54 +// neighbours, and four are tellable apart where forty are not.
55 +//
56 +// Two ramps because the map has two surfaces. On a light basemap the scale runs
57 +// light-to-dark, empty to full; on a dark one it is anchored the other way, or
58 +// an empty bike would be a black dot on a black map. Each was checked against
59 +// the surface it renders on: every step holds its own against the basemap, and
60 +// the palest end still clears the 2:1 floor.
61 +const CHARGE_RAMP = {
62 + light: ['#22c55e', '#16a34a', '#15803d', '#14532d'],
63 + dark: ['#15803d', '#16a34a', '#22c55e', '#4ade80']
64 +}
65 +
66 +// A bike whose feed reports neither charge nor range. Neutral rather than a
67 +// step of the ramp: any green here is a claim about how full it is, and the
68 +// second step in particular would make an unknown bike indistinguishable from
69 +// a half-charged one.
70 +const NO_CHARGE_COLOR = '#898781'
71 +
72 +// The colour is never the only place the number lives -- the popup states the
73 +// charge and the range in words, and says neither for a bike that reports
74 +// neither.
75 +function chargeColor(charge, dark) {
76 + const value = parseFloat(charge)
77 + if (!isFinite(value)) return NO_CHARGE_COLOR
78 +
79 + const ramp = dark ? CHARGE_RAMP.dark : CHARGE_RAMP.light
80 + const step = Math.min(ramp.length - 1, Math.max(0, Math.floor((value / 100) * ramp.length)))
81 + return ramp[step]
82 +}
83 +
84 +// The glyphs the preview cards label their numbers with, so a popup reads the
85 +// way the card for the same query reads.
86 +//
87 +// Codepoints rather than Font Awesome's own classes: the map draws inside a
88 +// shadow root, which the document's stylesheet cannot reach, and inlining
89 +// 138KB of Font Awesome CSS to reach a dozen icons is a poor trade. The
90 +// @font-face is document-level and font faces are not scoped to a tree, so the
91 +// family is available in here -- it is only the class rules that are not.
92 +// Taken from @fortawesome/fontawesome-free 6, solid.
93 +const ICON_GLYPHS = {
94 + 'bicycle': '\uf206',
95 + 'bolt-lightning': '\ue0b7',
96 + 'square-parking': '\uf540',
97 + 'battery-empty': '\uf244',
98 + 'battery-quarter': '\uf243',
99 + 'battery-half': '\uf242',
100 + 'battery-three-quarters': '\uf241',
101 + 'battery-full': '\uf240',
102 + 'road': '\uf018',
103 + 'clock': '\uf017',
104 + 'tower-broadcast': '\uf519',
105 + 'lungs': '\uf604',
106 + 'ban': '\uf05e',
107 + 'lock': '\uf023',
108 + 'train-tram': '\uf7da',
109 + 'train-subway': '\uf239',
110 + 'train': '\uf238',
111 + 'bus': '\uf207',
112 + 'bus-simple': '\uf55e',
113 + 'ferry': '\ue4ea',
114 + 'mountain': '\uf6fc',
115 + 'plane-up': '\ue22d',
116 + 'location-dot': '\uf3c5'
117 +}
118 +
119 +// The row's name, drawn as its glyph where there is one and as words where
120 +// there is not -- a route ("71 to Watertown") or a reading ("PM2.5") is the
121 +// data itself, not a label to be replaced by a picture.
122 +function rowLabel(line) {
123 + const glyph = ICON_GLYPHS[String(line.icon || '').replace(/^fa-/, '')]
124 +
125 + if (!glyph) return `<span class="text-gray-500">${escapeHtml(line.label)}</span>`
126 +
127 + // The name still reaches a screen reader, and a hover still says it.
128 + return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal"
129 + class="text-gray-500" title="${escapeHtml(line.label)}"
130 + role="img" aria-label="${escapeHtml(line.label)}">${glyph}</span>`
131 +}
132 +
133 +// A glyph on the value rather than in place of the row's name: an arrival time
134 +// is a live one or a scheduled one, which is about the number, not about which
135 +// row it is on.
136 +function valueIcon(line) {
137 + const glyph = ICON_GLYPHS[String(line.value_icon || '').replace(/^fa-/, '')]
138 + if (!glyph) return ''
139 +
140 + return `<span style="font-family:'Font Awesome 6 Free';font-weight:900;font-style:normal"
141 + class="text-gray-500 mr-1" aria-hidden="true">${glyph}</span>`
142 +}
143 +
144 +// Popup rows carry feed text -- a station name, a headsign -- straight from
145 +// the source, so it is escaped rather than interpolated.
146 +function escapeHtml(value) {
147 + if (value === null || value === undefined) return ''
148 + return String(value)
149 + .replace(/&/g, '&amp;')
150 + .replace(/</g, '&lt;')
151 + .replace(/>/g, '&gt;')
152 + .replace(/"/g, '&quot;')
153 +}
154 +
48 155 // Leaflet draws circles on canvas and nothing else, but a map holding several
49 156 // offerings needs a third axis after fill (what kind of thing) and outline
50 157 // (which tint) -- so: shape says whose.
@@ -282,6 +389,11 @@ class LeafletMap extends HTMLElement {
282 389 });
283 390 this.resizeObserver.observe(this.mapElement);
284 391
392 + // The charge ramp is anchored to the surface it is drawn on, so a theme
393 + // flip restyles the bikes rather than leaving a scale reading against
394 + // its own background.
395 + this.unwatchTheme = onThemeChange(() => this.repaintChargeMarkers());
396 +
285 397 // After the initial setView, so it is not mistaken for a user move.
286 398 this.watchForUserView();
287 399 this.addResetViewControl();
@@ -401,6 +513,10 @@ class LeafletMap extends HTMLElement {
401 513 this.basemap.remove();
402 514 this.basemap = null;
403 515 }
516 + if (this.unwatchTheme) {
517 + this.unwatchTheme();
518 + this.unwatchTheme = null;
519 + }
404 520 // Likewise: it listens on the document, not on the map.
405 521 if (this.fullscreen) {
406 522 this.fullscreen.remove();
@@ -484,6 +600,14 @@ class LeafletMap extends HTMLElement {
484 600 return this.addMarkerElement(markerEl);
485 601 }
486 602
603 + // A bike that has been ridden reports less charge; the shade is a style
604 + // on the same canvas circle, so it does not need a new layer.
605 + if (changedAttributes.has('charge') && existing.setStyle) {
606 + existing.setStyle({
607 + fillColor: chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this))
608 + });
609 + }
610 +
487 611 // The popup builds its content lazily, so a stale one only exists while
488 612 // it is on screen.
489 613 if (existing.isPopupOpen && existing.isPopupOpen()) {
@@ -491,6 +615,20 @@ class LeafletMap extends HTMLElement {
491 615 }
492 616 }
493 617
618 + repaintChargeMarkers() {
619 + if (!this.map) return;
620 + const dark = isDarkTheme(this);
621 +
622 + this.markerElements.forEach((markerEl, id) => {
623 + const type = markerEl.getAttribute('type');
624 + if (type !== 'free_bike' && type !== 'free-bike') return;
625 + const marker = this.markers.get(id);
626 + if (marker && marker.setStyle) {
627 + marker.setStyle({ fillColor: chargeColor(markerEl.getAttribute('charge'), dark) });
628 + }
629 + });
630 + }
631 +
494 632 // Turning the part of the icon that points somewhere, rather than building
495 633 // a whole new icon for a heading change. False means this marker cannot be
496 634 // turned in place -- an image icon, or a heading appearing or disappearing,
@@ -784,9 +922,14 @@ class LeafletMap extends HTMLElement {
784 922 }
785 923
786 924 createFreeBikeMarker(lat, lng, markerEl) {
787 − // Smaller markers for bikes
788 − const color = this.getMarkerColor(markerEl);
789 −
925 + // Smaller markers for bikes, shaded by how much charge is left in them.
926 + // A bike is always shaded by charge -- a missing attribute is the
927 + // unknown case, which chargeColor draws as neutral rather than as a
928 + // value it does not have.
929 + const color = markerEl.getAttribute('type') === 'vehicle'
930 + ? this.getMarkerColor(markerEl)
931 + : chargeColor(markerEl.getAttribute('charge'), isDarkTheme(this));
932 +
790 933 return L.circleMarker([lat, lng], {
791 934 renderer: this.canvasRenderer,
792 935 radius: 4,
@@ -920,21 +1063,49 @@ class LeafletMap extends HTMLElement {
920 1063 return colorMap[tint] || null;
921 1064 }
922 1065
1066 + // What a query currently says, as the preview cards say it: the server
1067 + // condenses the same data into label/value lines and hands them over on the
1068 + // marker. Coordinates stay, but at the bottom and in small type -- a popup
1069 + // that leads with them is repeating what the marker's position already
1070 + // said.
1071 + summaryRows(markerEl) {
1072 + const raw = markerEl.getAttribute('summary');
1073 + if (!raw) return '';
1074 +
1075 + let lines;
1076 + try {
1077 + lines = JSON.parse(raw);
1078 + } catch (e) {
1079 + return '';
1080 + }
1081 + if (!Array.isArray(lines) || lines.length === 0) return '';
1082 +
1083 + return lines
1084 + .map(
1085 + (line) => `
1086 + <div class="flex justify-between gap-3 text-xs mb-1">
1087 + ${rowLabel(line)}
1088 + <span class="font-medium text-gray-800 text-right">${
1089 + valueIcon(line)
1090 + }${escapeHtml(line.value)}</span>
1091 + </div>`
1092 + )
1093 + .join('');
1094 + }
1095 +
923 1096 createPopupContent(markerEl) {
924 1097 // Extract data from marker element attributes
925 1098 const name = markerEl.getAttribute('name') || 'Marker';
926 1099 const lat = markerEl.getAttribute('lat');
927 1100 const lng = markerEl.getAttribute('lng');
928 1101 const type = markerEl.getAttribute('type') || 'query';
929 −
1102 + const summary = this.summaryRows(markerEl);
1103 +
930 1104 // Get additional attributes based on type
931 1105 let content = `
932 1106 <div class="p-2 min-w-48">
933 1107 <h3 class="font-semibold text-sm mb-2">${name}</h3>
934 − <div class="text-xs text-gray-500 mb-2">
935 − <i class="fa-solid fa-location-dot mr-1"></i>
936 − ${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)}
937 − </div>
1108 + ${summary}
938 1109 `;
939 1110
940 1111 // Only rows we actually have. This used to print the vehicle-id and
@@ -960,7 +1131,13 @@ class LeafletMap extends HTMLElement {
960 1131 row('Heading', markerEl.getAttribute('bearing'));
961 1132 }
962 1133
963 − content += `</div>`;
1134 + // Where it is, after what it says.
1135 + content += `
1136 + <div class="text-[10px] text-gray-400 mt-2">
1137 + <i class="fa-solid fa-location-dot mr-1"></i>
1138 + ${parseFloat(lat).toFixed(4)}, ${parseFloat(lng).toFixed(4)}
1139 + </div>
1140 + </div>`;
964 1141 // Opt-in: the marker element carries phx-click, so routing the button
965 1142 // through markerEl.click() reuses the forwarding that already exists
966 1143 // rather than reaching into LiveView from here.
modified apps/room_sanctum/assets/js/leaflet/leaflet-marker.js
+1 −1
@@ -15,7 +15,7 @@ class LeafletMarker extends HTMLElement {
15 15 }
16 16
17 17 static get observedAttributes() {
18 − return ['lat', 'lng', 'name', 'type', 'tint', 'shape', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected'];
18 + return ['lat', 'lng', 'name', 'type', 'tint', 'shape', 'bearing', 'route-type', 'aircraft-class', 'vehicle-id', 'route-id', 'selected', 'summary', 'charge'];
19 19 }
20 20
21 21 attributeChangedCallback(name, oldValue, newValue) {
modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+66 −12
@@ -100,19 +100,51 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
100 100 end)
101 101
102 102 :gbfs ->
103 + # A bike names its type by an id of the feed's own choosing -- Bay
104 + # Wheels' e-bike is "2" -- so the types are resolved once for the list
105 + # rather than every bike being labelled with a number.
106 + vehicle_types = gbfs_vehicle_types(data)
107 +
103 108 data
104 − |> Enum.map(fn f ->
105 − %{
106 − name: f.name,
107 − id: f.station_id,
108 − avail: f.num_bikes_available,
109 − avail_elec: f.num_ebikes_available,
110 − avail_std: f.num_bikes_available - f.num_ebikes_available,
111 − docks_avail: f.num_docks_available,
112 − docks_disabled: f.num_docks_disabled,
113 − capacity: f.capacity,
114 − ebikes_info: f.ebikes_info |> Enum.map(fn eb -> %{name: eb.displayed_number, battery_pct: eb.battery_charge_percentage, range_mi_cons: eb.range_estimate.conservative_range_miles, range_me_est: eb.range_estimate.estimated_range_miles} end)
115 − }
109 + |> Enum.map(fn
110 + # An area query answers with loose bikes rather than a dock, and a
111 + # bike has none of a station's fields -- no name, no capacity,
112 + # nothing to dock. Told apart by what came back rather than by the
113 + # query, since the condenser is only ever handed the answer.
114 + %{bike_id: bike_id} = b ->
115 + %{
116 + kind: :free_bike,
117 + name: vehicle_label(vehicle_types, b) || bike_id,
118 + id: bike_id,
119 + lat: b.lat,
120 + lon: b.lon,
121 + range_m: b.current_range_meters,
122 + fuel_pct: b.current_fuel_percent,
123 + reserved: b.is_reserved,
124 + disabled: b.is_disabled
125 + }
126 +
127 + f ->
128 + %{
129 + name: f.name,
130 + id: f.station_id,
131 + avail: f.num_bikes_available,
132 + avail_elec: f.num_ebikes_available,
133 + avail_std: f.num_bikes_available - f.num_ebikes_available,
134 + docks_avail: f.num_docks_available,
135 + docks_disabled: f.num_docks_disabled,
136 + capacity: f.capacity,
137 + ebikes_info:
138 + f.ebikes_info
139 + |> Enum.map(fn eb ->
140 + %{
141 + name: eb.displayed_number,
142 + battery_pct: eb.battery_charge_percentage,
143 + range_mi_cons: eb.range_estimate.conservative_range_miles,
144 + range_me_est: eb.range_estimate.estimated_range_miles
145 + }
146 + end)
147 + }
116 148 end)
117 149
118 150 :tidal ->
@@ -251,4 +283,26 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
251 283 }
252 284 }
253 285 end
286 +
287 + # The vehicle types behind a list of bikes, or an empty map for a list that
288 + # holds none -- a station answer, or a source whose vehicle_types.json has
289 + # not been read yet.
290 + defp gbfs_vehicle_types(data) do
291 + data
292 + |> Enum.flat_map(fn
293 + %{bike_id: _, source_id: source_id} when not is_nil(source_id) -> [source_id]
294 + _ -> []
295 + end)
296 + |> Enum.uniq()
297 + |> Enum.reduce(%{}, fn source_id, acc ->
298 + Map.merge(acc, RoomSanctum.Storage.gbfs_vehicle_types(source_id))
299 + end)
300 + end
301 +
302 + defp vehicle_label(vehicle_types, bike) do
303 + vehicle_types
304 + |> Map.get(Map.get(bike, :vehicle_type_id))
305 + |> RoomSanctum.Storage.GBFS.V1.VehicleTypes.label()
306 + end
307 +
254 308 end
modified apps/room_sanctum/lib/room_sanctum/configuration/queries/gbfs.ex
+50 −2

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/storage.ex
+83 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum/storage/gbfs/v1/vehicle_types.ex
+38 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/components/query_geospatial_map.ex
+195 −13

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/form_component.html.heex
+24 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.ex
+13 −2

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_query_live/show.html.heex
+4 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.ex
+47 −1

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_vision_live/show.html.heex
+7 −0

Click to load diff…

added apps/room_sanctum/lib/room_sanctum_web/live/helpers/map_data.ex
+362 −0

Click to load diff…

modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+39 −1

Click to load diff…

added apps/room_sanctum/test/room_sanctum/gbfs_query_test.exs
+57 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/map_data_test.exs
+185 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/components/bike_charge_test.exs
+99 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/components/dock_popup_test.exs
+96 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum_web/components/gbfs_preview_test.exs
+84 −0

Click to load diff…

modified apps/room_sanctum/test/room_sanctum_web/components/query_placement_test.exs
+39 −0

Click to load diff…

Parents: b060813