Say which way a bike is, and cap how many come back

4118534 · Gabriel Morell · 2026-09-03 19:40

47 files +219 -7
Message
{commit_body(@commit)}

Files changed

modified apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+15 −0
@@ -165,6 +165,7 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
165 165 reserved: b.is_reserved,
166 166 disabled: b.is_disabled
167 167 }
168 + |> with_dir(b)
168 169
169 170 f ->
170 171 %{
@@ -195,6 +196,7 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
195 196 }
196 197 end)
197 198 }
199 + |> with_dir(f)
198 200 end)
199 201
200 202 :tidal ->
@@ -336,6 +338,19 @@ defmodule RoomSanctum.Condenser.BasicMQTT do
336 338 }
337 339 end
338 340
341 + # Which way the thing lies from where the reader is standing.
342 + #
343 + # Only a Plani stamps this -- it is the one field here that is relative to
344 + # the reader rather than to the world, and a vision's foci is a fixed place
345 + # whose directions never change. Left out entirely rather than published as
346 + # null, so a vision's payload is byte for byte what it always was.
347 + defp with_dir(condensed, row) do
348 + case Map.get(row, :dir) do
349 + nil -> condensed
350 + dir -> Map.put(condensed, :dir, dir)
351 + end
352 + end
353 +
339 354 # The vehicle types behind a list of bikes, or an empty map for a list that
340 355 # holds none -- a station answer, or a source whose vehicle_types.json has
341 356 # not been read yet.
modified apps/room_sanctum/lib/room_sanctum/configuration/plani.ex
+10 −1
@@ -47,6 +47,11 @@ defmodule RoomSanctum.Configuration.Plani do
47 47 # the same bus is listed once per stop.
48 48 field :nearest_per_route, :boolean, default: false
49 49
50 + # How many bikes and docks one GBFS source may contribute. Nil is
51 + # everything inside the radius, which is what a dockless system in a city
52 + # centre answers with -- hundreds of them.
53 + field :bike_limit, :integer
54 +
50 55 timestamps()
51 56 end
52 57
@@ -64,13 +69,17 @@ defmodule RoomSanctum.Configuration.Plani do
64 69 :radius,
65 70 :limit,
66 71 :break_out,
67 − :nearest_per_route
72 + :nearest_per_route,
73 + :bike_limit
68 74 ])
69 75 |> validate_required([:name, :home_foci_id])
70 76 # The same bounds an area query keeps, and for the same reason: past a
71 77 # couple of kilometres this stops meaning "near me".
72 78 |> validate_number(:radius, greater_than_or_equal_to: 50, less_than_or_equal_to: 3000)
73 79 |> validate_number(:limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 20)
80 + # Looser than `limit`: loose bikes are counted rather than read one by one,
81 + # so a larger number is still a sensible thing to ask for.
82 + |> validate_number(:bike_limit, greater_than_or_equal_to: 1, less_than_or_equal_to: 50)
74 83 |> validate_tint()
75 84 |> foreign_key_constraint(:user_id)
76 85 |> foreign_key_constraint(:home_foci_id)
modified apps/room_sanctum/lib/room_sanctum/workers/plani_worker.ex
+65 −2
@@ -247,8 +247,19 @@ defmodule RoomSanctum.Worker.Plani do
247 247 # Both, because a source does not say which kind it is. A docked
248 248 # system answers with docks and a dockless one with loose bikes, and
249 249 # asking only for the latter left a city full of docks looking empty.
250 − bikes = Storage.free_bikes_near(source_id, anchor, plani.radius)
251 − docks = Storage.stations_near(source_id, anchor, plani.radius)
250 + # Both come back closest-first, so the cap keeps the nearest rather
251 + # than whichever the feed happened to list first. Capped separately:
252 + # they are different things, and one starving the other would make a
253 + # docked system with loose bikes in it look like it had no docks.
254 + bikes =
255 + Storage.free_bikes_near(source_id, anchor, plani.radius)
256 + |> cap(plani.bike_limit)
257 + |> Enum.map(&toward(&1, anchor))
258 +
259 + docks =
260 + Storage.stations_near(source_id, anchor, plani.radius)
261 + |> cap(plani.bike_limit)
262 + |> Enum.map(&toward(&1, anchor))
252 263
253 264 entries =
254 265 if plani.break_out do
@@ -356,6 +367,58 @@ defmodule RoomSanctum.Worker.Plani do
356 367 {arrival.trip.route_id, direction}
357 368 end
358 369
370 + defp cap(rows, nil), do: rows
371 + defp cap(rows, limit) when is_integer(limit), do: Enum.take(rows, limit)
372 +
373 + # Which way to walk.
374 + #
375 + # "Four bikes nearby" is not directions; "NE" is. Stamped here rather than
376 + # worked out by each client, because it is the one thing on a bike that is
377 + # relative to the anchor -- and only this worker knows where the anchor is.
378 + #
379 + # Written onto the record for the condenser to pass through. A dock arrives
380 + # as a plain map and a loose bike as a schema struct; `Map.put/3` is right
381 + # for both, and the condenser reads the key rather than the struct.
382 + defp toward(row, anchor) do
383 + Map.put(row, :dir, compass(anchor, Map.get(row, :lat), Map.get(row, :lon)))
384 + end
385 +
386 + @compass ~w(N NNE NE ENE E ESE SE SSE S SSW SW WSW W WNW NW NNW)
387 +
388 + # Sixteen points, which is as fine as a direction can be read off a phone at
389 + # a glance -- and finer than the accuracy of the fix it is measured from.
390 + @doc false
391 + # Public only so a test can reach it; nothing outside calls this.
392 + def compass(%Geo.Point{coordinates: {lon1, lat1}}, lat2, lon2)
393 + when is_number(lat2) and is_number(lon2) do
394 + # Standing on the thing has no direction, and atan2(0, 0) would answer
395 + # "north" rather than saying so. Metres, roughly, which is all this needs.
396 + d_north = (lat2 - lat1) * 111_320
397 + d_east = (lon2 - lon1) * 111_320 * :math.cos(rad(lat1))
398 +
399 + if :math.sqrt(d_north * d_north + d_east * d_east) < 15 do
400 + nil
401 + else
402 + phi1 = rad(lat1)
403 + phi2 = rad(lat2)
404 + d_lon = rad(lon2 - lon1)
405 +
406 + y = :math.sin(d_lon) * :math.cos(phi2)
407 +
408 + x =
409 + :math.cos(phi1) * :math.sin(phi2) -
410 + :math.sin(phi1) * :math.cos(phi2) * :math.cos(d_lon)
411 +
412 + degrees = :math.fmod(:math.atan2(y, x) * 180 / :math.pi() + 360.0, 360.0)
413 +
414 + Enum.at(@compass, degrees |> Kernel./(22.5) |> round() |> rem(16))
415 + end
416 + end
417 +
418 + def compass(_anchor, _lat, _lon), do: nil
419 +
420 + defp rad(degrees), do: degrees * :math.pi() / 180
421 +
359 422 defp described(source), do: described(source, source.id, source.name)
360 423
361 424 defp described(source, id, name) do
modified apps/room_sanctum/lib/room_sanctum_web/live/cfg_plani_live/form_component.ex
+10 −0
@@ -133,6 +133,16 @@ defmodule RoomSanctumWeb.PlaniLive.FormComponent do
133 133
134 134 <.input field={@form[:radius]} type="number" label="Radius (metres, 50-3000)" />
135 135 <.input field={@form[:limit]} type="number" label="How many of each (1-20)" />
136 + <.input
137 + field={@form[:bike_limit]}
138 + type="number"
139 + label="Bikes and docks per source (blank for all)"
140 + />
141 + <p class="text-xs text-base-content/60">
142 + Stops and monitors have always been bounded by “how many of each”;
143 + bikes and docks were bounded only by the radius, so a dockless system
144 + in a city centre answers with hundreds. The nearest are kept.
145 + </p>
136 146
137 147 <.input
138 148 field={@form[:break_out]}
modified apps/room_sanctum/lib/room_sanctum_web/live/live_preview.ex
+5 −0
@@ -557,6 +557,11 @@ use PhoenixHTMLHelpers
557 557 <span class="font-bold text-lg lg:text-4xl text-accent">
558 558 <%= e.name |> to_string() |> String.split(" ") |> Enum.take(3) |> Enum.join(" ") %>
559 559 </span>
560 + <%!-- Only a Plani sends this: it is relative to where the
561 + client is, and a vision's foci does not move. --%>
562 + <span :if={Map.get(e, :dir)} class="text-sm lg:text-2xl opacity-70 whitespace-nowrap">
563 + <i class="fa-solid fa-location-arrow fa-fw"></i><%= Map.get(e, :dir) %>
564 + </span>
560 565 </div>
561 566 <%!-- A loose bike has no docks to count; what it has is charge.
562 567 Rendered per entry because an area query asked for docks
added apps/room_sanctum/priv/repo/migrations/20260903180000_add_bike_limit_to_plani.exs
+18 −0

Click to load diff…

added apps/room_sanctum/test/room_sanctum/plani_compass_test.exs
+34 −0

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/targets/GbfsRenderer.kt
+17 −3

Click to load diff…

modified clients/android/app/src/main/java/io/neiam/apolloscrib/types/Gbfs.kt
+9 −1

Click to load diff…

added clients/android/app/src/main/res/drawable/fa_location_arrow.xml
+12 −0

Click to load diff…

modified clients/android/app/src/test/java/io/neiam/apolloscrib/types/WireTest.kt
+22 −0

Click to load diff…

modified clients/android/tools/fa_to_vector.py
+2 −0

Click to load diff…

Parents: 51365c2