neiam
/apollos_crib
It's Apollo's Crib
public · Issues · Pulls 1 · Labels · Forks · Compare · Actions failure · Badges · Packages
Log in to mark this repository.
Say which way a bike is, and cap how many come back
4118534 · Gabriel Morell · 2026-09-03 19:40
Message
{commit_body(@commit)}
Files changed
modified
apps/room_sanctum/lib/room_sanctum/condensers/basic_mqtt.ex
+15
−0
| 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 | 196 | } |
| 196 | 197 | end) |
| 197 | 198 | } |
| 199 | + |> with_dir(f) | |
| 198 | 200 | end) |
| 199 | 201 | |
| 200 | 202 | :tidal -> |
| 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 | 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 | 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 | 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 | 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 | 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 | 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