Compare commits
6 commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 718c72dde1 | |||
| 4217e88338 | |||
| 6af3dad762 | |||
| 7fc92d24e4 | |||
| 148e1635d3 | |||
| ad55559f55 |
44 changed files with 4517 additions and 653 deletions
80
README.md
80
README.md
|
|
@ -39,14 +39,59 @@ bigbrainparking/
|
|||
| --- | --- | --- |
|
||||
| Phone + password login | ✅ wired | tokens in OS keystore |
|
||||
| Map of nearby meters (clickable, zoom, live GPS) | ✅ wired | MapLibre + OpenFreeMap tiles (no key) |
|
||||
| City parking map overlay (2h/3h/4h/no-limit/lots) | ✅ wired | the city's printed map, georeferenced — **never touches the IPS API** |
|
||||
| "Park here" pin + time tracking on any city area | ✅ wired | GPS or hand-placed pin, auto-detects the area, local countdown |
|
||||
| Last-lot + My-location search | ✅ wired | opens on your last session's lot; GPS sent only via explicit "My location" |
|
||||
| QR kiosk scan → meter lookup | ✅ wired | on-device VisionCamera |
|
||||
| Save / share kiosks | ✅ wired | local (no server favorites API exists) |
|
||||
| Active / past sessions | ✅ wired | list views + tap for full receipt |
|
||||
| Start a paid session | ✅ works | confirmed live end-to-end (real $0.10 DL-zone charge); declines surfaced |
|
||||
| Ongoing parking countdown | ✅ wired | foreground service; ticks down, **End** / **Extend** buttons, survives reboot |
|
||||
| Session-expiry reminders | ✅ wired | **local** on-device notifications — no server, no push |
|
||||
| UnifiedPush (ntfy) | ⚪ optional | not needed for reminders; stub for future server-initiated msgs |
|
||||
|
||||
## The city parking map
|
||||
|
||||
The **City map** layer on the Map tab is the City of Sandpoint's printed *Downtown &
|
||||
Waterfront Public Parking* map, georeferenced and drawn in the same colours as the legend:
|
||||
2-hour free, 3-hour, 4-hour, no time limit, and the paid city lots. 49 areas in all.
|
||||
|
||||
**The free areas never touch ParkSmarter.** They live in the local database (bundled with
|
||||
the app, refreshed from the zone-labels server, cached on-device), the countdown is the
|
||||
phone's own clock, and the notification is the same foreground service every other session
|
||||
uses. So tracking your time on a free city spot works with no account, no signal, no
|
||||
payment, and in Anonymous Mode.
|
||||
|
||||
The **green city lots are the exception** — they're the map's only paid category, and paying
|
||||
for them means ParkSmarter. They're hidden entirely when you're not signed in, since parking
|
||||
you can't actually buy is worse than no parking at all. (Standing in one and tapping "Park
|
||||
here" says so rather than reporting nothing nearby.) A single lot can be flipped back via
|
||||
the server's `requiresAccount` field if it turns out to take payment another way.
|
||||
|
||||
Two ways to start:
|
||||
|
||||
- **Park here** — pins your car from GPS and works out which area you're in. No GPS fix
|
||||
(garage, indoors, radio off)? It asks you to tap the spot instead and pins that. The pin
|
||||
stays on the map until you end the session, because "where did I leave the car" is half
|
||||
the point.
|
||||
- **Tap a coloured segment** — pick the block directly, no pin needed.
|
||||
|
||||
Either way you choose how long to track, capped at the posted limit (a 2-hour space won't
|
||||
offer to run a 4-hour timer — that's just scheduling a ticket). The ongoing notification's
|
||||
second button reads **+1 hr** here rather than *Extend*: there is nothing to buy, so it
|
||||
edits the local timer and says so.
|
||||
|
||||
The **Sessions** tab shows and manages these under *Tracking on this phone* — add an hour,
|
||||
end it, and see recent ones — with no account and no network, because that is the only
|
||||
place they exist. ParkSmarter's own sessions are layered on top when you're signed in, and
|
||||
failing to reach them (offline, or signed out) never hides the local half.
|
||||
|
||||
The georeference was fitted to OpenStreetMap street centrelines and lands within ~4 m
|
||||
(see [`tools/citymap/`](tools/citymap/) to regenerate it from a new edition of the PDF).
|
||||
Because a few metres is the difference between two sides of a street, **Account → Align city
|
||||
map** lets you nudge the whole overlay against a live GPS fix and save it — on the phone, or
|
||||
published to the server for every device if you hold the admin token.
|
||||
|
||||
## Privacy
|
||||
|
||||
BigBrainParking sends your location to ParkSmarter **only** when you explicitly tap "My
|
||||
|
|
@ -75,12 +120,35 @@ in `app/app.json` to point elsewhere.
|
|||
|
||||
## Notifications on GrapheneOS
|
||||
|
||||
Session-expiry reminders are scheduled **entirely on-device** from each session's end time
|
||||
(Android `AlarmManager`, via expo-notifications) — no server, no push, no FCM, no Play
|
||||
Services. They work fully offline. Configure the lead time (default 15 min) in
|
||||
**Account → Notifications**, where a **"Send a test reminder"** button lets you confirm it
|
||||
fires on your phone. UnifiedPush (ntfy) is wired only as an optional, no-op stub for any
|
||||
*future* server-initiated messages; nothing time-based needs it.
|
||||
Everything here is **entirely on-device** — no server, no push, no FCM, no Play Services —
|
||||
so it works fully offline. UnifiedPush (ntfy) is wired only as an optional, no-op stub for
|
||||
any *future* server-initiated messages; nothing time-based needs it.
|
||||
|
||||
**The ongoing parking countdown.** Whenever a session is active — a paid one you bought or
|
||||
a free check-in — a persistent notification shows the time left and ticks down, with
|
||||
**End** and **Extend** buttons. It is held up by a real **foreground service**
|
||||
(`modules/bbp-notify`, type `specialUse`), which is what makes it stick on GrapheneOS the
|
||||
way ntfy's does: it survives the app being killed, can't be swiped away, and a
|
||||
`BOOT_COMPLETED` receiver brings it back after a reboot. The service's life is exactly the
|
||||
session's life — it stops itself, removing the notification, on **End**, when the meter
|
||||
runs out, or whenever there's no session to show.
|
||||
|
||||
The countdown itself costs no battery: the end time is handed to Android as a
|
||||
[chronometer](https://developer.android.com/reference/android/app/Notification.Builder#setChronometerCountDown(boolean)),
|
||||
and the system redraws the ticking text with the app closed and no timer of its own.
|
||||
|
||||
- **End** stops tracking and clears the notification. On a *free check-in* that genuinely
|
||||
ends it. On a *paid* session it only stops the display — ParkSmarter has no stop-session
|
||||
endpoint, so time you already bought keeps running at the meter either way.
|
||||
- **Extend** (labeled **Pay** on a free check-in) opens the purchase screen for that exact
|
||||
zone. The active session is stored locally *with its zone*, so this works offline, in
|
||||
Anonymous Mode, and after a reboot. Extending doesn't end anything until the purchase
|
||||
actually goes through.
|
||||
|
||||
**Expiry reminders** fire a configurable lead time (default 15 min) before the end, via
|
||||
`AlarmManager`. Configure them in **Account → Notifications**, where a **"Send a test
|
||||
reminder"** button lets you confirm they fire on your phone; the same screen has a toggle
|
||||
for the ongoing countdown.
|
||||
|
||||
## Distribution via Obtainium (self-hosted)
|
||||
|
||||
|
|
|
|||
|
|
@ -3,14 +3,14 @@
|
|||
"name": "BigBrainParking",
|
||||
"slug": "bigbrainparking",
|
||||
"scheme": "bigbrainparking",
|
||||
"version": "0.4.1",
|
||||
"version": "0.6.4",
|
||||
"orientation": "portrait",
|
||||
"userInterfaceStyle": "automatic",
|
||||
"newArchEnabled": true,
|
||||
"icon": "./assets/icon.png",
|
||||
"android": {
|
||||
"package": "top.mowden.bigbrainparking",
|
||||
"versionCode": 19,
|
||||
"versionCode": 25,
|
||||
"edgeToEdgeEnabled": true,
|
||||
"adaptiveIcon": {
|
||||
"foregroundImage": "./assets/adaptive-icon.png",
|
||||
|
|
|
|||
|
|
@ -1,9 +1,37 @@
|
|||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
|
||||
<!-- The countdown is pinned by a foreground service so it survives the app being
|
||||
killed and can be restored after a reboot. "specialUse" is the right type: a
|
||||
user-visible parking timer isn't media, location, or data sync. -->
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_SPECIAL_USE" />
|
||||
<uses-permission android:name="android.permission.RECEIVE_BOOT_COMPLETED" />
|
||||
|
||||
<application>
|
||||
<!-- Fully-qualified name: relative ".Name" would resolve against the app
|
||||
<service
|
||||
android:name="expo.modules.bbpnotify.BbpSessionService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="specialUse">
|
||||
<property
|
||||
android:name="android.app.PROPERTY_SPECIAL_USE_FGS_SUBTYPE"
|
||||
android:value="Ongoing countdown for the parking session the user paid for or checked into, with end/extend actions." />
|
||||
</service>
|
||||
|
||||
<!-- Fully-qualified names: a relative ".Name" would resolve against the app
|
||||
package, not this module's namespace, once manifests are merged. -->
|
||||
<receiver
|
||||
android:name="expo.modules.bbpnotify.BbpActionReceiver"
|
||||
android:exported="false" />
|
||||
|
||||
<!-- BOOT_COMPLETED and MY_PACKAGE_REPLACED are protected system broadcasts, so
|
||||
the system still delivers them to a non-exported receiver. -->
|
||||
<receiver
|
||||
android:name="expo.modules.bbpnotify.BbpBootReceiver"
|
||||
android:exported="false">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.BOOT_COMPLETED" />
|
||||
<action android:name="android.intent.action.MY_PACKAGE_REPLACED" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
</application>
|
||||
</manifest>
|
||||
|
|
|
|||
|
|
@ -3,24 +3,28 @@ package expo.modules.bbpnotify
|
|||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
|
||||
/**
|
||||
* Handles the check-in notification's "End" / "Pay" buttons, even when the app
|
||||
* process is dead. It records the choice in SharedPreferences (read by JS via
|
||||
* `consumePendingAction` on next foreground) and, for "Pay", relaunches the app
|
||||
* so the check-in can hand off to the paid-session flow.
|
||||
* Handles the notification's "End" / "Extend" buttons, even when the app process is
|
||||
* dead. Each button records its choice in [BbpSessionStore]; JS picks it up via
|
||||
* `consumePendingAction` the next time it runs and finishes the job on its side
|
||||
* (clearing local state, cancelling the expiry reminder, opening the pay screen).
|
||||
*/
|
||||
class BbpActionReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
val prefs = context.getSharedPreferences(BbpNotifyModule.PREFS_NAME, Context.MODE_PRIVATE)
|
||||
when (intent.action) {
|
||||
BbpNotifyModule.ACTION_END -> {
|
||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "end").apply()
|
||||
NotificationManagerCompat.from(context).cancel(BbpNotifyModule.NOTIF_ID)
|
||||
ACTION_END -> {
|
||||
// Ending is immediate and local: drop the session and take the notification
|
||||
// down with the service, without waiting for the app to be opened.
|
||||
BbpSessionStore.setPending(context, "end")
|
||||
BbpSessionStore.clear(context)
|
||||
BbpSessionService.stop(context)
|
||||
}
|
||||
BbpNotifyModule.ACTION_PAY -> {
|
||||
prefs.edit().putString(BbpNotifyModule.PREF_PENDING, "pay").apply()
|
||||
ACTION_EXTEND -> {
|
||||
// Extending does NOT end anything — the time already bought keeps running,
|
||||
// and the purchase might be abandoned. Leave the session and its countdown
|
||||
// alone; JS replaces them if and when more time is actually bought.
|
||||
BbpSessionStore.setPending(context, "extend")
|
||||
context.packageManager.getLaunchIntentForPackage(context.packageName)?.let { launch ->
|
||||
launch.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(launch)
|
||||
|
|
@ -28,4 +32,9 @@ class BbpActionReceiver : BroadcastReceiver() {
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_END = "expo.modules.bbpnotify.END"
|
||||
const val ACTION_EXTEND = "expo.modules.bbpnotify.EXTEND"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,31 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
|
||||
/**
|
||||
* Brings the parking countdown back after a reboot or an app update, without the
|
||||
* user having to open the app.
|
||||
*
|
||||
* `specialUse` is one of the foreground-service types Android 14/15 still allow to
|
||||
* be started from BOOT_COMPLETED, so the service can claim its notification here.
|
||||
* [BbpSessionStore.load] returns null for an already-expired session, so a stale
|
||||
* record from yesterday's parking never resurrects itself.
|
||||
*/
|
||||
class BbpBootReceiver : BroadcastReceiver() {
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
Intent.ACTION_BOOT_COMPLETED, Intent.ACTION_MY_PACKAGE_REPLACED -> Unit
|
||||
else -> return
|
||||
}
|
||||
|
||||
if (BbpSessionStore.load(context) == null) {
|
||||
// No live session — make sure an expired record doesn't linger.
|
||||
if (BbpSessionStore.hasRecord(context)) BbpSessionStore.clear(context)
|
||||
return
|
||||
}
|
||||
|
||||
BbpSessionService.start(context)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,92 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
|
||||
/**
|
||||
* Builds the ongoing parking notification.
|
||||
*
|
||||
* The remaining time is an Android **chronometer counting down** to the session
|
||||
* end: `setWhen(endMs)` + `setUsesChronometer` + `setChronometerCountDown` hand the
|
||||
* end time to the system, which redraws the ticking text itself every second. The
|
||||
* app burns no CPU and needs no timer of its own, so the countdown stays accurate
|
||||
* while the app is closed.
|
||||
*
|
||||
* Shared by the foreground service and by the boot receiver's fallback path.
|
||||
*/
|
||||
internal object BbpNotification {
|
||||
const val CHANNEL_ID = "session-status"
|
||||
const val NOTIF_ID = 42421
|
||||
|
||||
fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) return
|
||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel =
|
||||
NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
mgr.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
fun build(ctx: Context, s: BbpSessionStore.Session): Notification {
|
||||
ensureChannel(ctx)
|
||||
val builder = base(ctx)
|
||||
.setContentTitle(s.title)
|
||||
.setContentText(s.body)
|
||||
.setWhen(s.endMs)
|
||||
.setShowWhen(true)
|
||||
.setUsesChronometer(true)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
builder.setChronometerCountDown(true)
|
||||
}
|
||||
|
||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx, 0, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
builder.addAction(0, s.endLabel, actionIntent(ctx, BbpActionReceiver.ACTION_END))
|
||||
builder.addAction(0, s.extendLabel, actionIntent(ctx, BbpActionReceiver.ACTION_EXTEND))
|
||||
return builder.build()
|
||||
}
|
||||
|
||||
/**
|
||||
* A bare notification for the start-then-immediately-stop path. A service launched
|
||||
* with startForegroundService() must call startForeground() within a few seconds or
|
||||
* the system kills the process, and that holds even when we've just discovered there
|
||||
* is no session to show — so we post this and remove it in the same breath. It is
|
||||
* never on screen long enough to be seen.
|
||||
*/
|
||||
fun placeholder(ctx: Context): Notification {
|
||||
ensureChannel(ctx)
|
||||
return base(ctx).setContentTitle("Parking").build()
|
||||
}
|
||||
|
||||
private fun base(ctx: Context) =
|
||||
NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
private fun actionIntent(ctx: Context, action: String): PendingIntent {
|
||||
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
|
||||
return PendingIntent.getBroadcast(
|
||||
ctx, action.hashCode(), intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -1,138 +1,63 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import expo.modules.kotlin.modules.Module
|
||||
import expo.modules.kotlin.modules.ModuleDefinition
|
||||
|
||||
/**
|
||||
* Posts an ongoing notification whose "time" is a native Android chronometer
|
||||
* counting DOWN to an end time — the system ticks it every second with no app
|
||||
* CPU/battery, so it works while the app is closed.
|
||||
* JS bridge for the ongoing parking notification.
|
||||
*
|
||||
* Two flavours: a plain paid-session countdown (`showCountdown`) and a free
|
||||
* check-in countdown (`showCheckin`) that adds "End" / "Pay" action buttons.
|
||||
* The buttons fire a BroadcastReceiver ([BbpActionReceiver]) that records the
|
||||
* choice in SharedPreferences; JS reads it via `consumePendingAction` on the
|
||||
* next foreground. Uses only platform APIs — no third-party dependencies.
|
||||
* The module itself holds no state — it writes the session to [BbpSessionStore] and
|
||||
* lets [BbpSessionService] render it. That keeps the app process out of the loop:
|
||||
* the notification is just as correct after the JS engine is gone as it is while
|
||||
* the user is looking at the app. Uses only platform APIs, no third-party deps.
|
||||
*/
|
||||
class BbpNotifyModule : Module() {
|
||||
override fun definition() = ModuleDefinition {
|
||||
Name("BbpNotify")
|
||||
|
||||
// Returns a short diagnostic string so JS can log exactly what happened
|
||||
// (posted / notifications-disabled / exception) — notify() fails silently.
|
||||
AsyncFunction("showCountdown") { title: String, body: String, endTimeMillis: Double ->
|
||||
/**
|
||||
* Post (or replace) the countdown for an active session. Returns a short
|
||||
* diagnostic string so JS can log exactly what happened — notifications and
|
||||
* service starts both fail silently otherwise.
|
||||
*/
|
||||
AsyncFunction("showSession") {
|
||||
title: String, body: String, endTimeMillis: Double, endLabel: String, extendLabel: String ->
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||
postCountdown(ctx, title, body, endTimeMillis, null, null)
|
||||
val endMs = endTimeMillis.toLong()
|
||||
if (endMs <= System.currentTimeMillis()) {
|
||||
// Nothing to count down to — treat it as "no session" rather than posting
|
||||
// a notification that is already at zero.
|
||||
BbpSessionStore.clear(ctx)
|
||||
BbpSessionService.stop(ctx)
|
||||
return@AsyncFunction "expired"
|
||||
}
|
||||
BbpSessionStore.save(
|
||||
ctx,
|
||||
BbpSessionStore.Session(title, body, endMs, endLabel, extendLabel),
|
||||
)
|
||||
BbpSessionService.start(ctx)
|
||||
}
|
||||
|
||||
// Same ticking countdown, plus "End" and "Pay" action buttons.
|
||||
AsyncFunction("showCheckin") {
|
||||
title: String, body: String, endTimeMillis: Double, endLabel: String, payLabel: String ->
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction "no-context"
|
||||
postCountdown(ctx, title, body, endTimeMillis, endLabel, payLabel)
|
||||
}
|
||||
|
||||
AsyncFunction("clear") {
|
||||
val ctx = appContext.reactContext
|
||||
if (ctx != null) {
|
||||
NotificationManagerCompat.from(ctx).cancel(NOTIF_ID)
|
||||
/** No session: drop the record and stop the service, taking the notification with it. */
|
||||
AsyncFunction("clearSession") {
|
||||
// No bare `return@AsyncFunction` here: the lambda's inferred return type is
|
||||
// Any?, so an early return of Unit doesn't type-check.
|
||||
appContext.reactContext?.let { ctx ->
|
||||
BbpSessionStore.clear(ctx)
|
||||
BbpSessionService.stop(ctx)
|
||||
}
|
||||
}
|
||||
|
||||
// Read + clear the action a notification button set while the app was away.
|
||||
// Returns "end", "pay", or "".
|
||||
/** Read + clear the action a notification button set. Returns "end", "extend", or "". */
|
||||
AsyncFunction("consumePendingAction") {
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction ""
|
||||
val prefs = ctx.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||
val action = prefs.getString(PREF_PENDING, "") ?: ""
|
||||
if (action.isNotEmpty()) prefs.edit().remove(PREF_PENDING).apply()
|
||||
action
|
||||
}
|
||||
BbpSessionStore.consumePending(ctx)
|
||||
}
|
||||
|
||||
private fun postCountdown(
|
||||
ctx: Context,
|
||||
title: String,
|
||||
body: String,
|
||||
endTimeMillis: Double,
|
||||
endLabel: String?,
|
||||
payLabel: String?,
|
||||
): String {
|
||||
ensureChannel(ctx)
|
||||
val mgr = NotificationManagerCompat.from(ctx)
|
||||
val enabled = mgr.areNotificationsEnabled()
|
||||
|
||||
val builder = NotificationCompat.Builder(ctx, CHANNEL_ID)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setSmallIcon(R.drawable.bbp_stat_parking)
|
||||
.setOngoing(true)
|
||||
.setOnlyAlertOnce(true)
|
||||
.setShowWhen(true)
|
||||
.setWhen(endTimeMillis.toLong())
|
||||
.setUsesChronometer(true)
|
||||
.setPriority(NotificationCompat.PRIORITY_LOW)
|
||||
.setCategory(NotificationCompat.CATEGORY_STATUS)
|
||||
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) {
|
||||
builder.setChronometerCountDown(true)
|
||||
}
|
||||
|
||||
ctx.packageManager.getLaunchIntentForPackage(ctx.packageName)?.let { launch ->
|
||||
builder.setContentIntent(
|
||||
PendingIntent.getActivity(
|
||||
ctx, 0, launch,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
if (endLabel != null) builder.addAction(0, endLabel, actionIntent(ctx, ACTION_END))
|
||||
if (payLabel != null) builder.addAction(0, payLabel, actionIntent(ctx, ACTION_PAY))
|
||||
|
||||
return try {
|
||||
mgr.notify(NOTIF_ID, builder.build())
|
||||
"posted enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||
} catch (e: SecurityException) {
|
||||
"security-exception enabled=$enabled msg=${e.message}"
|
||||
} catch (e: Exception) {
|
||||
"exception enabled=$enabled ${e.javaClass.simpleName}=${e.message}"
|
||||
}
|
||||
}
|
||||
|
||||
private fun actionIntent(ctx: Context, action: String): PendingIntent {
|
||||
val intent = Intent(ctx, BbpActionReceiver::class.java).setAction(action)
|
||||
return PendingIntent.getBroadcast(
|
||||
ctx, action.hashCode(), intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE,
|
||||
)
|
||||
}
|
||||
|
||||
private fun ensureChannel(ctx: Context) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
val mgr = ctx.getSystemService(NotificationManager::class.java)
|
||||
if (mgr.getNotificationChannel(CHANNEL_ID) == null) {
|
||||
val channel = NotificationChannel(CHANNEL_ID, "Active parking", NotificationManager.IMPORTANCE_LOW)
|
||||
channel.setShowBadge(false)
|
||||
mgr.createNotificationChannel(channel)
|
||||
/** True when a live session record exists — lets JS reconcile after a cold start. */
|
||||
AsyncFunction("hasActiveSession") {
|
||||
val ctx = appContext.reactContext ?: return@AsyncFunction false
|
||||
BbpSessionStore.load(ctx) != null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val CHANNEL_ID = "session-status"
|
||||
const val NOTIF_ID = 42421
|
||||
const val PREFS_NAME = "bbp_notify"
|
||||
const val PREF_PENDING = "pending_action"
|
||||
const val ACTION_END = "expo.modules.bbpnotify.END"
|
||||
const val ACTION_PAY = "expo.modules.bbpnotify.PAY"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,149 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.os.Handler
|
||||
import android.os.IBinder
|
||||
import android.os.Looper
|
||||
import androidx.core.app.NotificationManagerCompat
|
||||
import androidx.core.app.ServiceCompat
|
||||
|
||||
/**
|
||||
* Foreground service that owns the ongoing parking notification.
|
||||
*
|
||||
* A plain `notify()` was not enough: on Android 14+ the user can swipe an ongoing
|
||||
* notification away, and nothing brings it back after a reboot. A foreground
|
||||
* service pins the notification for as long as the service runs — the same reason
|
||||
* ntfy's notification stays put on GrapheneOS.
|
||||
*
|
||||
* The service's lifetime is exactly the session's lifetime. It runs while there is
|
||||
* an active session and fully stops — removing the notification — the moment there
|
||||
* isn't one: on "End", when the countdown reaches zero, or when JS clears it. It
|
||||
* never lingers with nothing to show.
|
||||
*/
|
||||
class BbpSessionService : Service() {
|
||||
private val handler = Handler(Looper.getMainLooper())
|
||||
private var expiryTask: Runnable? = null
|
||||
|
||||
override fun onBind(intent: Intent?): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
// A null intent means the system restarted us after killing the process
|
||||
// (START_STICKY) — fall through and rebuild from the store.
|
||||
val stopping = intent?.action == ACTION_STOP
|
||||
val session = if (stopping) null else BbpSessionStore.load(this)
|
||||
|
||||
if (session == null) {
|
||||
// Satisfy the startForegroundService() contract before standing down, or the
|
||||
// system kills us for not calling startForeground() in time.
|
||||
goForeground(BbpNotification.placeholder(this))
|
||||
if (!stopping) BbpSessionStore.clear(this) // expired record — don't leave it around
|
||||
shutdown()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
if (!goForeground(BbpNotification.build(this, session))) {
|
||||
// The system refused to let us hold a foreground service right now. The
|
||||
// countdown matters more than the pinning, so leave the notification posted
|
||||
// on its own and stand the service down rather than crash.
|
||||
postDirectly(this)
|
||||
stopSelf()
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
scheduleExpiry(session.endMs)
|
||||
return START_STICKY
|
||||
}
|
||||
|
||||
/**
|
||||
* startForeground() can still throw on Android 12+ even after startForegroundService()
|
||||
* was accepted (background-start restrictions are evaluated here too). An uncaught
|
||||
* throw would crash the app, so report the failure instead and let the caller fall back.
|
||||
*/
|
||||
private fun goForeground(notification: android.app.Notification): Boolean =
|
||||
try {
|
||||
startForeground(BbpNotification.NOTIF_ID, notification)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
false
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop the moment the meter runs out, so a finished session never leaves a dead
|
||||
* "0:00" notification pinned to the shade. A posted callback is enough here — a
|
||||
* foreground service is alive to run it, no alarm permission needed.
|
||||
*/
|
||||
private fun scheduleExpiry(endMs: Long) {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
val task = Runnable {
|
||||
BbpSessionStore.clear(this)
|
||||
shutdown()
|
||||
}
|
||||
expiryTask = task
|
||||
handler.postDelayed(task, (endMs - System.currentTimeMillis()).coerceAtLeast(0L))
|
||||
}
|
||||
|
||||
private fun shutdown() {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
expiryTask = null
|
||||
ServiceCompat.stopForeground(this, ServiceCompat.STOP_FOREGROUND_REMOVE)
|
||||
stopSelf()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
expiryTask?.let { handler.removeCallbacks(it) }
|
||||
expiryTask = null
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val ACTION_STOP = "expo.modules.bbpnotify.STOP_SERVICE"
|
||||
|
||||
/**
|
||||
* Start (or refresh) the service from the stored session. Returns a short
|
||||
* diagnostic string — JS logs it, because a failure here is otherwise silent.
|
||||
*/
|
||||
fun start(ctx: Context): String {
|
||||
val enabled = NotificationManagerCompat.from(ctx).areNotificationsEnabled()
|
||||
val intent = Intent(ctx, BbpSessionService::class.java)
|
||||
return try {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
|
||||
ctx.startForegroundService(intent)
|
||||
} else {
|
||||
ctx.startService(intent)
|
||||
}
|
||||
"service-started enabled=$enabled sdk=${Build.VERSION.SDK_INT}"
|
||||
} catch (e: Exception) {
|
||||
// Background-start restrictions can refuse the service (Android 12+). The
|
||||
// countdown still matters more than the pinning, so fall back to posting the
|
||||
// notification directly; it just becomes swipe-dismissable.
|
||||
val fallback = postDirectly(ctx)
|
||||
"service-failed=${e.javaClass.simpleName} fallback=$fallback enabled=$enabled"
|
||||
}
|
||||
}
|
||||
|
||||
fun stop(ctx: Context) {
|
||||
try {
|
||||
ctx.startService(Intent(ctx, BbpSessionService::class.java).setAction(ACTION_STOP))
|
||||
} catch (_: Exception) {
|
||||
// The service may already be gone; make sure the notification is too.
|
||||
}
|
||||
NotificationManagerCompat.from(ctx).cancel(BbpNotification.NOTIF_ID)
|
||||
}
|
||||
|
||||
/** Last-resort path: the plain ongoing notification, with no service pinning it. */
|
||||
fun postDirectly(ctx: Context): String {
|
||||
val session = BbpSessionStore.load(ctx) ?: return "no-session"
|
||||
return try {
|
||||
NotificationManagerCompat.from(ctx)
|
||||
.notify(BbpNotification.NOTIF_ID, BbpNotification.build(ctx, session))
|
||||
"posted"
|
||||
} catch (e: SecurityException) {
|
||||
"no-permission"
|
||||
} catch (e: Exception) {
|
||||
"failed=${e.javaClass.simpleName}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,86 @@
|
|||
package expo.modules.bbpnotify
|
||||
|
||||
import android.content.Context
|
||||
|
||||
/**
|
||||
* The single active-parking record the foreground service renders.
|
||||
*
|
||||
* It lives in SharedPreferences rather than in memory because every other actor
|
||||
* here can run without the JS app: the service after a system restart, the boot
|
||||
* receiver after a reboot, the action receiver after the process is gone. They
|
||||
* all read the same row, so there is exactly one source of truth for "is there a
|
||||
* session, and what does its notification say".
|
||||
*/
|
||||
internal object BbpSessionStore {
|
||||
const val PREFS = "bbp_notify"
|
||||
|
||||
private const val K_TITLE = "title"
|
||||
private const val K_BODY = "body"
|
||||
private const val K_END = "end_ms"
|
||||
private const val K_END_LABEL = "end_label"
|
||||
private const val K_EXTEND_LABEL = "extend_label"
|
||||
private const val K_PENDING = "pending_action"
|
||||
|
||||
data class Session(
|
||||
val title: String,
|
||||
val body: String,
|
||||
val endMs: Long,
|
||||
val endLabel: String,
|
||||
val extendLabel: String,
|
||||
)
|
||||
|
||||
private fun prefs(ctx: Context) = ctx.getSharedPreferences(PREFS, Context.MODE_PRIVATE)
|
||||
|
||||
fun save(ctx: Context, s: Session) {
|
||||
prefs(ctx).edit()
|
||||
.putString(K_TITLE, s.title)
|
||||
.putString(K_BODY, s.body)
|
||||
.putLong(K_END, s.endMs)
|
||||
.putString(K_END_LABEL, s.endLabel)
|
||||
.putString(K_EXTEND_LABEL, s.extendLabel)
|
||||
.apply()
|
||||
}
|
||||
|
||||
/**
|
||||
* The current session, or null when there isn't one. A record whose end time has
|
||||
* already passed reads as null so an expired session can never re-post itself
|
||||
* (e.g. a reboot hours after the meter ran out).
|
||||
*/
|
||||
fun load(ctx: Context): Session? {
|
||||
val p = prefs(ctx)
|
||||
val end = p.getLong(K_END, 0L)
|
||||
if (end <= System.currentTimeMillis()) return null
|
||||
return Session(
|
||||
title = p.getString(K_TITLE, "") ?: "",
|
||||
body = p.getString(K_BODY, "") ?: "",
|
||||
endMs = end,
|
||||
endLabel = p.getString(K_END_LABEL, "End") ?: "End",
|
||||
extendLabel = p.getString(K_EXTEND_LABEL, "Extend") ?: "Extend",
|
||||
)
|
||||
}
|
||||
|
||||
/** True when a record exists at all, expired or not — used to decide if cleanup is needed. */
|
||||
fun hasRecord(ctx: Context): Boolean = prefs(ctx).contains(K_END)
|
||||
|
||||
fun clear(ctx: Context) {
|
||||
prefs(ctx).edit()
|
||||
.remove(K_TITLE)
|
||||
.remove(K_BODY)
|
||||
.remove(K_END)
|
||||
.remove(K_END_LABEL)
|
||||
.remove(K_EXTEND_LABEL)
|
||||
.apply()
|
||||
}
|
||||
|
||||
fun setPending(ctx: Context, action: String) {
|
||||
prefs(ctx).edit().putString(K_PENDING, action).apply()
|
||||
}
|
||||
|
||||
/** Read + clear the action a notification button recorded while the app was away. */
|
||||
fun consumePending(ctx: Context): String {
|
||||
val p = prefs(ctx)
|
||||
val action = p.getString(K_PENDING, "") ?: ""
|
||||
if (action.isNotEmpty()) p.edit().remove(K_PENDING).apply()
|
||||
return action
|
||||
}
|
||||
}
|
||||
|
|
@ -3,25 +3,26 @@ import { requireOptionalNativeModule } from 'expo-modules-core';
|
|||
|
||||
interface BbpNotifyNative {
|
||||
/**
|
||||
* Post/replace an ongoing notification with a native chronometer counting down
|
||||
* to endTimeMillis. Returns a short diagnostic string (e.g. "posted enabled=true").
|
||||
* Post/replace the ongoing session notification: a native chronometer counting
|
||||
* down to endTimeMillis, pinned by a foreground service, with "End" and "Extend"
|
||||
* action buttons. Returns a short diagnostic string (e.g. "service-started …").
|
||||
*/
|
||||
showCountdown(title: string, body: string, endTimeMillis: number): Promise<string>;
|
||||
/** Same ticking countdown, plus "End" / "Pay" action buttons. */
|
||||
showCheckin(
|
||||
showSession(
|
||||
title: string,
|
||||
body: string,
|
||||
endTimeMillis: number,
|
||||
endLabel: string,
|
||||
payLabel: string,
|
||||
extendLabel: string,
|
||||
): Promise<string>;
|
||||
/** Remove the countdown notification. */
|
||||
clear(): Promise<void>;
|
||||
/** Read + clear the action a notification button set: 'end' | 'pay' | ''. */
|
||||
/** No active session: stop the service and remove the notification. */
|
||||
clearSession(): Promise<void>;
|
||||
/** Read + clear the action a notification button set: 'end' | 'extend' | ''. */
|
||||
consumePendingAction(): Promise<string>;
|
||||
/** Whether the native side still holds a live (unexpired) session record. */
|
||||
hasActiveSession(): Promise<boolean>;
|
||||
}
|
||||
|
||||
export type PendingAction = 'end' | 'pay' | null;
|
||||
export type PendingAction = 'end' | 'extend' | null;
|
||||
|
||||
// Android-only, and only present in a build that includes the native module
|
||||
// (returns null in Expo Go / other platforms — callers degrade gracefully).
|
||||
|
|
@ -33,32 +34,27 @@ const native =
|
|||
/** True when the native ticking-countdown module is available. */
|
||||
export const hasNativeCountdown = native != null;
|
||||
|
||||
export async function showCountdown(
|
||||
title: string,
|
||||
body: string,
|
||||
endTimeMillis: number,
|
||||
): Promise<string> {
|
||||
if (!native) return 'no-native-module';
|
||||
return native.showCountdown(title, body, endTimeMillis);
|
||||
}
|
||||
|
||||
export async function showCheckin(
|
||||
export async function showSession(
|
||||
title: string,
|
||||
body: string,
|
||||
endTimeMillis: number,
|
||||
endLabel: string,
|
||||
payLabel: string,
|
||||
extendLabel: string,
|
||||
): Promise<string> {
|
||||
if (!native) return 'no-native-module';
|
||||
return native.showCheckin(title, body, endTimeMillis, endLabel, payLabel);
|
||||
return native.showSession(title, body, endTimeMillis, endLabel, extendLabel);
|
||||
}
|
||||
|
||||
export async function clearCountdown(): Promise<void> {
|
||||
await native?.clear();
|
||||
export async function clearSession(): Promise<void> {
|
||||
await native?.clearSession();
|
||||
}
|
||||
|
||||
/** Read + clear a notification-button action taken while the app was away. */
|
||||
export async function consumePendingAction(): Promise<PendingAction> {
|
||||
const a = await native?.consumePendingAction();
|
||||
return a === 'end' || a === 'pay' ? a : null;
|
||||
return a === 'end' || a === 'extend' ? a : null;
|
||||
}
|
||||
|
||||
export async function hasActiveSession(): Promise<boolean> {
|
||||
return (await native?.hasActiveSession()) ?? false;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
"android": "expo run:android",
|
||||
"prebuild": "expo prebuild --platform android --clean",
|
||||
"typecheck": "tsc --noEmit",
|
||||
"test": "node --import tsx --test test/*.test.ts",
|
||||
"ios": "expo run:ios"
|
||||
},
|
||||
"dependencies": {
|
||||
|
|
@ -37,6 +38,7 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "~19.0.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "~5.4.0"
|
||||
}
|
||||
}
|
||||
|
|
|
|||
234
app/src/api/parkingAreas.ts
Normal file
234
app/src/api/parkingAreas.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
import Constants from 'expo-constants';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import { getAdminToken } from './adminStore';
|
||||
import bundled from '@/features/citymap/parkingAreas.json';
|
||||
import {
|
||||
adjustGeometry,
|
||||
overlayAnchor,
|
||||
IDENTITY_OVERLAY,
|
||||
type AreaGeometry,
|
||||
type OverlayAdjust,
|
||||
} from '@/features/citymap/geo';
|
||||
|
||||
/**
|
||||
* The colour-coded areas from the City of Sandpoint's printed Downtown &
|
||||
* Waterfront parking map, georeferenced.
|
||||
*
|
||||
* This is city geography, not ParkSmarter data — no call in this file, or in
|
||||
* anything that uses it, ever reaches the IPS API. That is the whole point:
|
||||
* tracking your time on one of these spots has to work with no account, no
|
||||
* network, and no payment.
|
||||
*
|
||||
* Three sources, in order: the server (editable without an app release), the
|
||||
* on-device cache (so it works offline), and a copy bundled with the app (so a
|
||||
* fresh install works before the server has ever been reached).
|
||||
*/
|
||||
|
||||
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
|
||||
|
||||
export interface ParkingArea {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
/** Human name, e.g. "N 3rd Ave · Cedar St to Oak St". */
|
||||
name: string;
|
||||
/** Short category label, e.g. "3-hour". */
|
||||
label: string;
|
||||
/** The map legend's own wording, e.g. "3-hour or permit". */
|
||||
legend: string;
|
||||
/** Default tracked duration in hours; 0 for the no-limit category. */
|
||||
hours: number;
|
||||
color: string;
|
||||
shape: 'line' | 'polygon';
|
||||
geometry: AreaGeometry;
|
||||
/**
|
||||
* Overrides the by-category default in [areaRequiresAccount]. Only set this to
|
||||
* correct a specific lot — e.g. one that turns out to be kiosk- or permit-only.
|
||||
*/
|
||||
requiresAccount?: boolean;
|
||||
}
|
||||
|
||||
export interface AreaData {
|
||||
areas: ParkingArea[];
|
||||
overlay: OverlayAdjust;
|
||||
/** Where the set came from, for the diagnostics screen. */
|
||||
source: 'server' | 'cache' | 'bundled';
|
||||
}
|
||||
|
||||
const CACHE_KEY = 'ps_parking_areas';
|
||||
const BASE_URL = String(Constants.expoConfig?.extra?.zoneLabelsApiUrl ?? '').replace(/\/+$/, '');
|
||||
|
||||
export class ParkingAreasError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
readonly status?: number,
|
||||
) {
|
||||
super(message);
|
||||
this.name = 'ParkingAreasError';
|
||||
}
|
||||
}
|
||||
|
||||
/** Colours match the printed map's legend. */
|
||||
export const AREA_COLORS: Record<AreaKind, string> = {
|
||||
green_lot: '#75b259',
|
||||
free_2h: '#d367cc',
|
||||
limit_3h: '#ccc542',
|
||||
limit_4h: '#f78b08',
|
||||
no_limit: '#c3c4c2',
|
||||
};
|
||||
|
||||
/** Whether parking in this category costs money. Only the city lots do. */
|
||||
export function areaIsFree(kind: AreaKind): boolean {
|
||||
return kind !== 'green_lot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether you need a ParkSmarter account to park here.
|
||||
*
|
||||
* The city lots are the map's only paid category ("City lots — Paid hourly or
|
||||
* permit"); paying for them means ParkSmarter, so they're no use to someone
|
||||
* browsing without an account. Everything else is free with a posted time limit
|
||||
* and needs nothing. A single lot can override this if it turns out to take
|
||||
* payment some other way.
|
||||
*/
|
||||
export function areaRequiresAccount(area: ParkingArea): boolean {
|
||||
return area.requiresAccount ?? area.kind === 'green_lot';
|
||||
}
|
||||
|
||||
/**
|
||||
* Durations offered when starting tracking, the posted limit first.
|
||||
*
|
||||
* Time-limited spots stop at the posted limit — offering to track 4 hours in a
|
||||
* 2-hour space would just be scheduling a ticket. Lots and unlimited spots have
|
||||
* no posted ceiling, so they get the long options.
|
||||
*/
|
||||
export function areaDurationOptions(area: ParkingArea): number[] {
|
||||
if (area.kind === 'no_limit' || area.kind === 'green_lot') return [1, 2, 3, 4, 8, 12];
|
||||
const posted = area.hours || 2;
|
||||
return [...new Set([posted, 3, 2, 1, 0.5].filter((h) => h <= posted))].sort((a, b) => b - a);
|
||||
}
|
||||
|
||||
const BUNDLED: ParkingArea[] = (bundled as any).features.map((f: any) => ({
|
||||
...f.properties,
|
||||
geometry: f.geometry,
|
||||
}));
|
||||
|
||||
/* ------------------------------------------------------------------- cache */
|
||||
|
||||
let memCache: AreaData | null = null;
|
||||
|
||||
async function readCache(): Promise<AreaData | null> {
|
||||
const raw = await AsyncStorage.getItem(CACHE_KEY);
|
||||
if (!raw) return null;
|
||||
try {
|
||||
const parsed = JSON.parse(raw) as { areas: ParkingArea[]; overlay: OverlayAdjust };
|
||||
if (!parsed.areas?.length) return null;
|
||||
return { areas: parsed.areas, overlay: parsed.overlay ?? IDENTITY_OVERLAY, source: 'cache' };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function writeCache(areas: ParkingArea[], overlay: OverlayAdjust): Promise<void> {
|
||||
await AsyncStorage.setItem(CACHE_KEY, JSON.stringify({ areas, overlay }));
|
||||
}
|
||||
|
||||
/* -------------------------------------------------------------------- read */
|
||||
|
||||
/**
|
||||
* The area set, without the overlay correction applied. Never throws and never
|
||||
* blocks on the network — the bundled copy is always a valid answer.
|
||||
*/
|
||||
export async function loadAreaData(): Promise<AreaData> {
|
||||
if (memCache) return memCache;
|
||||
const cached = await readCache();
|
||||
memCache = cached ?? { areas: BUNDLED, overlay: IDENTITY_OVERLAY, source: 'bundled' };
|
||||
return memCache;
|
||||
}
|
||||
|
||||
/**
|
||||
* The area set ready to draw and hit-test, with the alignment correction baked
|
||||
* in. Display and hit-testing must use the same geometry or tapping a segment
|
||||
* would select a different one than the one under your finger.
|
||||
*/
|
||||
export async function getAdjustedAreas(): Promise<AreaData> {
|
||||
const data = await loadAreaData();
|
||||
return { ...data, areas: applyOverlay(data.areas, data.overlay) };
|
||||
}
|
||||
|
||||
/** Apply an overlay correction to a set of areas (also used for live preview). */
|
||||
export function applyOverlay(areas: ParkingArea[], overlay: OverlayAdjust): ParkingArea[] {
|
||||
const anchor = overlayAnchor(areas.map((a) => a.geometry));
|
||||
return areas.map((a) => ({ ...a, geometry: adjustGeometry(a.geometry, overlay, anchor) }));
|
||||
}
|
||||
|
||||
/** Pull the areas + overlay from the server and cache them. */
|
||||
export async function refreshAreas(): Promise<AreaData> {
|
||||
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
|
||||
const res = await fetch(`${BASE_URL}/api/areas`);
|
||||
if (!res.ok) throw new ParkingAreasError('areas fetch failed', res.status);
|
||||
const body = (await res.json()) as { areas?: ParkingArea[]; overlay?: OverlayAdjust };
|
||||
|
||||
// An empty server (not yet seeded) must not wipe a working local map.
|
||||
if (!body.areas?.length) return loadAreaData();
|
||||
|
||||
const overlay = body.overlay ?? IDENTITY_OVERLAY;
|
||||
await writeCache(body.areas, overlay);
|
||||
memCache = { areas: body.areas, overlay, source: 'server' };
|
||||
return memCache;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------- admin */
|
||||
|
||||
async function authHeaders(): Promise<Record<string, string>> {
|
||||
const token = await getAdminToken();
|
||||
if (!token) throw new ParkingAreasError('not authenticated as admin');
|
||||
return { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' };
|
||||
}
|
||||
|
||||
/** Push the app's bundled area set to the server, seeding or resetting it. */
|
||||
export async function publishBundledAreas(): Promise<number> {
|
||||
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
|
||||
const res = await fetch(`${BASE_URL}/api/areas`, {
|
||||
method: 'PUT',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify({ areas: BUNDLED }),
|
||||
});
|
||||
if (!res.ok) throw new ParkingAreasError('publish failed', res.status);
|
||||
const body = (await res.json()) as { replaced?: number };
|
||||
memCache = null;
|
||||
await refreshAreas().catch(() => {});
|
||||
return body.replaced ?? 0;
|
||||
}
|
||||
|
||||
/** Persist an alignment correction so every device picks it up. */
|
||||
export async function saveOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<OverlayAdjust> {
|
||||
if (!BASE_URL) throw new ParkingAreasError('zoneLabelsApiUrl is not configured');
|
||||
const res = await fetch(`${BASE_URL}/api/areas/overlay`, {
|
||||
method: 'PUT',
|
||||
headers: await authHeaders(),
|
||||
body: JSON.stringify(o),
|
||||
});
|
||||
if (!res.ok) throw new ParkingAreasError('overlay save failed', res.status);
|
||||
const saved = (await res.json()) as OverlayAdjust;
|
||||
const data = await loadAreaData();
|
||||
await writeCache(data.areas, saved);
|
||||
memCache = { ...data, overlay: saved };
|
||||
return saved;
|
||||
}
|
||||
|
||||
/**
|
||||
* Store an overlay correction on this device only. Lets the alignment be fixed
|
||||
* without the admin token — the nudge is useful to anyone standing on the street.
|
||||
*/
|
||||
export async function saveOverlayLocally(o: Omit<OverlayAdjust, 'updatedAt'>): Promise<void> {
|
||||
const data = await loadAreaData();
|
||||
const overlay = { ...o, updatedAt: Date.now() };
|
||||
await writeCache(data.areas, overlay);
|
||||
memCache = { ...data, overlay };
|
||||
}
|
||||
|
||||
/** Drop the cache so the next read falls back to bundled/server. */
|
||||
export async function resetAreaCache(): Promise<void> {
|
||||
memCache = null;
|
||||
await AsyncStorage.removeItem(CACHE_KEY);
|
||||
}
|
||||
30
app/src/api/parseTime.ts
Normal file
30
app/src/api/parseTime.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
|||
/**
|
||||
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
|
||||
* /api/Session + estimates: "07-15-2026 05:00 PM" dashes, 12h, AM/PM
|
||||
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
|
||||
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so we
|
||||
* build the Date from explicit components.
|
||||
*
|
||||
* This is the single parser for both — an earlier stricter copy that required
|
||||
* dashes and AM/PM silently returned null on the other format, which meant no
|
||||
* countdown notification at all for sessions whose end time came back that way.
|
||||
*/
|
||||
export function parseApiTime(s?: string | null): Date | null {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(
|
||||
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
|
||||
);
|
||||
if (!m) {
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(+d) ? null : d;
|
||||
}
|
||||
let hr = parseInt(m[4], 10);
|
||||
const ap = m[7];
|
||||
if (ap) {
|
||||
const pm = /pm/i.test(ap);
|
||||
if (pm && hr !== 12) hr += 12;
|
||||
if (!pm && hr === 12) hr = 0;
|
||||
}
|
||||
const sec = m[6] ? parseInt(m[6], 10) : 0;
|
||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
|
||||
}
|
||||
|
|
@ -31,9 +31,21 @@ export function AuthProvider({ children }: { children: React.ReactNode }) {
|
|||
const [validity, setValidity] = useState<ApplicationValidityResponse | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Let the non-React modules see the mode. Kept in sync here rather than read
|
||||
// from storage: anonymous mode is deliberately not persisted across restarts.
|
||||
useEffect(() => {
|
||||
authBus.isAnonymous = status === 'anonymous';
|
||||
}, [status]);
|
||||
|
||||
// Any 401 from the API (expired/rotated token) bounces us back to sign-in.
|
||||
useEffect(() => {
|
||||
authBus.onUnauthorized = () => {
|
||||
// ...except in Anonymous Mode, where there is no session to expire. A 401
|
||||
// there just means something asked ParkSmarter a question it had no
|
||||
// business asking, and bouncing to sign-in would make the app unusable
|
||||
// without an account — which is the whole point of the mode. Read at call
|
||||
// time, so this stays correct as the status changes.
|
||||
if (authBus.isAnonymous) return;
|
||||
setError('Your session expired — please sign in again.');
|
||||
setStatus('signedOut');
|
||||
};
|
||||
|
|
|
|||
|
|
@ -2,5 +2,13 @@
|
|||
* Tiny bridge so the API client (created at module load) can notify the React
|
||||
* auth layer when a 401 happens, without a circular import. AuthProvider
|
||||
* registers a handler; the client calls it via app/src/api/client.ts.
|
||||
*
|
||||
* `isAnonymous` mirrors the auth status for the non-React modules that need it.
|
||||
* It matters because the 401 hook is global: it fires on every unauthorized
|
||||
* response whether or not the caller caught the error, so without this a single
|
||||
* stray ParkSmarter call in Anonymous Mode throws the user to the sign-in screen.
|
||||
*/
|
||||
export const authBus: { onUnauthorized?: () => void } = {};
|
||||
export const authBus: {
|
||||
onUnauthorized?: () => void;
|
||||
isAnonymous: boolean;
|
||||
} = { isAnonymous: false };
|
||||
|
|
|
|||
|
|
@ -1,135 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import {
|
||||
showCheckin,
|
||||
clearCountdown,
|
||||
consumePendingAction,
|
||||
hasNativeCountdown,
|
||||
} from '../../../modules/bbp-notify';
|
||||
import { getReminderLeadMinutes, getRemindersEnabled, getCountdownEnabled } from '@/features/notifications/reminderPrefs';
|
||||
import { ensureNotificationPermission } from '@/notifications/localReminders';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import { labelHours, type LabelKind } from '@/api/zoneLabels';
|
||||
import { getCheckin, setCheckin, clearCheckinState, type CheckinState } from './checkinStore';
|
||||
|
||||
const ALERT_ID = 'checkin-alert';
|
||||
const FALLBACK_NOTIF_ID = 'checkin-status';
|
||||
|
||||
function fmtTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/** Post (or re-post) the ongoing ticking check-in notification with End/Pay buttons. */
|
||||
async function postCheckinNotification(s: CheckinState): Promise<void> {
|
||||
if (!(await getCountdownEnabled())) return;
|
||||
const ends = fmtTime(s.endMs);
|
||||
if (hasNativeCountdown) {
|
||||
const diag = await showCheckin(`Free parking · ${s.zoneName}`, `Free until ${ends}`, s.endMs, 'End', 'Pay');
|
||||
logLine(`[CHECKIN] native: ${diag}`);
|
||||
return;
|
||||
}
|
||||
// Fallback (Expo Go / non-native): a static ongoing notification, no buttons.
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: FALLBACK_NOTIF_ID,
|
||||
content: {
|
||||
title: `Free parking · ${s.zoneName}`,
|
||||
body: `Free until ${ends}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
data: { kind: 'session-status' },
|
||||
},
|
||||
trigger:
|
||||
Platform.OS === 'android'
|
||||
? { type: Notifications.SchedulableTriggerInputTypes.DATE, date: new Date(Date.now() + 400), channelId: 'session-status' }
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function scheduleCheckinAlert(s: CheckinState): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
|
||||
if (!(await getRemindersEnabled())) return;
|
||||
const fireAt = s.endMs - s.leadMinutes * 60_000;
|
||||
if (fireAt <= Date.now()) return;
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: ALERT_ID,
|
||||
content: {
|
||||
title: 'Free parking ending soon',
|
||||
body: `${s.zoneName}: your free time ends at ${fmtTime(s.endMs)}. Pay to extend or move your car.`,
|
||||
data: { kind: 'checkin-alert' },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: new Date(fireAt),
|
||||
channelId: 'session-reminders',
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/** Start a local free check-in for `hours` at the given zone. */
|
||||
export async function startCheckin(zone: Zone, hours: number): Promise<void> {
|
||||
const now = Date.now();
|
||||
const state: CheckinState = {
|
||||
zone,
|
||||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
startMs: now,
|
||||
endMs: now + hours * 3_600_000,
|
||||
kind: `free_${hours}h` as LabelKind,
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setCheckin(state);
|
||||
await ensureNotificationPermission();
|
||||
await postCheckinNotification(state);
|
||||
await scheduleCheckinAlert(state);
|
||||
}
|
||||
|
||||
/** End the active check-in and clear its notifications. */
|
||||
export async function endCheckin(): Promise<void> {
|
||||
await clearCheckinState();
|
||||
await clearCountdown().catch(() => {});
|
||||
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||
await Notifications.cancelScheduledNotificationAsync(ALERT_ID).catch(() => {});
|
||||
}
|
||||
|
||||
/** Hours of free time for a label kind, or null for pay-immediate. */
|
||||
export { labelHours };
|
||||
|
||||
/**
|
||||
* Keep the check-in in sync on foreground: apply any notification-button action
|
||||
* (End clears it; Pay hands off to the paid flow), drop expired check-ins, and
|
||||
* re-post the ongoing notification (e.g. after a reboot). Mirrors useSessionStatusSync.
|
||||
*/
|
||||
export function useCheckinSync(): void {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
useEffect(() => {
|
||||
const run = async () => {
|
||||
const action = await consumePendingAction();
|
||||
if (action === 'end') {
|
||||
await endCheckin();
|
||||
return;
|
||||
}
|
||||
if (action === 'pay') {
|
||||
const s = await getCheckin();
|
||||
await endCheckin();
|
||||
if (s?.zone) navigation.navigate('StartSession', { zone: s.zone });
|
||||
return;
|
||||
}
|
||||
const s = await getCheckin();
|
||||
if (!s) return;
|
||||
if (s.endMs <= Date.now()) {
|
||||
await endCheckin();
|
||||
} else {
|
||||
await postCheckinNotification(s);
|
||||
}
|
||||
};
|
||||
void run();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void run();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [navigation]);
|
||||
}
|
||||
|
|
@ -1,32 +0,0 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
import type { LabelKind } from '@/api/zoneLabels';
|
||||
|
||||
/**
|
||||
* A local, API-free "check-in" for a free time-limited space. The full Zone is
|
||||
* stored so the notification's "Pay" button can hand off straight into the paid
|
||||
* flow without re-fetching. Only one active check-in at a time.
|
||||
*/
|
||||
const KEY = 'ps_checkin';
|
||||
|
||||
export interface CheckinState {
|
||||
zone: Zone;
|
||||
zoneName: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
kind: LabelKind;
|
||||
leadMinutes: number;
|
||||
}
|
||||
|
||||
export async function getCheckin(): Promise<CheckinState | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
return raw ? (JSON.parse(raw) as CheckinState) : null;
|
||||
}
|
||||
|
||||
export async function setCheckin(state: CheckinState): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export async function clearCheckinState(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY);
|
||||
}
|
||||
147
app/src/features/citymap/geo.ts
Normal file
147
app/src/features/citymap/geo.ts
Normal file
|
|
@ -0,0 +1,147 @@
|
|||
/**
|
||||
* Geometry for the city parking-map overlay.
|
||||
*
|
||||
* Everything here is plain arithmetic on lon/lat — no turf, no geo library. The
|
||||
* covered area is ten blocks of downtown Sandpoint, so a local flat-earth
|
||||
* approximation is accurate to well under a metre, and hit-testing has to run on
|
||||
* every map tap.
|
||||
*/
|
||||
|
||||
export type LonLat = [number, number];
|
||||
|
||||
/** GeoJSON geometry as it comes from the server or the bundled map. */
|
||||
export type AreaGeometry =
|
||||
| { type: 'LineString'; coordinates: LonLat[] }
|
||||
| { type: 'Polygon'; coordinates: LonLat[][] };
|
||||
|
||||
export interface OverlayAdjust {
|
||||
/** Ground metres east. */
|
||||
dxMeters: number;
|
||||
/** Ground metres north. */
|
||||
dyMeters: number;
|
||||
/** Multiplier about the overlay centroid. */
|
||||
scale: number;
|
||||
/** Degrees counter-clockwise about the overlay centroid. */
|
||||
rotationDeg: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_OVERLAY: OverlayAdjust = {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
export function isIdentity(o: OverlayAdjust): boolean {
|
||||
return o.dxMeters === 0 && o.dyMeters === 0 && o.scale === 1 && o.rotationDeg === 0;
|
||||
}
|
||||
|
||||
const M_PER_DEG_LAT = 110574;
|
||||
const metresPerDegLon = (lat: number) => 111320 * Math.cos((lat * Math.PI) / 180);
|
||||
|
||||
/* ------------------------------------------------------------------ distance */
|
||||
|
||||
/** Metres between two lon/lat points (flat-earth; exact enough downtown). */
|
||||
export function distanceMeters(a: LonLat, b: LonLat): number {
|
||||
const mx = metresPerDegLon((a[1] + b[1]) / 2);
|
||||
const dx = (a[0] - b[0]) * mx;
|
||||
const dy = (a[1] - b[1]) * M_PER_DEG_LAT;
|
||||
return Math.hypot(dx, dy);
|
||||
}
|
||||
|
||||
/** Metres from `p` to the segment a→b. */
|
||||
function distToSegment(p: LonLat, a: LonLat, b: LonLat): number {
|
||||
const mx = metresPerDegLon(p[1]);
|
||||
const px = p[0] * mx;
|
||||
const py = p[1] * M_PER_DEG_LAT;
|
||||
const ax = a[0] * mx;
|
||||
const ay = a[1] * M_PER_DEG_LAT;
|
||||
const bx = b[0] * mx;
|
||||
const by = b[1] * M_PER_DEG_LAT;
|
||||
const dx = bx - ax;
|
||||
const dy = by - ay;
|
||||
const len2 = dx * dx + dy * dy;
|
||||
const t = len2 === 0 ? 0 : Math.max(0, Math.min(1, ((px - ax) * dx + (py - ay) * dy) / len2));
|
||||
return Math.hypot(px - (ax + t * dx), py - (ay + t * dy));
|
||||
}
|
||||
|
||||
function ringContains(p: LonLat, ring: LonLat[]): boolean {
|
||||
let inside = false;
|
||||
for (let i = 0, j = ring.length - 1; i < ring.length; j = i++) {
|
||||
const [xi, yi] = ring[i];
|
||||
const [xj, yj] = ring[j];
|
||||
if (yi > p[1] !== yj > p[1] && p[0] < ((xj - xi) * (p[1] - yi)) / (yj - yi) + xi) {
|
||||
inside = !inside;
|
||||
}
|
||||
}
|
||||
return inside;
|
||||
}
|
||||
|
||||
/**
|
||||
* Metres from a point to a geometry — 0 when the point is inside a polygon, so
|
||||
* "which area am I in" and "which area is nearest" are the same question.
|
||||
*/
|
||||
export function distanceToGeometry(p: LonLat, g: AreaGeometry): number {
|
||||
if (g.type === 'Polygon') {
|
||||
const [outer, ...holes] = g.coordinates;
|
||||
if (!outer?.length) return Infinity;
|
||||
if (ringContains(p, outer) && !holes.some((h) => ringContains(p, h))) return 0;
|
||||
let best = Infinity;
|
||||
for (const ring of g.coordinates) {
|
||||
for (let i = 1; i < ring.length; i++) best = Math.min(best, distToSegment(p, ring[i - 1], ring[i]));
|
||||
}
|
||||
return best;
|
||||
}
|
||||
const line = g.coordinates;
|
||||
if (line.length === 1) return distanceMeters(p, line[0]);
|
||||
let best = Infinity;
|
||||
for (let i = 1; i < line.length; i++) best = Math.min(best, distToSegment(p, line[i - 1], line[i]));
|
||||
return best;
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ overlay */
|
||||
|
||||
/**
|
||||
* Apply the whole-overlay correction: scale and rotate about `anchor`, then shift.
|
||||
*
|
||||
* The georeference was fitted to OpenStreetMap and is good to a few metres, but
|
||||
* "a few metres" is the difference between two sides of a street. This is what
|
||||
* lets that be corrected from the phone against a live GPS fix, with no rebuild.
|
||||
*/
|
||||
export function adjustGeometry(g: AreaGeometry, o: OverlayAdjust, anchor: LonLat): AreaGeometry {
|
||||
if (isIdentity(o)) return g;
|
||||
|
||||
const mx = metresPerDegLon(anchor[1]);
|
||||
const cosT = Math.cos((o.rotationDeg * Math.PI) / 180);
|
||||
const sinT = Math.sin((o.rotationDeg * Math.PI) / 180);
|
||||
|
||||
const move = (p: LonLat): LonLat => {
|
||||
// Into local metres relative to the anchor, transform, and back out.
|
||||
const ex = (p[0] - anchor[0]) * mx;
|
||||
const ny = (p[1] - anchor[1]) * M_PER_DEG_LAT;
|
||||
const rx = (ex * cosT - ny * sinT) * o.scale + o.dxMeters;
|
||||
const ry = (ex * sinT + ny * cosT) * o.scale + o.dyMeters;
|
||||
return [anchor[0] + rx / mx, anchor[1] + ry / M_PER_DEG_LAT];
|
||||
};
|
||||
|
||||
return g.type === 'Polygon'
|
||||
? { type: 'Polygon', coordinates: g.coordinates.map((r) => r.map(move)) }
|
||||
: { type: 'LineString', coordinates: g.coordinates.map(move) };
|
||||
}
|
||||
|
||||
/** Centroid of every vertex in the set — the anchor scale and rotation turn about. */
|
||||
export function overlayAnchor(geoms: AreaGeometry[]): LonLat {
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let n = 0;
|
||||
for (const g of geoms) {
|
||||
for (const [lon, lat] of g.type === 'Polygon' ? g.coordinates.flat() : g.coordinates) {
|
||||
x += lon;
|
||||
y += lat;
|
||||
n++;
|
||||
}
|
||||
}
|
||||
return n ? [x / n, y / n] : [0, 0];
|
||||
}
|
||||
1
app/src/features/citymap/parkingAreas.json
Normal file
1
app/src/features/citymap/parkingAreas.json
Normal file
File diff suppressed because one or more lines are too long
|
|
@ -1,4 +1,4 @@
|
|||
import { useCallback, useEffect, useState } from 'react';
|
||||
import { useCallback, useEffect, useRef, useState } from 'react';
|
||||
import * as Location from 'expo-location';
|
||||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
|
||||
|
|
@ -7,6 +7,20 @@ export interface Coords {
|
|||
longitude: number;
|
||||
}
|
||||
|
||||
export interface UseLocationOptions {
|
||||
/**
|
||||
* Re-read the OS fix this often, in ms. Omit (or 0) for a single fix at mount.
|
||||
* A fix goes stale as soon as you drive a block, so any screen that shows
|
||||
* "where am I" for more than a moment wants this.
|
||||
*/
|
||||
intervalMs?: number;
|
||||
/**
|
||||
* Poll only while true. Callers pass screen focus AND app foreground: polling
|
||||
* a map nobody is looking at spends battery on an answer no one reads.
|
||||
*/
|
||||
active?: boolean;
|
||||
}
|
||||
|
||||
const LAST_LOC_KEY = 'ps_last_location';
|
||||
|
||||
/** Persist the most recent fix so the "near my last location" button works cold. */
|
||||
|
|
@ -21,32 +35,54 @@ export async function getLastKnownSavedLocation(): Promise<Coords | null> {
|
|||
/**
|
||||
* Foreground location. On GrapheneOS this uses the OS location provider directly
|
||||
* (no Google Play Services). We prefer a fast last-known fix, then refine.
|
||||
*
|
||||
* `updatedAt` is when `coords` was actually read, so callers can tell a fresh fix
|
||||
* from one that has been sitting there since the screen opened.
|
||||
*/
|
||||
export function useLocation() {
|
||||
export function useLocation({ intervalMs = 0, active = true }: UseLocationOptions = {}) {
|
||||
const [coords, setCoords] = useState<Coords | null>(null);
|
||||
const [updatedAt, setUpdatedAt] = useState(0);
|
||||
const [granted, setGranted] = useState<boolean | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
// Read inside refresh() without making it a dependency — refresh is the
|
||||
// interval's callback, and a changing identity would restart the timer on
|
||||
// every fix, so it would never actually reach the interval.
|
||||
const haveFix = useRef(false);
|
||||
const permitted = useRef(false);
|
||||
|
||||
const refresh = useCallback(async () => {
|
||||
try {
|
||||
if (!permitted.current) {
|
||||
const { status } = await Location.requestForegroundPermissionsAsync();
|
||||
const ok = status === 'granted';
|
||||
permitted.current = ok;
|
||||
setGranted(ok);
|
||||
if (!ok) {
|
||||
setError('Location permission denied.');
|
||||
return null;
|
||||
}
|
||||
}
|
||||
// Only worth it before we have anything to show: on a later poll the
|
||||
// last-known fix is usually older than the one we already hold, and
|
||||
// publishing it would make the dot jump backwards.
|
||||
if (!haveFix.current) {
|
||||
const last = await Location.getLastKnownPositionAsync();
|
||||
if (last) {
|
||||
const c = { latitude: last.coords.latitude, longitude: last.coords.longitude };
|
||||
haveFix.current = true;
|
||||
setCoords(c);
|
||||
setUpdatedAt(Date.now());
|
||||
void saveLastLocation(c);
|
||||
}
|
||||
}
|
||||
const cur = await Location.getCurrentPositionAsync({
|
||||
accuracy: Location.Accuracy.Balanced,
|
||||
});
|
||||
const c = { latitude: cur.coords.latitude, longitude: cur.coords.longitude };
|
||||
haveFix.current = true;
|
||||
setCoords(c);
|
||||
setUpdatedAt(Date.now());
|
||||
setError(null);
|
||||
void saveLastLocation(c);
|
||||
return c;
|
||||
} catch (e: any) {
|
||||
|
|
@ -56,8 +92,19 @@ export function useLocation() {
|
|||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
// Denied is denied — polling it every 30s just burns wake-ups to be told no.
|
||||
if (granted === false) return;
|
||||
// Re-activating (screen focused, app foregrounded) is exactly when the held
|
||||
// fix is most likely to be stale, so read one straight away rather than
|
||||
// waiting out a whole interval.
|
||||
void refresh();
|
||||
}, [refresh]);
|
||||
if (!intervalMs) return;
|
||||
const id = setInterval(() => {
|
||||
void refresh();
|
||||
}, intervalMs);
|
||||
return () => clearInterval(id);
|
||||
}, [active, granted, intervalMs, refresh]);
|
||||
|
||||
return { coords, granted, error, refresh };
|
||||
return { coords, updatedAt, granted, error, refresh };
|
||||
}
|
||||
|
|
|
|||
378
app/src/features/session/activeParking.ts
Normal file
378
app/src/features/session/activeParking.ts
Normal file
|
|
@ -0,0 +1,378 @@
|
|||
import { useEffect } from 'react';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
import { ps } from '@/api/client';
|
||||
import { authBus } from '@/auth/authBus';
|
||||
import { parseApiTime } from '@/api/parseTime';
|
||||
import type { LabelKind } from '@/api/zoneLabels';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import {
|
||||
ensureNotificationPermission,
|
||||
REMINDER_CHANNEL_ID,
|
||||
} from '@/notifications/localReminders';
|
||||
import {
|
||||
getCountdownEnabled,
|
||||
getReminderLeadMinutes,
|
||||
getRemindersEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import type { ParkingArea } from '@/api/parkingAreas';
|
||||
import { recordLocalSession } from './localHistory';
|
||||
import {
|
||||
clearActiveParking,
|
||||
getActiveParking,
|
||||
setActiveParking,
|
||||
setParkedPin,
|
||||
type ActiveParking,
|
||||
type ParkedSpot,
|
||||
} from './activeParkingStore';
|
||||
import {
|
||||
clearSession,
|
||||
consumePendingAction,
|
||||
hasNativeCountdown,
|
||||
showSession,
|
||||
} from '../../../modules/bbp-notify';
|
||||
|
||||
/**
|
||||
* Everything that happens while a car is parked, paid or free, lives here.
|
||||
*
|
||||
* There is exactly one active parking session at a time, so there is exactly one
|
||||
* ongoing notification. Both entry points (buying time, checking into a free
|
||||
* space) write the same record and post the same countdown, which is why the
|
||||
* notification behaves identically whichever way you parked.
|
||||
*
|
||||
* The notification itself is owned by a native foreground service — see
|
||||
* modules/bbp-notify. JS's job is to keep the record truthful; the service
|
||||
* renders it and stops itself the moment there's nothing to show.
|
||||
*/
|
||||
|
||||
/** One session at a time, so one reminder id. */
|
||||
const EXPIRY_REMINDER_ID = 'parking-expiry';
|
||||
/** Fallback ongoing notification for Expo Go, where the native module is absent. */
|
||||
const FALLBACK_NOTIF_ID = 'parking-status';
|
||||
/** How much a city-map session's "extend" button adds, and what it's labelled. */
|
||||
const EXTEND_MINUTES = 60;
|
||||
const EXTEND_LABEL = '+1 hr';
|
||||
|
||||
function fmtTime(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------------ posting */
|
||||
|
||||
/** Post (or replace) the ongoing ticking notification for a session. */
|
||||
async function postNotification(p: ActiveParking): Promise<void> {
|
||||
if (!(await getCountdownEnabled())) {
|
||||
logLine('[PARKING] countdown disabled — not posting');
|
||||
await clearNotification();
|
||||
return;
|
||||
}
|
||||
await ensureNotificationPermission();
|
||||
|
||||
const ends = fmtTime(p.endMs);
|
||||
const free = p.kind === 'free';
|
||||
const title = free ? `Free parking · ${p.zoneName}` : `Parking · ${p.zoneName}`;
|
||||
const body = free ? `Free until ${ends}` : `Paid until ${ends}`;
|
||||
// What the second button does depends on what it *can* do. City-map parking has
|
||||
// no ParkSmarter zone to buy time in, so there it adds an hour to the local
|
||||
// timer and says so; a real zone gets the purchase screen.
|
||||
const extendLabel = p.area ? EXTEND_LABEL : free ? 'Pay' : 'Extend';
|
||||
|
||||
if (hasNativeCountdown) {
|
||||
const diag = await showSession(title, body, p.endMs, 'End', extendLabel);
|
||||
logLine(`[PARKING] ${p.kind} native: ${diag}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Expo Go / no native module: a static ongoing notification, no buttons and no
|
||||
// ticking, refreshed whenever the app is foregrounded.
|
||||
logLine('[PARKING] no native module — static fallback notification');
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: FALLBACK_NOTIF_ID,
|
||||
content: {
|
||||
title,
|
||||
body,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
data: { kind: 'session-status' },
|
||||
},
|
||||
trigger:
|
||||
Platform.OS === 'android'
|
||||
? {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: new Date(Date.now() + 400),
|
||||
channelId: 'session-status',
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
async function clearNotification(): Promise<void> {
|
||||
await clearSession().catch(() => {});
|
||||
await Notifications.dismissNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||
await Notifications.cancelScheduledNotificationAsync(FALLBACK_NOTIF_ID).catch(() => {});
|
||||
}
|
||||
|
||||
/* ---------------------------------------------------------------- reminders */
|
||||
|
||||
async function scheduleExpiryReminder(p: ActiveParking): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||
if (!(await getRemindersEnabled())) return;
|
||||
const fireAt = p.endMs - p.leadMinutes * 60_000;
|
||||
if (fireAt <= Date.now()) return;
|
||||
|
||||
const free = p.kind === 'free';
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: EXPIRY_REMINDER_ID,
|
||||
content: {
|
||||
title: free ? 'Free parking ending soon' : 'Parking expiring soon',
|
||||
body: `${p.zoneName}: ${free ? 'your free time ends' : 'your session ends'} at ${fmtTime(
|
||||
p.endMs,
|
||||
)}. Extend if you need more time.`,
|
||||
data: { kind: 'parking-expiry' },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: new Date(fireAt),
|
||||
channelId: REMINDER_CHANNEL_ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
/* ------------------------------------------------------------ start / stop */
|
||||
|
||||
/** Begin tracking a paid session that ParkSmarter has just confirmed. */
|
||||
export async function startPaidSession(args: {
|
||||
zone: Zone;
|
||||
endTime: Date;
|
||||
transactionId?: string | number;
|
||||
}): Promise<void> {
|
||||
const state: ActiveParking = {
|
||||
kind: 'paid',
|
||||
zone: args.zone,
|
||||
zoneName: args.zone.ZoneName ?? 'Parking',
|
||||
startMs: Date.now(),
|
||||
endMs: args.endTime.getTime(),
|
||||
transactionId: args.transactionId != null ? String(args.transactionId) : undefined,
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setActiveParking(state);
|
||||
await postNotification(state);
|
||||
await scheduleExpiryReminder(state);
|
||||
}
|
||||
|
||||
/** Start a local free check-in for `hours` at the given zone. */
|
||||
export async function startFreeCheckin(
|
||||
zone: Zone,
|
||||
hours: number,
|
||||
labelKind?: LabelKind,
|
||||
): Promise<void> {
|
||||
const now = Date.now();
|
||||
const state: ActiveParking = {
|
||||
kind: 'free',
|
||||
zone,
|
||||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
startMs: now,
|
||||
endMs: now + hours * 3_600_000,
|
||||
labelKind: labelKind ?? (`free_${hours}h` as LabelKind),
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setActiveParking(state);
|
||||
await postNotification(state);
|
||||
await scheduleExpiryReminder(state);
|
||||
}
|
||||
|
||||
/**
|
||||
* Start tracking time on an area from the city parking map.
|
||||
*
|
||||
* Deliberately the whole story: no ParkSmarter call, no account, no network. The
|
||||
* area came from the local database, the clock is the phone's, and the countdown
|
||||
* is the same foreground service every other session uses. Works in Anonymous
|
||||
* Mode, offline, and with the IPS API down.
|
||||
*/
|
||||
export async function startAreaParking(args: {
|
||||
area: ParkingArea;
|
||||
hours: number;
|
||||
spot?: ParkedSpot;
|
||||
}): Promise<void> {
|
||||
const now = Date.now();
|
||||
const state: ActiveParking = {
|
||||
// Only the city lots cost money; everything else on the map is free parking
|
||||
// that merely has a posted time limit.
|
||||
kind: args.area.kind === 'green_lot' ? 'paid' : 'free',
|
||||
area: {
|
||||
id: args.area.id,
|
||||
kind: args.area.kind,
|
||||
name: args.area.name,
|
||||
legend: args.area.legend,
|
||||
color: args.area.color,
|
||||
},
|
||||
spot: args.spot,
|
||||
zoneName: args.area.name,
|
||||
startMs: now,
|
||||
endMs: now + Math.round(args.hours * 3_600_000),
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
await setActiveParking(state);
|
||||
if (args.spot) await setParkedPin(args.spot);
|
||||
await postNotification(state);
|
||||
await scheduleExpiryReminder(state);
|
||||
logLine(`[PARKING] city area ${args.area.id} (${args.area.kind}) for ${args.hours}h`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Drop the "where is my car" pin. Deliberately independent of any session — you
|
||||
* can pin the car without starting a timer, and the pin has to survive that.
|
||||
*/
|
||||
export async function pinParkedSpot(spot: ParkedSpot): Promise<void> {
|
||||
await setParkedPin(spot);
|
||||
const current = await getActiveParking();
|
||||
if (current) await setActiveParking({ ...current, spot });
|
||||
}
|
||||
|
||||
/**
|
||||
* Add time to a city-map session's local timer. There is nothing to buy here —
|
||||
* the app is only tracking a clock — so extending is a local edit, not a purchase.
|
||||
*/
|
||||
export async function extendAreaParking(minutes = EXTEND_MINUTES): Promise<void> {
|
||||
const current = await getActiveParking();
|
||||
if (!current) return;
|
||||
// Extend from now if it already lapsed, so "+1 hr" always means a full hour.
|
||||
const from = Math.max(current.endMs, Date.now());
|
||||
const next = { ...current, endMs: from + minutes * 60_000 };
|
||||
await setActiveParking(next);
|
||||
await postNotification(next);
|
||||
await scheduleExpiryReminder(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* Stop tracking the active session and take its notification down.
|
||||
*
|
||||
* For a free check-in this genuinely ends it. For a paid session it only stops
|
||||
* tracking — ParkSmarter has no stop-session endpoint, so the time you bought
|
||||
* keeps running at the meter whether or not the app is showing it.
|
||||
*/
|
||||
export async function endActiveParking(): Promise<void> {
|
||||
// Write it to history before dropping it. A local session has no server copy, so
|
||||
// if it isn't recorded here it is simply gone.
|
||||
const current = await getActiveParking();
|
||||
if (current) await recordLocalSession(current);
|
||||
await clearActiveParking();
|
||||
await clearNotification();
|
||||
await Notifications.cancelScheduledNotificationAsync(EXPIRY_REMINDER_ID).catch(() => {});
|
||||
}
|
||||
|
||||
/* ----------------------------------------------------------------- syncing */
|
||||
|
||||
/**
|
||||
* Ask the server whether a paid session is running. Only used when nothing is
|
||||
* tracked locally — a session bought on another device, or before this version
|
||||
* started persisting them. The active-session API returns no Zone, so a session
|
||||
* found this way has no zone to extend into.
|
||||
*/
|
||||
async function discoverPaidSession(): Promise<ActiveParking | null> {
|
||||
try {
|
||||
const res = await ps.getActiveParkingSessions();
|
||||
const soonest = (res.ParkingSession ?? [])
|
||||
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
||||
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
||||
.sort((a, b) => a.end!.getTime() - b.end!.getTime())[0];
|
||||
if (!soonest) return null;
|
||||
|
||||
const found: ActiveParking = {
|
||||
kind: 'paid',
|
||||
zoneName: soonest.s.ZoneName ?? 'Parking',
|
||||
startMs: parseApiTime(soonest.s.StartTime)?.getTime() ?? Date.now(),
|
||||
endMs: soonest.end!.getTime(),
|
||||
transactionId:
|
||||
soonest.s.TransactionID != null ? String(soonest.s.TransactionID) : undefined,
|
||||
leadMinutes: await getReminderLeadMinutes(),
|
||||
};
|
||||
logLine(`[PARKING] discovered server session ending ${new Date(found.endMs).toISOString()}`);
|
||||
await setActiveParking(found);
|
||||
await scheduleExpiryReminder(found);
|
||||
return found;
|
||||
} catch (e: any) {
|
||||
logLine(`[PARKING] discover failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile the record, its notification and any button the user pressed while the
|
||||
* app was away. Safe to call repeatedly; it's the app's foreground heartbeat.
|
||||
*/
|
||||
export async function syncActiveParking(onExtend: (zone?: Zone) => void): Promise<void> {
|
||||
const action = await consumePendingAction();
|
||||
|
||||
if (action === 'end') {
|
||||
logLine('[PARKING] notification "End" pressed');
|
||||
await endActiveParking();
|
||||
return;
|
||||
}
|
||||
|
||||
let current = await getActiveParking();
|
||||
|
||||
// The meter ran out: the service already removed its own notification when the
|
||||
// countdown hit zero, so this just clears the record behind it.
|
||||
if (current && current.endMs <= Date.now()) {
|
||||
await endActiveParking();
|
||||
current = null;
|
||||
}
|
||||
|
||||
// "+1 hr" on a city-map session is a local edit, not a purchase — handle it here
|
||||
// and stay put rather than sending the user to a payment screen for a free spot.
|
||||
if (action === 'extend' && current?.area) {
|
||||
logLine(`[PARKING] notification "${EXTEND_LABEL}" pressed on city area ${current.area.id}`);
|
||||
await extendAreaParking();
|
||||
return;
|
||||
}
|
||||
|
||||
// A city-map session is never on the ParkSmarter server, so don't let a stale
|
||||
// server session overwrite it. In Anonymous Mode there is no account to ask at
|
||||
// all — asking anyway would 401 on every single foreground.
|
||||
if (!current && !authBus.isAnonymous) current = await discoverPaidSession();
|
||||
|
||||
if (current) {
|
||||
await postNotification(current);
|
||||
} else {
|
||||
await clearNotification();
|
||||
}
|
||||
|
||||
if (action === 'extend') {
|
||||
logLine('[PARKING] notification "Extend" pressed');
|
||||
onExtend(current?.zone);
|
||||
}
|
||||
}
|
||||
|
||||
/** Called by the tab navigator: sync on open and on every foreground. */
|
||||
export function useActiveParkingSync(): void {
|
||||
const navigation = useNavigation<NativeStackNavigationProp<RootStackParamList>>();
|
||||
|
||||
useEffect(() => {
|
||||
const run = () =>
|
||||
void syncActiveParking((zone) => {
|
||||
// "Extend" means "sell me more time for this exact spot" — go straight to
|
||||
// the purchase screen for the stored zone. A server-discovered session has
|
||||
// no zone, so fall back to the sessions list rather than guessing.
|
||||
if (zone) navigation.navigate('StartSession', { zone });
|
||||
else navigation.navigate('Tabs');
|
||||
});
|
||||
|
||||
run();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') run();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, [navigation]);
|
||||
}
|
||||
|
||||
/** Turn the countdown notification on/off from Settings without touching the record. */
|
||||
export async function refreshParkingNotification(): Promise<void> {
|
||||
const current = await getActiveParking();
|
||||
if (current && current.endMs > Date.now()) await postNotification(current);
|
||||
else await clearNotification();
|
||||
}
|
||||
104
app/src/features/session/activeParkingStore.ts
Normal file
104
app/src/features/session/activeParkingStore.ts
Normal file
|
|
@ -0,0 +1,104 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
import type { LabelKind } from '@/api/zoneLabels';
|
||||
import type { AreaKind } from '@/api/parkingAreas';
|
||||
|
||||
/**
|
||||
* The one parking session the app is currently tracking — paid or a local free
|
||||
* check-in. Persisting it locally is what lets the countdown come back after a
|
||||
* reboot, offline, or in Anonymous Mode: the notification no longer depends on a
|
||||
* ParkSmarter round-trip to know a session exists.
|
||||
*
|
||||
* The full Zone is stored so the notification's "Extend" button can hand straight
|
||||
* into the paid flow without re-fetching the meter.
|
||||
*/
|
||||
const KEY = 'ps_active_parking';
|
||||
/** Pre-0.5 free check-ins lived here; read once so an in-flight check-in survives the upgrade. */
|
||||
const LEGACY_CHECKIN_KEY = 'ps_checkin';
|
||||
/**
|
||||
* The parked pin lives outside the session on purpose: "where is my car" outlives
|
||||
* "am I tracking time". You can drop a pin without starting a timer, and it has to
|
||||
* still be there when you come back to the map.
|
||||
*/
|
||||
const PIN_KEY = 'ps_parked_pin';
|
||||
|
||||
export type ParkingKind = 'paid' | 'free';
|
||||
|
||||
/**
|
||||
* Where you parked, when the spot came from the city map rather than ParkSmarter.
|
||||
* Held by value — the whole point is that the countdown keeps working with no
|
||||
* network, no account and no IPS call, so it can't depend on a lookup.
|
||||
*/
|
||||
export interface ParkedArea {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
name: string;
|
||||
/** The map legend's own wording, shown on the session screen. */
|
||||
legend: string;
|
||||
color: string;
|
||||
}
|
||||
|
||||
/** The pin for "where is my car", dropped from GPS or placed by hand. */
|
||||
export interface ParkedSpot {
|
||||
latitude: number;
|
||||
longitude: number;
|
||||
/** True when the user placed it themselves because GPS wasn't usable. */
|
||||
manual: boolean;
|
||||
}
|
||||
|
||||
export interface ActiveParking {
|
||||
kind: ParkingKind;
|
||||
/**
|
||||
* Absent for a paid session discovered from the server (the active-session API
|
||||
* returns no zone) and for city-map parking, which has no ParkSmarter zone at
|
||||
* all. "Extend" falls back accordingly.
|
||||
*/
|
||||
zone?: Zone;
|
||||
/** Set instead of `zone` when parked on a city-map area. */
|
||||
area?: ParkedArea;
|
||||
/** Where the car actually is. Independent of `area` — you can pin without one. */
|
||||
spot?: ParkedSpot;
|
||||
zoneName: string;
|
||||
startMs: number;
|
||||
endMs: number;
|
||||
/** Paid only: the ParkSmarter transaction id, for receipts/debugging. */
|
||||
transactionId?: string;
|
||||
/** Free only: which zone-label this check-in came from. */
|
||||
labelKind?: LabelKind;
|
||||
/** Minutes before expiry to fire the local reminder. */
|
||||
leadMinutes: number;
|
||||
}
|
||||
|
||||
export async function getActiveParking(): Promise<ActiveParking | null> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (raw) return JSON.parse(raw) as ActiveParking;
|
||||
|
||||
const legacy = await AsyncStorage.getItem(LEGACY_CHECKIN_KEY);
|
||||
if (!legacy) return null;
|
||||
const c = JSON.parse(legacy) as Omit<ActiveParking, 'kind'>;
|
||||
const migrated: ActiveParking = { ...c, kind: 'free' };
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(migrated));
|
||||
await AsyncStorage.removeItem(LEGACY_CHECKIN_KEY);
|
||||
return migrated;
|
||||
}
|
||||
|
||||
export async function setActiveParking(state: ActiveParking): Promise<void> {
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(state));
|
||||
}
|
||||
|
||||
export async function clearActiveParking(): Promise<void> {
|
||||
// The pin goes with it: ending a session means you drove away, and a pin left
|
||||
// behind would point at a space you no longer occupy.
|
||||
await AsyncStorage.multiRemove([KEY, LEGACY_CHECKIN_KEY, PIN_KEY]);
|
||||
}
|
||||
|
||||
/** Where the car is, whether or not a timer is running. */
|
||||
export async function getParkedPin(): Promise<ParkedSpot | null> {
|
||||
const raw = await AsyncStorage.getItem(PIN_KEY);
|
||||
return raw ? (JSON.parse(raw) as ParkedSpot) : null;
|
||||
}
|
||||
|
||||
export async function setParkedPin(spot: ParkedSpot | null): Promise<void> {
|
||||
if (spot) await AsyncStorage.setItem(PIN_KEY, JSON.stringify(spot));
|
||||
else await AsyncStorage.removeItem(PIN_KEY);
|
||||
}
|
||||
81
app/src/features/session/localHistory.ts
Normal file
81
app/src/features/session/localHistory.ts
Normal file
|
|
@ -0,0 +1,81 @@
|
|||
import AsyncStorage from '@react-native-async-storage/async-storage';
|
||||
import type { AreaKind } from '@/api/parkingAreas';
|
||||
import type { ActiveParking, ParkedSpot, ParkingKind } from './activeParkingStore';
|
||||
|
||||
/**
|
||||
* History for the sessions ParkSmarter never sees.
|
||||
*
|
||||
* A city-map timer or a free check-in exists only on this phone, so if it isn't
|
||||
* recorded here it vanishes the moment it ends — there is no server to ask. Paid
|
||||
* ParkSmarter sessions are deliberately excluded: those already come back from the
|
||||
* account, and storing them too would show every one of them twice.
|
||||
*/
|
||||
|
||||
const KEY = 'ps_local_session_history';
|
||||
/** Enough to cover months of parking without letting the record grow forever. */
|
||||
const MAX = 50;
|
||||
|
||||
export interface LocalSessionRecord {
|
||||
/** Start time doubles as the id — there is only ever one session at a time. */
|
||||
id: string;
|
||||
kind: ParkingKind;
|
||||
zoneName: string;
|
||||
areaId?: string;
|
||||
areaKind?: AreaKind;
|
||||
color?: string;
|
||||
legend?: string;
|
||||
startMs: number;
|
||||
/** When it was due to end. */
|
||||
plannedEndMs: number;
|
||||
/** When it actually ended. */
|
||||
endedAtMs: number;
|
||||
/** True when the user ended it before the clock ran out. */
|
||||
endedEarly: boolean;
|
||||
spot?: ParkedSpot;
|
||||
}
|
||||
|
||||
/** True when ParkSmarter has no record of this session, so we must keep our own. */
|
||||
export function isLocalOnly(p: ActiveParking): boolean {
|
||||
return !p.transactionId;
|
||||
}
|
||||
|
||||
export async function getLocalHistory(): Promise<LocalSessionRecord[]> {
|
||||
const raw = await AsyncStorage.getItem(KEY);
|
||||
if (!raw) return [];
|
||||
try {
|
||||
const list = JSON.parse(raw) as LocalSessionRecord[];
|
||||
return Array.isArray(list) ? list : [];
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
/** Record a finished local session. No-op for anything ParkSmarter already has. */
|
||||
export async function recordLocalSession(p: ActiveParking): Promise<void> {
|
||||
if (!isLocalOnly(p)) return;
|
||||
const endedAtMs = Date.now();
|
||||
const record: LocalSessionRecord = {
|
||||
id: String(p.startMs),
|
||||
kind: p.kind,
|
||||
zoneName: p.zoneName,
|
||||
areaId: p.area?.id,
|
||||
areaKind: p.area?.kind,
|
||||
color: p.area?.color,
|
||||
legend: p.area?.legend,
|
||||
startMs: p.startMs,
|
||||
plannedEndMs: p.endMs,
|
||||
endedAtMs,
|
||||
endedEarly: endedAtMs < p.endMs - 60_000, // a minute's slack for timer wake-up
|
||||
spot: p.spot,
|
||||
};
|
||||
const list = await getLocalHistory();
|
||||
// Guard against double-recording: ending can be driven from the notification and
|
||||
// the screen at nearly the same moment.
|
||||
const deduped = list.filter((r) => r.id !== record.id);
|
||||
deduped.unshift(record);
|
||||
await AsyncStorage.setItem(KEY, JSON.stringify(deduped.slice(0, MAX)));
|
||||
}
|
||||
|
||||
export async function clearLocalHistory(): Promise<void> {
|
||||
await AsyncStorage.removeItem(KEY);
|
||||
}
|
||||
|
|
@ -24,15 +24,21 @@ import { StartSessionScreen } from '@/screens/StartSessionScreen';
|
|||
import { SessionDetailScreen } from '@/screens/SessionDetailScreen';
|
||||
import { DiagnosticsScreen } from '@/screens/DiagnosticsScreen';
|
||||
import { AdminScreen } from '@/screens/AdminScreen';
|
||||
import { CityAreaScreen } from '@/screens/CityAreaScreen';
|
||||
import { MapAlignScreen } from '@/screens/MapAlignScreen';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { useSessionStatusSync } from '@/notifications/sessionStatus';
|
||||
import { useCheckinSync } from '@/features/checkin/checkin';
|
||||
import { useActiveParkingSync } from '@/features/session/activeParking';
|
||||
import type { ParkingArea } from '@/api/parkingAreas';
|
||||
import type { ParkedSpot } from '@/features/session/activeParkingStore';
|
||||
import type { ActiveSession, PastSession, Zone } from 'parksmarter-client';
|
||||
|
||||
export type RootStackParamList = {
|
||||
Tabs: undefined;
|
||||
MeterDetail: { zone: Zone };
|
||||
StartSession: { zone: Zone };
|
||||
/** An area from the city's printed parking map — no ParkSmarter zone involved. */
|
||||
CityArea: { area: ParkingArea; spot?: ParkedSpot };
|
||||
MapAlign: undefined;
|
||||
SessionDetail: { session: PastSession | ActiveSession; kind: 'active' | 'past' };
|
||||
About: undefined;
|
||||
Profile: undefined;
|
||||
|
|
@ -72,10 +78,9 @@ const TAB_ICONS: Record<keyof TabParamList, keyof typeof Ionicons.glyphMap> = {
|
|||
};
|
||||
|
||||
function Tabs() {
|
||||
// Keep the ongoing "time left" status notification fresh (on open + foreground).
|
||||
useSessionStatusSync();
|
||||
// Apply check-in notification actions (End/Pay) + keep its countdown in sync.
|
||||
useCheckinSync();
|
||||
// Keep the ongoing countdown in sync (on open + every foreground) and apply any
|
||||
// End/Extend the user pressed on the notification while the app was away.
|
||||
useActiveParkingSync();
|
||||
return (
|
||||
<Tab.Navigator
|
||||
screenOptions={({ route }) => ({
|
||||
|
|
@ -153,7 +158,17 @@ export function RootNavigator() {
|
|||
component={DiagnosticsScreen}
|
||||
options={{ title: 'Diagnostics' }}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="CityArea"
|
||||
component={CityAreaScreen}
|
||||
options={{ title: 'Parking area' }}
|
||||
/>
|
||||
<Stack.Screen name="Admin" component={AdminScreen} options={{ title: 'Admin' }} />
|
||||
<Stack.Screen
|
||||
name="MapAlign"
|
||||
component={MapAlignScreen}
|
||||
options={{ title: 'Align city map' }}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
) : (
|
||||
<LoginScreen />
|
||||
|
|
|
|||
|
|
@ -1,9 +1,5 @@
|
|||
import { Platform } from 'react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import {
|
||||
getReminderLeadMinutes,
|
||||
getRemindersEnabled,
|
||||
} from '@/features/notifications/reminderPrefs';
|
||||
|
||||
/**
|
||||
* Session-expiry reminders are purely LOCAL scheduled notifications: the app knows
|
||||
|
|
@ -11,7 +7,9 @@ import {
|
|||
* No server, no FCM, no push, no Play Services — works fully offline on GrapheneOS.
|
||||
*/
|
||||
|
||||
const CHANNEL_ID = 'session-reminders';
|
||||
/** Exported so the active-parking module can schedule onto the same channel. */
|
||||
export const REMINDER_CHANNEL_ID = 'session-reminders';
|
||||
const CHANNEL_ID = REMINDER_CHANNEL_ID;
|
||||
|
||||
/** Android 8+ needs a notification channel; ensure it exists once. */
|
||||
async function ensureChannel(): Promise<void> {
|
||||
|
|
@ -48,48 +46,6 @@ Notifications.setNotificationHandler({
|
|||
},
|
||||
});
|
||||
|
||||
export interface ScheduleReminderArgs {
|
||||
transactionId: string | number;
|
||||
zoneName: string;
|
||||
/** When the parking session ends. */
|
||||
endTime: Date;
|
||||
/** Override the user's configured lead time (minutes before end). */
|
||||
leadMinutes?: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* Schedule an expiry reminder using the user's Notifications preferences
|
||||
* (lead time + enabled). Returns the notification id, or null if disabled/late.
|
||||
*/
|
||||
export async function scheduleExpiryReminder(
|
||||
args: ScheduleReminderArgs,
|
||||
): Promise<string | null> {
|
||||
if (!(await getRemindersEnabled())) return null;
|
||||
const leadMinutes = args.leadMinutes ?? (await getReminderLeadMinutes());
|
||||
const lead = leadMinutes * 60 * 1000;
|
||||
const fireAt = new Date(args.endTime.getTime() - lead);
|
||||
if (fireAt.getTime() <= Date.now()) return null; // already too late
|
||||
|
||||
await ensureChannel();
|
||||
return Notifications.scheduleNotificationAsync({
|
||||
identifier: `session-${args.transactionId}`,
|
||||
content: {
|
||||
title: 'Parking expiring soon',
|
||||
body: `${args.zoneName} ends at ${args.endTime.toLocaleTimeString()}. Extend if you need more time.`,
|
||||
data: { transactionId: String(args.transactionId) },
|
||||
},
|
||||
trigger: {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: fireAt,
|
||||
channelId: CHANNEL_ID,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
export async function cancelExpiryReminder(transactionId: string | number): Promise<void> {
|
||||
await Notifications.cancelScheduledNotificationAsync(`session-${transactionId}`);
|
||||
}
|
||||
|
||||
/** Fire a test reminder a few seconds out — lets you confirm reminders work on-device. */
|
||||
export async function sendTestReminder(seconds = 10): Promise<boolean> {
|
||||
if (!(await ensureNotificationPermission())) return false;
|
||||
|
|
|
|||
|
|
@ -1,159 +0,0 @@
|
|||
import { useEffect } from 'react';
|
||||
import { AppState, Platform } from 'react-native';
|
||||
import * as Notifications from 'expo-notifications';
|
||||
import { ps } from '@/api/client';
|
||||
import { getCountdownEnabled } from '@/features/notifications/reminderPrefs';
|
||||
import { ensureNotificationPermission } from '@/notifications/localReminders';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import { hasNativeCountdown, showCountdown, clearCountdown } from '../../modules/bbp-notify';
|
||||
|
||||
/**
|
||||
* Ongoing "time left" status notification while a parking session is active.
|
||||
*
|
||||
* Preferred path: a tiny native module (modules/bbp-notify) posts a notification
|
||||
* whose time is an Android **chronometer counting down** to the session end — the
|
||||
* system ticks it every second with no app running (glance without opening the app).
|
||||
* If that native module isn't present, we fall back to an expo-notifications
|
||||
* ongoing notification showing the (static) expiry time, refreshed on foreground.
|
||||
*/
|
||||
|
||||
const CHANNEL = 'session-status';
|
||||
const NOTIF_ID = 'session-status';
|
||||
|
||||
async function ensureExpoChannel(): Promise<void> {
|
||||
if (Platform.OS !== 'android') return;
|
||||
await Notifications.setNotificationChannelAsync(CHANNEL, {
|
||||
name: 'Active parking',
|
||||
importance: Notifications.AndroidImportance.LOW,
|
||||
showBadge: false,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Parse a ParkSmarter date into a local Date. The API uses TWO formats:
|
||||
* /api/Session (past): "07-15-2026 05:00 PM" dashes, 12h, AM/PM
|
||||
* /api/ParkingSession (live): "07/15/2026 17:00:00" slashes, 24h, seconds
|
||||
* Both are device-local wall-clock time. Hermes' Date() can't parse either, so
|
||||
* we build the Date from explicit components.
|
||||
*/
|
||||
export function parseApiTime(s?: string | null): Date | null {
|
||||
if (!s) return null;
|
||||
const m = String(s).match(
|
||||
/(\d{1,2})[-/](\d{1,2})[-/](\d{4})\s+(\d{1,2}):(\d{2})(?::(\d{2}))?\s*(AM|PM)?/i,
|
||||
);
|
||||
if (!m) {
|
||||
const d = new Date(s);
|
||||
return Number.isNaN(+d) ? null : d;
|
||||
}
|
||||
let hr = parseInt(m[4], 10);
|
||||
const ap = m[7];
|
||||
if (ap) {
|
||||
const pm = /pm/i.test(ap);
|
||||
if (pm && hr !== 12) hr += 12;
|
||||
if (!pm && hr === 12) hr = 0;
|
||||
}
|
||||
const sec = m[6] ? parseInt(m[6], 10) : 0;
|
||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5], sec);
|
||||
}
|
||||
|
||||
function fmtLeft(min: number): string {
|
||||
if (min <= 0) return 'expired';
|
||||
const h = Math.floor(min / 60);
|
||||
const m = min % 60;
|
||||
return h ? (m ? `${h}h ${m}m` : `${h}h`) : `${m}m`;
|
||||
}
|
||||
|
||||
/** Post (or replace) the ongoing status notification for a session end time. */
|
||||
export async function showSessionStatus(args: { zoneName: string; endTime: Date }): Promise<void> {
|
||||
if (!(await getCountdownEnabled())) {
|
||||
logLine('[STATUS] skip: countdown disabled');
|
||||
return;
|
||||
}
|
||||
const granted = await ensureNotificationPermission();
|
||||
const ends = args.endTime.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
logLine(
|
||||
`[STATUS] show native=${hasNativeCountdown} perm=${granted} end=${args.endTime.toISOString()}`,
|
||||
);
|
||||
|
||||
if (hasNativeCountdown) {
|
||||
// Native chronometer: a live ticking countdown, updated by the OS.
|
||||
try {
|
||||
const diag = await showCountdown(
|
||||
`Parking · ${args.zoneName}`,
|
||||
`Expires ${ends}`,
|
||||
args.endTime.getTime(),
|
||||
);
|
||||
logLine(`[STATUS] native result: ${diag}`);
|
||||
} catch (e: any) {
|
||||
logLine(`[STATUS] native threw: ${e?.message ?? e}`);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
// Fallback: static expiry-time notification, refreshed on foreground.
|
||||
await ensureExpoChannel();
|
||||
const remainMin = Math.round((args.endTime.getTime() - Date.now()) / 60000);
|
||||
await Notifications.scheduleNotificationAsync({
|
||||
identifier: NOTIF_ID,
|
||||
content: {
|
||||
title: remainMin > 0 ? `Parking: ${fmtLeft(remainMin)} left` : 'Parking expired',
|
||||
body: `${args.zoneName} · expires ${ends}`,
|
||||
sticky: true,
|
||||
autoDismiss: false,
|
||||
data: { kind: 'session-status' },
|
||||
},
|
||||
trigger:
|
||||
Platform.OS === 'android'
|
||||
? {
|
||||
type: Notifications.SchedulableTriggerInputTypes.DATE,
|
||||
date: new Date(Date.now() + 400),
|
||||
channelId: CHANNEL,
|
||||
}
|
||||
: null,
|
||||
});
|
||||
}
|
||||
|
||||
export async function clearSessionStatus(): Promise<void> {
|
||||
await clearCountdown().catch(() => {});
|
||||
await Notifications.dismissNotificationAsync(NOTIF_ID).catch(() => {});
|
||||
await Notifications.cancelScheduledNotificationAsync(NOTIF_ID).catch(() => {});
|
||||
}
|
||||
|
||||
/**
|
||||
* Fetch active sessions and re-post the status for the soonest-expiring one,
|
||||
* or clear it if there are none / the feature is off.
|
||||
*/
|
||||
export async function refreshSessionStatus(): Promise<void> {
|
||||
if (!(await getCountdownEnabled())) {
|
||||
await clearSessionStatus();
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const res = await ps.getActiveParkingSessions();
|
||||
logLine(`[STATUS] active sessions raw: ${JSON.stringify(res.ParkingSession ?? [])}`);
|
||||
const withEnd = (res.ParkingSession ?? [])
|
||||
.map((s: any) => ({ s, end: parseApiTime(s.EndTime ?? s.EndTimeDisplay) }))
|
||||
.filter((x) => x.end != null && x.end.getTime() > Date.now())
|
||||
.sort((a, b) => a.end!.getTime() - b.end!.getTime());
|
||||
logLine(`[STATUS] parsed ${withEnd.length} future-end session(s) of ${(res.ParkingSession ?? []).length}`);
|
||||
if (withEnd.length === 0) {
|
||||
await clearSessionStatus();
|
||||
return;
|
||||
}
|
||||
const { s, end } = withEnd[0];
|
||||
await showSessionStatus({ zoneName: s.ZoneName ?? s.Zone ?? 'Parking', endTime: end! });
|
||||
} catch (e: any) {
|
||||
logLine(`[STATUS] refresh failed: ${e?.serverMessage ?? e?.message ?? e}`);
|
||||
}
|
||||
}
|
||||
|
||||
/** Keep the status notification fresh: on mount and each time the app is foregrounded. */
|
||||
export function useSessionStatusSync(): void {
|
||||
useEffect(() => {
|
||||
void refreshSessionStatus();
|
||||
const sub = AppState.addEventListener('change', (state) => {
|
||||
if (state === 'active') void refreshSessionStatus();
|
||||
});
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
}
|
||||
|
|
@ -71,6 +71,11 @@ export function AccountScreen() {
|
|||
<Switch value={mode === 'dark'} onValueChange={toggle} />
|
||||
</View>
|
||||
<Item icon="bug" label="Diagnostics" onPress={() => navigation.navigate('Diagnostics')} />
|
||||
<Item
|
||||
icon="git-compare"
|
||||
label="Align city map"
|
||||
onPress={() => navigation.navigate('MapAlign')}
|
||||
/>
|
||||
<Item
|
||||
icon="shield-checkmark"
|
||||
label="Admin (zone labeling)"
|
||||
|
|
|
|||
243
app/src/screens/CityAreaScreen.tsx
Normal file
243
app/src/screens/CityAreaScreen.tsx
Normal file
|
|
@ -0,0 +1,243 @@
|
|||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { Alert, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import type { RouteProp } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation, useRoute } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { areaDurationOptions, areaIsFree, areaRequiresAccount } from '@/api/parkingAreas';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import {
|
||||
endActiveParking,
|
||||
extendAreaParking,
|
||||
startAreaParking,
|
||||
} from '@/features/session/activeParking';
|
||||
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||
|
||||
type AreaRoute = RouteProp<RootStackParamList, 'CityArea'>;
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function fmtHours(h: number): string {
|
||||
if (h < 1) return `${Math.round(h * 60)} min`;
|
||||
return h === 1 ? '1 hour' : `${h} hours`;
|
||||
}
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtRemaining(ms: number): string {
|
||||
const mins = Math.max(0, Math.round(ms / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* One area from the city's printed parking map: what the sign says, and a timer
|
||||
* for it.
|
||||
*
|
||||
* Nothing on this screen talks to ParkSmarter. The area came from the local
|
||||
* database and the countdown is the phone's own clock, so this works with no
|
||||
* account, no signal, and in Anonymous Mode.
|
||||
*/
|
||||
export function CityAreaScreen() {
|
||||
const { area, spot } = useRoute<AreaRoute>().params;
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { colors } = useTheme();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
const [active, setActive] = useState<ActiveParking | null>(null);
|
||||
|
||||
const options = areaDurationOptions(area);
|
||||
const [hours, setHours] = useState<number>(options[0]);
|
||||
const free = areaIsFree(area.kind);
|
||||
const parkedHere = active?.area?.id === area.id;
|
||||
|
||||
const reload = useCallback(() => {
|
||||
void getActiveParking().then(setActive);
|
||||
}, []);
|
||||
useFocusEffect(reload);
|
||||
|
||||
// Re-read while the screen is open so "time left" doesn't sit frozen at whatever
|
||||
// it was when you opened it. The notification is the real live countdown; this
|
||||
// just keeps the screen from lying.
|
||||
useEffect(() => {
|
||||
if (!active) return;
|
||||
const id = setInterval(reload, 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [active, reload]);
|
||||
|
||||
useEffect(() => {
|
||||
navigation.setOptions({ title: area.label });
|
||||
}, [navigation, area.label]);
|
||||
|
||||
const start = async () => {
|
||||
if (active && !parkedHere) {
|
||||
const where = active.zoneName;
|
||||
const ok = await new Promise<boolean>((resolve) =>
|
||||
Alert.alert(
|
||||
'Already tracking',
|
||||
`You're tracking parking at ${where}. Replace it with this spot?`,
|
||||
[
|
||||
{ text: 'Cancel', style: 'cancel', onPress: () => resolve(false) },
|
||||
{ text: 'Replace', style: 'destructive', onPress: () => resolve(true) },
|
||||
],
|
||||
),
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
await startAreaParking({ area, hours, spot });
|
||||
navigation.navigate('Tabs');
|
||||
};
|
||||
|
||||
const stop = async () => {
|
||||
await endActiveParking();
|
||||
reload();
|
||||
};
|
||||
|
||||
const s = styles(colors);
|
||||
|
||||
return (
|
||||
<ScrollView style={s.screen} contentContainerStyle={s.content}>
|
||||
<View style={s.card}>
|
||||
<View style={s.chipRow}>
|
||||
<View style={[s.swatch, { backgroundColor: area.color }]} />
|
||||
<Text style={s.chipText}>{area.legend}</Text>
|
||||
</View>
|
||||
<Text style={s.name}>{area.name}</Text>
|
||||
<Text style={s.sub}>
|
||||
{free ? 'Free parking' : 'Paid — pay at the kiosk or by permit'}
|
||||
{area.hours > 0 ? ` · ${fmtHours(area.hours)} posted limit` : ' · no posted time limit'}
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
{spot ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Your car</Text>
|
||||
<Text style={s.sub}>
|
||||
Pinned at {spot.latitude.toFixed(5)}, {spot.longitude.toFixed(5)}
|
||||
</Text>
|
||||
<Text style={s.hint}>
|
||||
{spot.manual ? 'Placed by hand.' : 'From GPS.'} The pin stays on the map until you end
|
||||
the session.
|
||||
</Text>
|
||||
</View>
|
||||
) : null}
|
||||
|
||||
{isAnonymous && areaRequiresAccount(area) ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Sign in to park here</Text>
|
||||
<Text style={s.sub}>
|
||||
This is a paid city lot — parking in it is bought through ParkSmarter, so it needs
|
||||
an account. The free time-limited streets on the map don't.
|
||||
</Text>
|
||||
<TouchableOpacity style={s.primary} onPress={requireLogin}>
|
||||
<Text style={s.primaryText}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : parkedHere && active ? (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Tracking now</Text>
|
||||
<Text style={s.big}>{fmtRemaining(active.endMs - Date.now())} left</Text>
|
||||
<Text style={s.sub}>Until {fmtClock(active.endMs)}</Text>
|
||||
<View style={s.row}>
|
||||
<TouchableOpacity
|
||||
style={s.secondary}
|
||||
onPress={async () => {
|
||||
await extendAreaParking();
|
||||
reload();
|
||||
}}
|
||||
>
|
||||
<Text style={s.secondaryText}>+1 hour</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.danger} onPress={stop}>
|
||||
<Text style={s.primaryText}>End</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
) : (
|
||||
<View style={s.card}>
|
||||
<Text style={s.sectionTitle}>Track my time here</Text>
|
||||
<View style={s.row}>
|
||||
{options.map((h) => (
|
||||
<TouchableOpacity
|
||||
key={h}
|
||||
style={[s.pick, hours === h && { borderColor: colors.primary, borderWidth: 2 }]}
|
||||
onPress={() => setHours(h)}
|
||||
>
|
||||
<Text style={[s.pickText, hours === h && { color: colors.primary }]}>
|
||||
{h < 1 ? `${Math.round(h * 60)}m` : `${h}h`}
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))}
|
||||
</View>
|
||||
<Text style={s.hint}>
|
||||
{area.hours > 0
|
||||
? `The sign says ${fmtHours(area.hours)}. The countdown runs on this phone only — it doesn't buy or reserve anything.`
|
||||
: "No posted limit here — pick however long you'll be. The countdown runs on this phone only."}
|
||||
</Text>
|
||||
<TouchableOpacity style={s.primary} onPress={start}>
|
||||
<Text style={s.primaryText}>Start {fmtHours(hours)} timer</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
|
||||
StyleSheet.create({
|
||||
screen: { flex: 1, backgroundColor: c.bg },
|
||||
content: { padding: 16, gap: 12 },
|
||||
card: {
|
||||
backgroundColor: c.card,
|
||||
borderRadius: 12,
|
||||
padding: 16,
|
||||
gap: 8,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
},
|
||||
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
swatch: { width: 22, height: 12, borderRadius: 3 },
|
||||
chipText: { color: c.subtext, fontSize: 13, fontWeight: '600' },
|
||||
name: { color: c.text, fontSize: 20, fontWeight: '700' },
|
||||
sub: { color: c.subtext, fontSize: 14 },
|
||||
hint: { color: c.subtext, fontSize: 12, lineHeight: 17 },
|
||||
sectionTitle: { color: c.text, fontSize: 15, fontWeight: '700' },
|
||||
big: { color: c.text, fontSize: 28, fontWeight: '700' },
|
||||
row: { flexDirection: 'row', flexWrap: 'wrap', gap: 8, marginTop: 4 },
|
||||
pick: {
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
backgroundColor: c.bg,
|
||||
borderRadius: 10,
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
minWidth: 56,
|
||||
alignItems: 'center',
|
||||
},
|
||||
pickText: { color: c.text, fontWeight: '600' },
|
||||
primary: {
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 14,
|
||||
alignItems: 'center',
|
||||
marginTop: 4,
|
||||
},
|
||||
primaryText: { color: '#fff', fontWeight: '700', fontSize: 15 },
|
||||
secondary: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
secondaryText: { color: c.text, fontWeight: '700' },
|
||||
danger: {
|
||||
flex: 1,
|
||||
backgroundColor: c.danger,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
});
|
||||
309
app/src/screens/MapAlignScreen.tsx
Normal file
309
app/src/screens/MapAlignScreen.tsx
Normal file
|
|
@ -0,0 +1,309 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { Alert, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
ShapeSource,
|
||||
FillLayer,
|
||||
LineLayer,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import {
|
||||
applyOverlay,
|
||||
loadAreaData,
|
||||
saveOverlay,
|
||||
saveOverlayLocally,
|
||||
publishBundledAreas,
|
||||
type ParkingArea,
|
||||
} from '@/api/parkingAreas';
|
||||
import { getAdminToken } from '@/api/adminStore';
|
||||
import { IDENTITY_OVERLAY, type OverlayAdjust } from '@/features/citymap/geo';
|
||||
|
||||
const MAP_STYLE_LIGHT =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrl as string) ??
|
||||
'https://tiles.openfreemap.org/styles/liberty';
|
||||
const MAP_STYLE_DARK =
|
||||
(Constants.expoConfig?.extra?.mapStyleUrlDark as string) ??
|
||||
'https://basemaps.cartocdn.com/gl/dark-matter-gl-style/style.json';
|
||||
|
||||
const SANDPOINT: [number, number] = [-116.5533, 48.2766];
|
||||
|
||||
/**
|
||||
* Align the city parking map against reality.
|
||||
*
|
||||
* The overlay was georeferenced by fitting the printed map to OpenStreetMap street
|
||||
* centrelines, which lands within a few metres — but a few metres is the difference
|
||||
* between one side of a street and the other. This is the fix for that: stand on a
|
||||
* marked block, watch the blue dot against the coloured stripe, and nudge until
|
||||
* they agree. No rebuild, and no need to re-derive the fit.
|
||||
*
|
||||
* "Save on this phone" is deliberately available without the admin token — the
|
||||
* person who can see the misalignment is the person standing on the street.
|
||||
*/
|
||||
export function MapAlignScreen() {
|
||||
const { mode, colors } = useTheme();
|
||||
const insets = useSafeAreaInsets();
|
||||
const cameraRef = useRef<any>(null);
|
||||
|
||||
const [base, setBase] = useState<ParkingArea[]>([]);
|
||||
const [adj, setAdj] = useState<Omit<OverlayAdjust, 'updatedAt'>>(IDENTITY_OVERLAY);
|
||||
const [step, setStep] = useState(2); // metres per nudge
|
||||
const [isAdmin, setIsAdmin] = useState(false);
|
||||
const [status, setStatus] = useState('Loading the city map…');
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const data = await loadAreaData();
|
||||
setBase(data.areas);
|
||||
const { updatedAt, ...rest } = data.overlay;
|
||||
setAdj(rest);
|
||||
setStatus(
|
||||
`${data.areas.length} areas (${data.source})` +
|
||||
(updatedAt ? ` · adjusted ${new Date(updatedAt).toLocaleDateString()}` : ''),
|
||||
);
|
||||
})();
|
||||
void getAdminToken().then((t) => setIsAdmin(!!t));
|
||||
}, []);
|
||||
|
||||
const preview = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: applyOverlay(base, { ...adj, updatedAt: 0 }).map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
id: a.id,
|
||||
geometry: a.geometry,
|
||||
properties: { color: a.color },
|
||||
})),
|
||||
}),
|
||||
[base, adj],
|
||||
);
|
||||
|
||||
const nudge = useCallback(
|
||||
(dx: number, dy: number) => setAdj((a) => ({ ...a, dxMeters: a.dxMeters + dx, dyMeters: a.dyMeters + dy })),
|
||||
[],
|
||||
);
|
||||
|
||||
const bump = useCallback(
|
||||
(key: 'scale' | 'rotationDeg', by: number) =>
|
||||
setAdj((a) => ({ ...a, [key]: Number((a[key] + by).toFixed(4)) })),
|
||||
[],
|
||||
);
|
||||
|
||||
const saveLocal = async () => {
|
||||
await saveOverlayLocally(adj);
|
||||
setStatus('Saved on this phone.');
|
||||
};
|
||||
|
||||
const savePublished = async () => {
|
||||
try {
|
||||
await saveOverlay(adj);
|
||||
setStatus('Published — every device will pick this up.');
|
||||
} catch (e: any) {
|
||||
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
|
||||
}
|
||||
};
|
||||
|
||||
const publishAreas = async () => {
|
||||
try {
|
||||
setStatus('Publishing bundled areas…');
|
||||
const n = await publishBundledAreas();
|
||||
setStatus(`Published ${n} areas to the server.`);
|
||||
} catch (e: any) {
|
||||
Alert.alert('Publish failed', e?.message ?? 'Could not reach the server.');
|
||||
}
|
||||
};
|
||||
|
||||
const s = styles(colors);
|
||||
const dirty =
|
||||
adj.dxMeters !== 0 || adj.dyMeters !== 0 || adj.scale !== 1 || adj.rotationDeg !== 0;
|
||||
|
||||
return (
|
||||
<View style={s.container}>
|
||||
<MapView
|
||||
style={s.map}
|
||||
mapStyle={mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT}
|
||||
rotateEnabled={false}
|
||||
>
|
||||
<Camera ref={cameraRef} defaultSettings={{ centerCoordinate: SANDPOINT, zoomLevel: 16 }} />
|
||||
<UserLocation visible renderMode="normal" />
|
||||
<ShapeSource id="align-preview" shape={preview}>
|
||||
<FillLayer
|
||||
id="align-fills"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
|
||||
/>
|
||||
<LineLayer
|
||||
id="align-lines"
|
||||
style={{
|
||||
lineColor: ['get', 'color'],
|
||||
lineWidth: ['interpolate', ['linear'], ['zoom'], 14, 3, 18, 11],
|
||||
lineOpacity: 0.95,
|
||||
lineCap: 'round',
|
||||
}}
|
||||
/>
|
||||
</ShapeSource>
|
||||
</MapView>
|
||||
|
||||
<View style={[s.status, { top: insets.top + 12 }]}>
|
||||
<Text style={s.statusText} numberOfLines={2}>
|
||||
{status}
|
||||
</Text>
|
||||
<Text style={s.readout}>
|
||||
E {adj.dxMeters.toFixed(1)} m · N {adj.dyMeters.toFixed(1)} m · ×
|
||||
{adj.scale.toFixed(3)} · {adj.rotationDeg.toFixed(2)}°
|
||||
</Text>
|
||||
</View>
|
||||
|
||||
<View style={[s.panel, { paddingBottom: insets.bottom + 12 }]}>
|
||||
<View style={s.padRow}>
|
||||
<View style={s.pad}>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(0, step)}>
|
||||
<Text style={s.keyText}>↑</Text>
|
||||
</TouchableOpacity>
|
||||
<View style={s.padMid}>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(-step, 0)}>
|
||||
<Text style={s.keyText}>←</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.stepKey} onPress={() => setStep(step >= 8 ? 0.5 : step * 2)}>
|
||||
<Text style={s.stepText}>{step} m</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(step, 0)}>
|
||||
<Text style={s.keyText}>→</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<TouchableOpacity style={s.key} onPress={() => nudge(0, -step)}>
|
||||
<Text style={s.keyText}>↓</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
<View style={s.fine}>
|
||||
<Text style={s.fineLabel}>Rotate</Text>
|
||||
<View style={s.fineRow}>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', -0.25)}>
|
||||
<Text style={s.keyText}>↺</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('rotationDeg', 0.25)}>
|
||||
<Text style={s.keyText}>↻</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={s.fineLabel}>Scale</Text>
|
||||
<View style={s.fineRow}>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', -0.002)}>
|
||||
<Text style={s.keyText}>−</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.fineKey} onPress={() => bump('scale', 0.002)}>
|
||||
<Text style={s.keyText}>+</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
|
||||
<View style={s.btnRow}>
|
||||
<TouchableOpacity
|
||||
style={[s.btn, !dirty && s.btnMuted]}
|
||||
onPress={() => setAdj(IDENTITY_OVERLAY)}
|
||||
>
|
||||
<Text style={s.btnText}>Reset</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.btnPrimary} onPress={saveLocal}>
|
||||
<Text style={s.btnTextOn}>Save on this phone</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
|
||||
{isAdmin ? (
|
||||
<View style={s.btnRow}>
|
||||
<TouchableOpacity style={s.btn} onPress={publishAreas}>
|
||||
<Text style={s.btnText}>Publish areas</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={s.btnPrimary} onPress={savePublished}>
|
||||
<Text style={s.btnTextOn}>Publish alignment</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : null}
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = (c: ReturnType<typeof useTheme>['colors']) =>
|
||||
StyleSheet.create({
|
||||
container: { flex: 1, backgroundColor: c.bg },
|
||||
map: { flex: 1 },
|
||||
status: {
|
||||
position: 'absolute',
|
||||
left: 12,
|
||||
right: 12,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
borderRadius: 12,
|
||||
paddingHorizontal: 12,
|
||||
paddingVertical: 8,
|
||||
gap: 2,
|
||||
},
|
||||
statusText: { color: '#fff', fontSize: 13 },
|
||||
readout: { color: '#c9d6d2', fontSize: 12, fontVariant: ['tabular-nums'] },
|
||||
panel: {
|
||||
backgroundColor: c.card,
|
||||
borderTopWidth: 1,
|
||||
borderTopColor: c.border,
|
||||
padding: 12,
|
||||
gap: 10,
|
||||
},
|
||||
padRow: { flexDirection: 'row', gap: 16, alignItems: 'center' },
|
||||
pad: { alignItems: 'center', gap: 6 },
|
||||
padMid: { flexDirection: 'row', alignItems: 'center', gap: 6 },
|
||||
key: {
|
||||
width: 52,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
keyText: { color: c.text, fontSize: 20, fontWeight: '700' },
|
||||
stepKey: {
|
||||
width: 52,
|
||||
height: 44,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.primary,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
stepText: { color: '#fff', fontWeight: '700', fontSize: 13 },
|
||||
fine: { flex: 1, gap: 4 },
|
||||
fineLabel: { color: c.subtext, fontSize: 12, fontWeight: '600' },
|
||||
fineRow: { flexDirection: 'row', gap: 6 },
|
||||
fineKey: {
|
||||
flex: 1,
|
||||
height: 38,
|
||||
borderRadius: 10,
|
||||
backgroundColor: c.bg,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
},
|
||||
btnRow: { flexDirection: 'row', gap: 8 },
|
||||
btn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderColor: c.border,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnMuted: { opacity: 0.5 },
|
||||
btnPrimary: {
|
||||
flex: 1,
|
||||
backgroundColor: c.primary,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnText: { color: c.text, fontWeight: '700' },
|
||||
btnTextOn: { color: '#fff', fontWeight: '700' },
|
||||
});
|
||||
|
|
@ -1,14 +1,24 @@
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import { ActivityIndicator, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import {
|
||||
ActivityIndicator,
|
||||
Alert,
|
||||
AppState,
|
||||
StyleSheet,
|
||||
Text,
|
||||
TouchableOpacity,
|
||||
View,
|
||||
} from 'react-native';
|
||||
import Constants from 'expo-constants';
|
||||
import {
|
||||
MapView,
|
||||
Camera,
|
||||
ShapeSource,
|
||||
CircleLayer,
|
||||
FillLayer,
|
||||
LineLayer,
|
||||
UserLocation,
|
||||
} from '@maplibre/maplibre-react-native';
|
||||
import { useNavigation } from '@react-navigation/native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { useSafeAreaInsets } from 'react-native-safe-area-context';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
|
|
@ -16,6 +26,19 @@ import { ps } from '@/api/client';
|
|||
import { useLocation, type Coords } from '@/features/location/useLocation';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { getMirrorZones, syncZones } from '@/api/zoneMirror';
|
||||
import {
|
||||
areaRequiresAccount,
|
||||
getAdjustedAreas,
|
||||
refreshAreas,
|
||||
type ParkingArea,
|
||||
} from '@/api/parkingAreas';
|
||||
import { distanceToGeometry, type LonLat } from '@/features/citymap/geo';
|
||||
import {
|
||||
getParkedPin,
|
||||
setParkedPin,
|
||||
type ParkedSpot,
|
||||
} from '@/features/session/activeParkingStore';
|
||||
import { pinParkedSpot } from '@/features/session/activeParking';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { Zone } from 'parksmarter-client';
|
||||
|
||||
|
|
@ -30,8 +53,43 @@ const MAP_STYLE_DARK =
|
|||
const DEFAULT_CENTER: Coords = { latitude: 48.2766, longitude: -116.5533 };
|
||||
const DEFAULT_ZOOM = 14;
|
||||
|
||||
/**
|
||||
* How often to re-read the GPS while the map is the screen you're looking at.
|
||||
* A single fix at mount goes stale the moment you walk a block, which is the
|
||||
* whole time you'd be looking at this screen.
|
||||
*/
|
||||
const GPS_REFRESH_MS = 30_000;
|
||||
|
||||
/**
|
||||
* How old a fix can be before "Park here" / "My location" stops trusting it and
|
||||
* goes and asks again. A poll and a half, so a fix arriving on schedule is never
|
||||
* treated as stale.
|
||||
*/
|
||||
const FIX_MAX_AGE_MS = 45_000;
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
/**
|
||||
* How far from a city-map area a parked pin can be and still be taken as "that's
|
||||
* where I am". A block is ~170 m, a street ~12 m wide; 40 m picks the right side
|
||||
* of the right street without silently matching a spot two blocks away.
|
||||
*/
|
||||
const AREA_SNAP_METERS = 40;
|
||||
|
||||
/** Nearest city-map area to a point, or null if nothing is close enough. */
|
||||
function areaAt(point: LonLat, areas: ParkingArea[]): ParkingArea | null {
|
||||
let best: ParkingArea | null = null;
|
||||
let bestDist = AREA_SNAP_METERS;
|
||||
for (const a of areas) {
|
||||
const d = distanceToGeometry(point, a.geometry);
|
||||
if (d < bestDist) {
|
||||
best = a;
|
||||
bestDist = d;
|
||||
}
|
||||
}
|
||||
return best;
|
||||
}
|
||||
|
||||
/** Coerce the server's zone color (hex string, color name, or numeric) into a usable color. */
|
||||
function normalizeColor(v: unknown): string | null {
|
||||
if (typeof v === 'number') return '#' + (v & 0xffffff).toString(16).padStart(6, '0');
|
||||
|
|
@ -49,13 +107,33 @@ export function MapScreen() {
|
|||
const insets = useSafeAreaInsets();
|
||||
const { mode } = useTheme();
|
||||
const mapStyle = mode === 'dark' ? MAP_STYLE_DARK : MAP_STYLE_LIGHT;
|
||||
const { coords, refresh } = useLocation();
|
||||
// Poll the GPS while this screen is actually in front of someone. Focus alone
|
||||
// isn't enough: a backgrounded app stays "focused" on its last tab, and Android
|
||||
// won't give a foreground app's location out to one that isn't.
|
||||
const [focused, setFocused] = useState(true);
|
||||
const [foreground, setForeground] = useState(AppState.currentState === 'active');
|
||||
useEffect(() => {
|
||||
const sub = AppState.addEventListener('change', (next) => setForeground(next === 'active'));
|
||||
return () => sub.remove();
|
||||
}, []);
|
||||
const { coords, updatedAt, refresh } = useLocation({
|
||||
intervalMs: GPS_REFRESH_MS,
|
||||
active: focused && foreground,
|
||||
});
|
||||
const { isAnonymous } = useAuth();
|
||||
const [zones, setZones] = useState<Zone[]>([]);
|
||||
const [status, setStatus] = useState<string>('Pan to an area and tap “Search this area”.');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [initialCenter, setInitialCenter] = useState<Coords | null>(null);
|
||||
|
||||
// The city's printed parking map, georeferenced. Local geography — loading and
|
||||
// tapping these never touches the ParkSmarter API.
|
||||
const [areas, setAreas] = useState<ParkingArea[]>([]);
|
||||
const [showAreas, setShowAreas] = useState(true);
|
||||
// Set while waiting for the user to tap where they parked (the GPS-less path).
|
||||
const [pinning, setPinning] = useState(false);
|
||||
const [spot, setSpot] = useState<ParkedSpot | null>(null);
|
||||
|
||||
const mapRef = useRef<any>(null);
|
||||
const cameraRef = useRef<any>(null);
|
||||
const [mapReady, setMapReady] = useState(false);
|
||||
|
|
@ -69,11 +147,16 @@ export function MapScreen() {
|
|||
const viewRef = useRef<{ center: [number, number]; zoom: number } | null>(null);
|
||||
// The native UserLocation dot has its own GPS feed — capture it so "My
|
||||
// location" works even when expo-location can't get a fix (e.g. indoors).
|
||||
const nativeFix = useRef<Coords | null>(null);
|
||||
// Stamped, because that feed goes quiet whenever the map isn't drawing and a
|
||||
// silently stale fix is worse than no fix.
|
||||
const nativeFix = useRef<(Coords & { at: number }) | null>(null);
|
||||
|
||||
// Look up the LAST session's parking-lot coordinate (the meter's own location
|
||||
// from history — never the user's GPS). Used to open the map and by "Last lot".
|
||||
const lastSessionLot = useCallback(async (): Promise<Coords | null> => {
|
||||
// No account, no session history — and asking anyway 401s, which the global
|
||||
// handler would turn into a bogus "session expired" bounce.
|
||||
if (isAnonymous) return null;
|
||||
try {
|
||||
const past = await ps.getPastParkingSessions({ currentPage: 1, pageSize: 1 });
|
||||
const s = past.Session?.[0] as Record<string, any> | undefined;
|
||||
|
|
@ -86,7 +169,7 @@ export function MapScreen() {
|
|||
/* ignore */
|
||||
}
|
||||
return null;
|
||||
}, []);
|
||||
}, [isAnonymous]);
|
||||
|
||||
// Open on your last parking lot — NOT your GPS. Your location is only ever sent
|
||||
// to the API when you explicitly tap "My location", so we never auto-center on it.
|
||||
|
|
@ -148,6 +231,53 @@ export function MapScreen() {
|
|||
if (isAnonymous) void searchAt(DEFAULT_CENTER, 'Sandpoint');
|
||||
}, [isAnonymous, searchAt]);
|
||||
|
||||
// City parking map: draw the cached/bundled copy immediately so the overlay is
|
||||
// there offline, then quietly refresh from the server behind it.
|
||||
useEffect(() => {
|
||||
let alive = true;
|
||||
(async () => {
|
||||
const local = await getAdjustedAreas();
|
||||
if (alive) setAreas(local.areas);
|
||||
try {
|
||||
await refreshAreas();
|
||||
const fresh = await getAdjustedAreas();
|
||||
if (alive) setAreas(fresh.areas);
|
||||
} catch {
|
||||
/* offline or unseeded — the local copy is already drawn */
|
||||
}
|
||||
})();
|
||||
return () => {
|
||||
alive = false;
|
||||
};
|
||||
}, []);
|
||||
|
||||
// Re-read the pin on every focus: the session may have ended on another screen
|
||||
// (or from the notification), which clears it. Focus also gates the GPS poll.
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
void getParkedPin().then(setSpot);
|
||||
setFocused(true);
|
||||
return () => setFocused(false);
|
||||
}, []),
|
||||
);
|
||||
|
||||
/**
|
||||
* The freshest fix we can get, in the order it's cheapest to get it: the map's
|
||||
* own dot, then the polled expo fix, then a forced read. Whichever we hold is
|
||||
* only used if it's recent — the point of the poll is that "where am I" answers
|
||||
* with where you are now, not where you were when the screen opened.
|
||||
*/
|
||||
const bestFix = useCallback(async (): Promise<Coords | null> => {
|
||||
const now = Date.now();
|
||||
const n = nativeFix.current;
|
||||
if (n && now - n.at < FIX_MAX_AGE_MS) return { latitude: n.latitude, longitude: n.longitude };
|
||||
if (coords && now - updatedAt < FIX_MAX_AGE_MS) return coords;
|
||||
const fresh = await refresh();
|
||||
if (fresh) return fresh;
|
||||
// Nothing current and nothing new — a stale fix still beats no answer.
|
||||
return n ? { latitude: n.latitude, longitude: n.longitude } : coords;
|
||||
}, [coords, updatedAt, refresh]);
|
||||
|
||||
// Search whatever the map is currently centered on. This only ever sends the
|
||||
// map's center point — never the device GPS. (If you want to search your own
|
||||
// location, tap "My location" to center there first, then Search this area.)
|
||||
|
|
@ -171,10 +301,9 @@ export function MapScreen() {
|
|||
await searchAt({ latitude: center[1], longitude: center[0] }, 'this area');
|
||||
};
|
||||
|
||||
// Recenter on the live GPS fix (if available) and search there. Prefer the
|
||||
// native map fix (the blue dot), then expo-location, then a forced refresh.
|
||||
// Recenter on the live GPS fix (if available) and search there.
|
||||
const goToMyLocation = async () => {
|
||||
const c = nativeFix.current ?? coords ?? (await refresh());
|
||||
const c = await bestFix();
|
||||
if (!c) {
|
||||
setStatus('No location fix yet — GPS may be unavailable (e.g. indoors).');
|
||||
return;
|
||||
|
|
@ -190,6 +319,10 @@ export function MapScreen() {
|
|||
// Center on the LAST SESSION's parking lot (the meter's own coordinate from
|
||||
// history — never your GPS) and search around it with a ~couple-mile view.
|
||||
const searchLastSessionLot = async () => {
|
||||
if (isAnonymous) {
|
||||
setStatus('Sign in to use your last parking lot — it comes from your account history.');
|
||||
return;
|
||||
}
|
||||
setLoading(true);
|
||||
setStatus('Finding your last parking lot…');
|
||||
const lot = await lastSessionLot();
|
||||
|
|
@ -206,6 +339,139 @@ export function MapScreen() {
|
|||
await searchAt(lot, 'your last lot');
|
||||
};
|
||||
|
||||
/* -------------------------------------------------- parking-map interaction */
|
||||
|
||||
/**
|
||||
* The paid city lots are ParkSmarter-only, so they're hidden from someone
|
||||
* browsing without an account — showing parking you can't actually buy is worse
|
||||
* than not showing it.
|
||||
*/
|
||||
const hidden = useCallback(
|
||||
(a: ParkingArea) => isAnonymous && areaRequiresAccount(a),
|
||||
[isAnonymous],
|
||||
);
|
||||
|
||||
/** What actually gets drawn and tapped. */
|
||||
const visibleAreas = useMemo(() => areas.filter((a) => !hidden(a)), [areas, hidden]);
|
||||
|
||||
/** Open an area, carrying the pin along if we have one. */
|
||||
const openArea = useCallback(
|
||||
(area: ParkingArea, at?: ParkedSpot) => {
|
||||
navigation.navigate('CityArea', { area, spot: at });
|
||||
},
|
||||
[navigation],
|
||||
);
|
||||
|
||||
/** Drop the pin at `c`, work out which area that is, and open it. */
|
||||
const pinAt = useCallback(
|
||||
(c: Coords, manual: boolean) => {
|
||||
const at: ParkedSpot = { latitude: c.latitude, longitude: c.longitude, manual };
|
||||
setSpot(at);
|
||||
// Persist immediately — the pin is worth keeping even if you never start a
|
||||
// timer, and even if you back out of the screen we're about to open.
|
||||
void pinParkedSpot(at);
|
||||
// Detect against every area, including the ones hidden from this user, so
|
||||
// standing in a paid lot gets an explanation rather than "nothing found".
|
||||
const found = areaAt([c.longitude, c.latitude], areas);
|
||||
if (found && hidden(found)) {
|
||||
setStatus(`Pinned. ${found.name} is a paid city lot — sign in to park there.`);
|
||||
} else if (found) {
|
||||
setStatus(`Parked at ${found.name}`);
|
||||
openArea(found, at);
|
||||
} else {
|
||||
// Pin still stands — you parked somewhere, it's just not on the city map.
|
||||
setStatus('Pinned. No mapped parking area within 40 m — tap a coloured segment to pick one.');
|
||||
}
|
||||
},
|
||||
[areas, hidden, openArea],
|
||||
);
|
||||
|
||||
// "Park here": pin from GPS and auto-detect the area. When there's no fix (a
|
||||
// garage, indoors, GPS off) fall back to letting the user tap the spot — the
|
||||
// pin is the point, so it must not depend on the GPS working.
|
||||
const parkHere = async () => {
|
||||
if (pinning) {
|
||||
setPinning(false);
|
||||
setStatus('Pin cancelled.');
|
||||
return;
|
||||
}
|
||||
const c = await bestFix();
|
||||
if (!c) {
|
||||
setPinning(true);
|
||||
setStatus('No GPS fix — tap the map where you parked.');
|
||||
return;
|
||||
}
|
||||
cameraRef.current?.setCamera?.({
|
||||
centerCoordinate: [c.longitude, c.latitude],
|
||||
zoomLevel: 17,
|
||||
animationDuration: 500,
|
||||
});
|
||||
pinAt(c, false);
|
||||
};
|
||||
|
||||
/** A tap on the map: places the manual pin, but only while we asked for one. */
|
||||
const onMapPress = (e: any) => {
|
||||
if (!pinning) return;
|
||||
const c = e?.geometry?.coordinates;
|
||||
if (!Array.isArray(c) || c.length !== 2) return;
|
||||
setPinning(false);
|
||||
pinAt({ latitude: c[1], longitude: c[0] }, true);
|
||||
};
|
||||
|
||||
/** A tap on your own pin: the only way to take it down without ending a session. */
|
||||
const onSpotPress = () => {
|
||||
if (!spot) return;
|
||||
Alert.alert('Your car', 'Remove the parked pin?', [
|
||||
{ text: 'Keep', style: 'cancel' },
|
||||
{
|
||||
text: 'Remove',
|
||||
style: 'destructive',
|
||||
onPress: () => {
|
||||
setSpot(null);
|
||||
void setParkedPin(null);
|
||||
setStatus('Pin removed.');
|
||||
},
|
||||
},
|
||||
]);
|
||||
};
|
||||
|
||||
/** A tap on a coloured segment: the other start flow, no pin involved. */
|
||||
const onAreaPress = (e: any) => {
|
||||
const id = e?.features?.[0]?.properties?.id;
|
||||
const found = visibleAreas.find((a) => a.id === id);
|
||||
if (found) openArea(found, spot ?? undefined);
|
||||
};
|
||||
|
||||
const areaFeatures = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: visibleAreas.map((a) => ({
|
||||
type: 'Feature' as const,
|
||||
id: a.id,
|
||||
geometry: a.geometry,
|
||||
properties: { id: a.id, color: a.color, kind: a.kind },
|
||||
})),
|
||||
}),
|
||||
[visibleAreas],
|
||||
);
|
||||
|
||||
const spotFeature = useMemo(
|
||||
() => ({
|
||||
type: 'FeatureCollection' as const,
|
||||
features: spot
|
||||
? [
|
||||
{
|
||||
type: 'Feature' as const,
|
||||
id: 'parked',
|
||||
geometry: { type: 'Point' as const, coordinates: [spot.longitude, spot.latitude] },
|
||||
properties: {},
|
||||
},
|
||||
]
|
||||
: [],
|
||||
}),
|
||||
[spot],
|
||||
);
|
||||
|
||||
// Meters as a GeoJSON layer (GPU-drawn, coordinate-anchored) — far more stable
|
||||
// than React MarkerViews, which floated and thrashed the camera.
|
||||
const meterFeatures = useMemo(
|
||||
|
|
@ -246,6 +512,7 @@ export function MapScreen() {
|
|||
nativeFix.current = {
|
||||
latitude: loc.coords.latitude,
|
||||
longitude: loc.coords.longitude,
|
||||
at: Date.now(),
|
||||
};
|
||||
}
|
||||
}}
|
||||
|
|
@ -262,6 +529,7 @@ export function MapScreen() {
|
|||
mapStyle={mapStyle}
|
||||
rotateEnabled={false}
|
||||
onDidFinishLoadingMap={() => setMapReady(true)}
|
||||
onPress={onMapPress}
|
||||
onRegionDidChange={(f: any) => {
|
||||
const c = f?.geometry?.coordinates;
|
||||
const z = f?.properties?.zoomLevel;
|
||||
|
|
@ -273,6 +541,57 @@ export function MapScreen() {
|
|||
{cameraEl}
|
||||
{userLocationEl}
|
||||
|
||||
{/* The city parking map, under the meter pins so pins stay tappable. */}
|
||||
{showAreas ? (
|
||||
<ShapeSource id="city-areas" shape={areaFeatures} onPress={onAreaPress}>
|
||||
<FillLayer
|
||||
id="city-area-fills"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ fillColor: ['get', 'color'], fillOpacity: 0.45 }}
|
||||
/>
|
||||
<LineLayer
|
||||
id="city-area-outlines"
|
||||
filter={['==', ['geometry-type'], 'Polygon']}
|
||||
style={{ lineColor: ['get', 'color'], lineWidth: 1.5, lineOpacity: 0.9 }}
|
||||
/>
|
||||
{/* Street segments, scaled with zoom so they read as painted kerb. */}
|
||||
<LineLayer
|
||||
id="city-area-lines"
|
||||
filter={['==', ['geometry-type'], 'LineString']}
|
||||
style={{
|
||||
lineColor: ['get', 'color'],
|
||||
lineOpacity: 0.95,
|
||||
lineCap: 'round',
|
||||
lineWidth: ['interpolate', ['linear'], ['zoom'], 12, 2, 15, 5, 18, 11],
|
||||
}}
|
||||
/>
|
||||
{/* A fat, near-invisible line purely to make thin segments tappable —
|
||||
a 5 px kerb stripe is far too small a target for a fingertip. */}
|
||||
<LineLayer
|
||||
id="city-area-touch"
|
||||
filter={['==', ['geometry-type'], 'LineString']}
|
||||
style={{ lineColor: '#000000', lineOpacity: 0.01, lineWidth: 24 }}
|
||||
/>
|
||||
</ShapeSource>
|
||||
) : null}
|
||||
|
||||
{/* Where the car is. Drawn above everything — it's the thing you came back for. */}
|
||||
<ShapeSource id="parked-spot" shape={spotFeature} onPress={onSpotPress}>
|
||||
<CircleLayer
|
||||
id="parked-halo"
|
||||
style={{ circleColor: '#1e6f5c', circleOpacity: 0.25, circleRadius: 18 }}
|
||||
/>
|
||||
<CircleLayer
|
||||
id="parked-dot"
|
||||
style={{
|
||||
circleColor: '#1e6f5c',
|
||||
circleStrokeColor: '#ffffff',
|
||||
circleStrokeWidth: 3,
|
||||
circleRadius: 8,
|
||||
}}
|
||||
/>
|
||||
</ShapeSource>
|
||||
|
||||
<ShapeSource id="meters" shape={meterFeatures} onPress={onPinPress}>
|
||||
<CircleLayer
|
||||
id="meter-circles"
|
||||
|
|
@ -303,7 +622,22 @@ export function MapScreen() {
|
|||
</View>
|
||||
|
||||
<View style={[styles.controls, { bottom: insets.bottom + 24 }]}>
|
||||
<TouchableOpacity style={styles.pillPrimary} onPress={searchThisArea}>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity
|
||||
style={pinning ? styles.pillActive : styles.pillPrimary}
|
||||
onPress={parkHere}
|
||||
>
|
||||
<Text style={styles.pillText}>{pinning ? 'Tap the map…' : 'Park here'}</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity
|
||||
style={showAreas ? styles.pillOn : styles.pill}
|
||||
onPress={() => setShowAreas((v) => !v)}
|
||||
>
|
||||
<Text style={styles.pillText}>City map</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<View style={styles.row}>
|
||||
<TouchableOpacity style={styles.pill} onPress={searchThisArea}>
|
||||
<Text style={styles.pillText}>Search this area</Text>
|
||||
</TouchableOpacity>
|
||||
<TouchableOpacity style={styles.pill} onPress={goToMyLocation}>
|
||||
|
|
@ -314,6 +648,7 @@ export function MapScreen() {
|
|||
</TouchableOpacity>
|
||||
</View>
|
||||
</View>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
@ -337,9 +672,10 @@ const styles = StyleSheet.create({
|
|||
position: 'absolute',
|
||||
bottom: 24,
|
||||
alignSelf: 'center',
|
||||
flexDirection: 'row',
|
||||
alignItems: 'center',
|
||||
gap: 8,
|
||||
},
|
||||
row: { flexDirection: 'row', gap: 8 },
|
||||
pill: {
|
||||
backgroundColor: '#444',
|
||||
paddingHorizontal: 14,
|
||||
|
|
@ -352,5 +688,18 @@ const styles = StyleSheet.create({
|
|||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
/** Waiting for the user to tap where they parked. */
|
||||
pillActive: {
|
||||
backgroundColor: '#c07a12',
|
||||
paddingHorizontal: 16,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillOn: {
|
||||
backgroundColor: '#2f6f60',
|
||||
paddingHorizontal: 14,
|
||||
paddingVertical: 10,
|
||||
borderRadius: 22,
|
||||
},
|
||||
pillText: { color: '#fff', fontWeight: '600' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -19,7 +19,7 @@ import {
|
|||
type LabelKind,
|
||||
type ZoneLabel,
|
||||
} from '@/api/zoneLabels';
|
||||
import { startCheckin } from '@/features/checkin/checkin';
|
||||
import { startFreeCheckin } from '@/features/session/activeParking';
|
||||
|
||||
/** Admin labeling buttons — kind, or 'clear' to remove. */
|
||||
const LABEL_CHOICES: Array<{ label: string; kind: LabelKind | 'clear' }> = [
|
||||
|
|
@ -140,7 +140,7 @@ export function MeterDetailScreen() {
|
|||
const labeledHours = label ? labelHours(label.kind) : null;
|
||||
|
||||
const doCheckin = async (hours: number) => {
|
||||
await startCheckin(z, hours);
|
||||
await startFreeCheckin(z, hours, label?.kind);
|
||||
Alert.alert(
|
||||
'Checked in',
|
||||
`Free timer set for ${hours}h. You'll get a heads-up before it ends — with buttons to pay or end.`,
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { Alert, StyleSheet, Switch, Text, TouchableOpacity, View } from 'react-n
|
|||
import { useFocusEffect } from '@react-navigation/native';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { sendTestReminder } from '@/notifications/localReminders';
|
||||
import { refreshSessionStatus, clearSessionStatus } from '@/notifications/sessionStatus';
|
||||
import { refreshParkingNotification } from '@/features/session/activeParking';
|
||||
import {
|
||||
DEFAULT_LEAD_MINUTES,
|
||||
LEAD_STEP,
|
||||
|
|
@ -38,7 +38,7 @@ export function NotificationsScreen() {
|
|||
|
||||
const toggleCountdown = (v: boolean) => {
|
||||
setCountdown(v);
|
||||
void setCountdownEnabled(v).then(() => (v ? refreshSessionStatus() : clearSessionStatus()));
|
||||
void setCountdownEnabled(v).then(refreshParkingNotification);
|
||||
};
|
||||
|
||||
const bump = (delta: number) => {
|
||||
|
|
|
|||
|
|
@ -1,26 +1,72 @@
|
|||
import React, { useCallback, useState } from 'react';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { RefreshControl, ScrollView, StyleSheet, Text, TouchableOpacity, View } from 'react-native';
|
||||
import { useFocusEffect, useNavigation } from '@react-navigation/native';
|
||||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useAuth } from '@/auth/AuthContext';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { endActiveParking, extendAreaParking } from '@/features/session/activeParking';
|
||||
import { getActiveParking, type ActiveParking } from '@/features/session/activeParkingStore';
|
||||
import {
|
||||
getLocalHistory,
|
||||
isLocalOnly,
|
||||
type LocalSessionRecord,
|
||||
} from '@/features/session/localHistory';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import type { ActiveSession, PastSession } from 'parksmarter-client';
|
||||
|
||||
type Nav = NativeStackNavigationProp<RootStackParamList>;
|
||||
|
||||
function fmtClock(ms: number): string {
|
||||
return new Date(ms).toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' });
|
||||
}
|
||||
|
||||
function fmtDate(ms: number): string {
|
||||
return new Date(ms).toLocaleDateString([], { month: 'short', day: 'numeric' });
|
||||
}
|
||||
|
||||
function fmtRemaining(ms: number): string {
|
||||
const mins = Math.max(0, Math.round(ms / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
function fmtSpan(from: number, to: number): string {
|
||||
const mins = Math.max(0, Math.round((to - from) / 60_000));
|
||||
const h = Math.floor(mins / 60);
|
||||
return h ? `${h}h ${mins % 60}m` : `${mins}m`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sessions, in two halves that must not depend on each other.
|
||||
*
|
||||
* Anything tracked on this phone — a city-map timer, a free check-in — is shown
|
||||
* and managed with no network and no account, because that is the only place it
|
||||
* exists. ParkSmarter's own sessions are layered on top when signed in, and a
|
||||
* failure to reach them (offline, or simply not logged in) must never hide the
|
||||
* local half.
|
||||
*/
|
||||
export function SessionsScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
const { isAnonymous, requireLogin } = useAuth();
|
||||
|
||||
const [local, setLocal] = useState<ActiveParking | null>(null);
|
||||
const [history, setHistory] = useState<LocalSessionRecord[]>([]);
|
||||
const [active, setActive] = useState<ActiveSession[]>([]);
|
||||
const [past, setPast] = useState<PastSession[]>([]);
|
||||
const [refreshing, setRefreshing] = useState(false);
|
||||
const [remoteError, setRemoteError] = useState<string | null>(null);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
/** On-device only. Never awaits the network, so it works offline and signed out. */
|
||||
const loadLocal = useCallback(async () => {
|
||||
const [a, h] = await Promise.all([getActiveParking(), getLocalHistory()]);
|
||||
setLocal(a && a.endMs > Date.now() ? a : null);
|
||||
setHistory(h);
|
||||
}, []);
|
||||
|
||||
const loadRemote = useCallback(async () => {
|
||||
if (isAnonymous) return;
|
||||
setRefreshing(true);
|
||||
try {
|
||||
const [a, p] = await Promise.all([
|
||||
ps.getActiveParkingSessions(),
|
||||
|
|
@ -28,10 +74,22 @@ export function SessionsScreen() {
|
|||
]);
|
||||
setActive(a.ParkingSession ?? []);
|
||||
setPast(p.Session ?? []);
|
||||
setRemoteError(null);
|
||||
} catch (e: any) {
|
||||
// Offline or the API is unhappy. Say so quietly and keep the local half.
|
||||
setRemoteError(e?.serverMessage ?? e?.message ?? 'Could not reach ParkSmarter.');
|
||||
}
|
||||
}, [isAnonymous]);
|
||||
|
||||
const load = useCallback(async () => {
|
||||
setRefreshing(true);
|
||||
try {
|
||||
await loadLocal();
|
||||
await loadRemote();
|
||||
} finally {
|
||||
setRefreshing(false);
|
||||
}
|
||||
}, [isAnonymous]);
|
||||
}, [loadLocal, loadRemote]);
|
||||
|
||||
useFocusEffect(
|
||||
useCallback(() => {
|
||||
|
|
@ -39,24 +97,14 @@ export function SessionsScreen() {
|
|||
}, [load]),
|
||||
);
|
||||
|
||||
if (isAnonymous) {
|
||||
return (
|
||||
<View style={[styles.center, { backgroundColor: colors.bg }]}>
|
||||
<Text style={[styles.zone, { color: colors.text, marginBottom: 8 }]}>
|
||||
Sign in to see your sessions
|
||||
</Text>
|
||||
<Text style={[styles.empty, { color: colors.subtext, textAlign: 'center', marginBottom: 16 }]}>
|
||||
Your active and past parking sessions live in your ParkSmarter account.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[styles.card, { backgroundColor: colors.primary, paddingHorizontal: 28 }]}
|
||||
onPress={requireLogin}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
}
|
||||
// Keep "time left" honest while the screen sits open.
|
||||
useEffect(() => {
|
||||
if (!local) return;
|
||||
const id = setInterval(() => void loadLocal(), 30_000);
|
||||
return () => clearInterval(id);
|
||||
}, [local, loadLocal]);
|
||||
|
||||
const s = styles;
|
||||
|
||||
return (
|
||||
<ScrollView
|
||||
|
|
@ -64,52 +112,166 @@ export function SessionsScreen() {
|
|||
contentContainerStyle={{ padding: 16 }}
|
||||
refreshControl={<RefreshControl refreshing={refreshing} onRefresh={load} />}
|
||||
>
|
||||
<Text style={[styles.header, { color: colors.text }]}>Active</Text>
|
||||
{active.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
<Text style={[s.header, { color: colors.text }]}>Tracking on this phone</Text>
|
||||
{local ? (
|
||||
<View style={[s.card, { backgroundColor: colors.primary + '22' }]}>
|
||||
<View style={s.chipRow}>
|
||||
{local.area ? <View style={[s.swatch, { backgroundColor: local.area.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{local.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.big, { color: colors.text }]}>
|
||||
{fmtRemaining(local.endMs - Date.now())} left
|
||||
</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{local.area?.legend ? `${local.area.legend} · ` : ''}
|
||||
{local.kind === 'free' ? 'Free' : 'Paid'} until {fmtClock(local.endMs)}
|
||||
{local.spot ? (local.spot.manual ? ' · pin placed by hand' : ' · pinned from GPS') : ''}
|
||||
</Text>
|
||||
<View style={s.row}>
|
||||
{/* Same split as the notification's second button: a local timer can be
|
||||
nudged for free, but a bought session can only be extended by buying
|
||||
more, so that one goes to the purchase screen instead of lying. */}
|
||||
{isLocalOnly(local) ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={async () => {
|
||||
await extendAreaParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>+1 hour</Text>
|
||||
</TouchableOpacity>
|
||||
) : local.zone ? (
|
||||
<TouchableOpacity
|
||||
style={[s.btn, { borderColor: colors.border }]}
|
||||
onPress={() => navigation.navigate('StartSession', { zone: local.zone! })}
|
||||
>
|
||||
<Text style={{ color: colors.text, fontWeight: '700' }}>Extend</Text>
|
||||
</TouchableOpacity>
|
||||
) : null}
|
||||
<TouchableOpacity
|
||||
style={[s.btnFilled, { backgroundColor: colors.danger }]}
|
||||
onPress={async () => {
|
||||
await endActiveParking();
|
||||
await loadLocal();
|
||||
}}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>End</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
<Text style={[s.hint, { color: colors.subtext }]}>
|
||||
{isLocalOnly(local)
|
||||
? 'Works offline — this timer lives on your phone, not on a server.'
|
||||
: 'Ending stops the countdown here. Time you bought keeps running at the meter.'}
|
||||
</Text>
|
||||
</View>
|
||||
) : (
|
||||
active.map((s, i) => (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>
|
||||
Nothing being tracked. Start one from the map — “Park here”, or tap a coloured block.
|
||||
</Text>
|
||||
)}
|
||||
|
||||
{history.length > 0 ? (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Recent on this phone</Text>
|
||||
{history.map((r) => (
|
||||
<View key={r.id} style={[s.card, { backgroundColor: colors.card }]}>
|
||||
<View style={s.chipRow}>
|
||||
{r.color ? <View style={[s.swatch, { backgroundColor: r.color }]} /> : null}
|
||||
<Text style={[s.zone, { color: colors.text, flexShrink: 1 }]}>{r.zoneName}</Text>
|
||||
</View>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{fmtDate(r.startMs)} · {fmtClock(r.startMs)}–{fmtClock(r.endedAtMs)} ·{' '}
|
||||
{fmtSpan(r.startMs, r.endedAtMs)}
|
||||
{r.endedEarly ? ' · ended early' : ' · ran out'}
|
||||
</Text>
|
||||
</View>
|
||||
))}
|
||||
</>
|
||||
) : null}
|
||||
|
||||
{isAnonymous ? (
|
||||
<View style={[s.card, { backgroundColor: colors.card, marginTop: 20 }]}>
|
||||
<Text style={[s.zone, { color: colors.text }]}>Paid ParkSmarter sessions</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext, marginBottom: 10 }]}>
|
||||
Sessions you bought live in your ParkSmarter account. Sign in to see them here —
|
||||
everything above stays on this phone either way.
|
||||
</Text>
|
||||
<TouchableOpacity
|
||||
style={[s.btnFilled, { backgroundColor: colors.primary, alignSelf: 'flex-start', paddingHorizontal: 24 }]}
|
||||
onPress={requireLogin}
|
||||
>
|
||||
<Text style={{ color: '#fff', fontWeight: '700' }}>Sign in</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
) : (
|
||||
<>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>Active (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>{remoteError} Pull to retry.</Text>
|
||||
) : active.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No active sessions.</Text>
|
||||
) : (
|
||||
active.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'active' })}
|
||||
style={[s.card, { backgroundColor: colors.primary + '22' }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'active' })}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>{s.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.SpaceName ?? s.Space ?? ''} · ends {s.EndTimeDisplay ?? s.EndTime ?? ''} › tap for details
|
||||
<Text style={[s.zone, { color: colors.text }]}>{sess.ZoneName ?? 'Session'}</Text>
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.SpaceName ?? sess.Space ?? ''} · ends{' '}
|
||||
{sess.EndTimeDisplay ?? sess.EndTime ?? ''} › tap for details
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
|
||||
<Text style={[styles.header, { color: colors.text, marginTop: 20 }]}>History</Text>
|
||||
{past.length === 0 ? (
|
||||
<Text style={[styles.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
<Text style={[s.header, { color: colors.text, marginTop: 20 }]}>History (ParkSmarter)</Text>
|
||||
{remoteError ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>Unavailable offline.</Text>
|
||||
) : past.length === 0 ? (
|
||||
<Text style={[s.empty, { color: colors.subtext }]}>No past sessions.</Text>
|
||||
) : (
|
||||
past.map((s, i) => (
|
||||
past.map((sess, i) => (
|
||||
<TouchableOpacity
|
||||
key={i}
|
||||
style={[styles.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: s, kind: 'past' })}
|
||||
style={[s.card, { backgroundColor: colors.card }]}
|
||||
onPress={() => navigation.navigate('SessionDetail', { session: sess, kind: 'past' })}
|
||||
>
|
||||
<Text style={[styles.zone, { color: colors.text }]}>
|
||||
{s.Description ?? s.Zone ?? s.ZoneName ?? 'Session'}
|
||||
<Text style={[s.zone, { color: colors.text }]}>
|
||||
{sess.Description ?? sess.Zone ?? sess.ZoneName ?? 'Session'}
|
||||
</Text>
|
||||
<Text style={[styles.meta, { color: colors.subtext }]}>
|
||||
{s.StartTime ?? ''} · {s.Amount != null ? `$${s.Amount}` : ''} › tap for receipt
|
||||
<Text style={[s.meta, { color: colors.subtext }]}>
|
||||
{sess.StartTime ?? ''} · {sess.Amount != null ? `$${sess.Amount}` : ''} › tap for
|
||||
receipt
|
||||
</Text>
|
||||
</TouchableOpacity>
|
||||
))
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</ScrollView>
|
||||
);
|
||||
}
|
||||
|
||||
const styles = StyleSheet.create({
|
||||
center: { flex: 1, alignItems: 'center', justifyContent: 'center', padding: 32 },
|
||||
header: { fontSize: 18, fontWeight: '700', marginBottom: 8 },
|
||||
empty: { marginBottom: 8 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10 },
|
||||
card: { borderRadius: 10, padding: 14, marginBottom: 10, gap: 4 },
|
||||
chipRow: { flexDirection: 'row', alignItems: 'center', gap: 8 },
|
||||
swatch: { width: 20, height: 11, borderRadius: 3 },
|
||||
zone: { fontSize: 16, fontWeight: '600' },
|
||||
big: { fontSize: 26, fontWeight: '700' },
|
||||
meta: { fontSize: 13, marginTop: 2 },
|
||||
hint: { fontSize: 12, marginTop: 6 },
|
||||
row: { flexDirection: 'row', gap: 8, marginTop: 8 },
|
||||
btn: {
|
||||
flex: 1,
|
||||
borderWidth: 1,
|
||||
borderRadius: 10,
|
||||
paddingVertical: 12,
|
||||
alignItems: 'center',
|
||||
},
|
||||
btnFilled: { flex: 1, borderRadius: 10, paddingVertical: 12, alignItems: 'center' },
|
||||
});
|
||||
|
|
|
|||
|
|
@ -14,9 +14,8 @@ import { useNavigation, useRoute } from '@react-navigation/native';
|
|||
import type { NativeStackNavigationProp } from '@react-navigation/native-stack';
|
||||
import { ps } from '@/api/client';
|
||||
import { useTheme } from '@/theme/ThemeContext';
|
||||
import { scheduleExpiryReminder } from '@/notifications/localReminders';
|
||||
import { showSessionStatus } from '@/notifications/sessionStatus';
|
||||
import { endCheckin } from '@/features/checkin/checkin';
|
||||
import { parseApiTime } from '@/api/parseTime';
|
||||
import { startPaidSession } from '@/features/session/activeParking';
|
||||
import { logLine } from '@/features/diagnostics/fileLogger';
|
||||
import type { RootStackParamList } from '@/navigation/RootNavigator';
|
||||
import {
|
||||
|
|
@ -107,18 +106,6 @@ export async function buildSingleLadder(base: {
|
|||
return { free: false, flat: false, ladder };
|
||||
}
|
||||
|
||||
/** Parse the API's "MM-DD-YYYY hh:mm AM" end-time string into a Date for reminders. */
|
||||
function parseApiTime(s?: string): Date | null {
|
||||
if (!s) return null;
|
||||
const m = s.match(/(\d{2})-(\d{2})-(\d{4})\s+(\d{1,2}):(\d{2})\s*(AM|PM)/i);
|
||||
if (!m) return null;
|
||||
let hr = parseInt(m[4], 10);
|
||||
const pm = /pm/i.test(m[6]);
|
||||
if (pm && hr !== 12) hr += 12;
|
||||
if (!pm && hr === 12) hr = 0;
|
||||
return new Date(+m[3], +m[1] - 1, +m[2], hr, +m[5]);
|
||||
}
|
||||
|
||||
export function StartSessionScreen() {
|
||||
const { colors } = useTheme();
|
||||
const navigation = useNavigation<Nav>();
|
||||
|
|
@ -275,18 +262,18 @@ export function StartSessionScreen() {
|
|||
minCreditAmount: zone.MinimumAmount,
|
||||
meterTypeId: zone.MeterTypeId!,
|
||||
});
|
||||
// Paid session supersedes any free check-in (they share the notification).
|
||||
await endCheckin();
|
||||
// Schedule the local expiry reminder from the purchased end time.
|
||||
// Becomes the one active parking session, replacing any free check-in (and,
|
||||
// when this purchase is an extension, the session it extends). That posts the
|
||||
// ongoing countdown notification and schedules the expiry reminder.
|
||||
const end = parseApiTime(selected.EndTime);
|
||||
if (end) {
|
||||
await scheduleExpiryReminder({
|
||||
transactionId: (res as any)?.TransactionID ?? Date.now(),
|
||||
zoneName: zone.ZoneName ?? 'Parking',
|
||||
await startPaidSession({
|
||||
zone,
|
||||
endTime: end,
|
||||
transactionId: (res as any)?.TransactionID,
|
||||
});
|
||||
// Ongoing "time left" status notification (glanceable countdown).
|
||||
await showSessionStatus({ zoneName: zone.ZoneName ?? 'Parking', endTime: end });
|
||||
} else {
|
||||
logLine(`[SESSION] no countdown: unparseable EndTime "${selected.EndTime}"`);
|
||||
}
|
||||
logLine(`[SESSION] start OK: ${JSON.stringify(res)}`);
|
||||
Alert.alert('Parked!', `Session started at ${zone.ZoneName}.`, [
|
||||
|
|
|
|||
152
app/test/geo.test.ts
Normal file
152
app/test/geo.test.ts
Normal file
|
|
@ -0,0 +1,152 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import {
|
||||
adjustGeometry,
|
||||
distanceMeters,
|
||||
distanceToGeometry,
|
||||
overlayAnchor,
|
||||
IDENTITY_OVERLAY,
|
||||
type AreaGeometry,
|
||||
type LonLat,
|
||||
} from '../src/features/citymap/geo';
|
||||
import bundled from '../src/features/citymap/parkingAreas.json';
|
||||
|
||||
/**
|
||||
* The city-map geometry, checked against the real bundled area set.
|
||||
*
|
||||
* This is the maths that decides which street you are tracking time on, so it is
|
||||
* tested against the actual 49 areas rather than toy shapes — the awkward cases
|
||||
* (an L-shaped run down two streets, a crescent-shaped beach lot) only exist in
|
||||
* the real data.
|
||||
*
|
||||
* Run with: npm test --workspace app
|
||||
*/
|
||||
|
||||
interface Area {
|
||||
id: string;
|
||||
shape: 'line' | 'polygon';
|
||||
geometry: AreaGeometry;
|
||||
}
|
||||
|
||||
const areas: Area[] = (bundled as any).features.map((f: any) => ({
|
||||
...f.properties,
|
||||
geometry: f.geometry,
|
||||
}));
|
||||
const geoms: AreaGeometry[] = areas.map((a) => a.geometry);
|
||||
|
||||
/**
|
||||
* Points that genuinely lie on an area: edge midpoints. A centroid is no good —
|
||||
* an L-shaped run's lands mid-block and the crescent City Beach lot's lands in
|
||||
* the water.
|
||||
*/
|
||||
function onGeometry(g: AreaGeometry): LonLat[] {
|
||||
const rings = g.type === 'Polygon' ? g.coordinates : [g.coordinates];
|
||||
const out: LonLat[] = [];
|
||||
for (const r of rings) {
|
||||
for (let i = 1; i < r.length; i++) {
|
||||
out.push([(r[i - 1][0] + r[i][0]) / 2, (r[i - 1][1] + r[i][1]) / 2]);
|
||||
}
|
||||
}
|
||||
return out;
|
||||
}
|
||||
|
||||
function nearest(p: LonLat): { area: Area; dist: number } {
|
||||
let best = areas[0];
|
||||
let bd = Infinity;
|
||||
for (const a of areas) {
|
||||
const d = distanceToGeometry(p, a.geometry);
|
||||
if (d < bd) {
|
||||
best = a;
|
||||
bd = d;
|
||||
}
|
||||
}
|
||||
return { area: best, dist: bd };
|
||||
}
|
||||
|
||||
test('the bundled map has the expected shape', () => {
|
||||
assert.equal(areas.length, 49);
|
||||
assert.ok(areas.some((a) => a.shape === 'polygon'), 'city lots should be polygons');
|
||||
assert.ok(areas.some((a) => a.shape === 'line'), 'on-street runs should be lines');
|
||||
});
|
||||
|
||||
test('a point on an area measures zero distance to it', () => {
|
||||
for (const a of areas) {
|
||||
for (const p of onGeometry(a.geometry)) {
|
||||
const d = distanceToGeometry(p, a.geometry);
|
||||
assert.ok(d < 0.01, `${a.id}: a point on it measured ${d.toFixed(3)} m away`);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('hit-testing resolves each area from points on it', () => {
|
||||
for (const a of areas) {
|
||||
for (const p of onGeometry(a.geometry)) {
|
||||
const hit = nearest(p);
|
||||
if (hit.area.id === a.id) continue;
|
||||
// Categories meet at intersections, so an exact tie is acceptable; silently
|
||||
// resolving to something FURTHER away is the bug this guards against.
|
||||
assert.ok(
|
||||
hit.dist < 0.01,
|
||||
`${a.id}: a point on it resolved to ${hit.area.id} at ${hit.dist.toFixed(2)} m`,
|
||||
);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('a point off the map does not snap to an area', () => {
|
||||
// East of the highway, across Sand Creek — no mapped parking anywhere near.
|
||||
assert.ok(nearest([-116.5445, 48.2705]).dist > 40);
|
||||
});
|
||||
|
||||
test('the identity overlay is a no-op', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
for (const g of geoms) assert.deepEqual(adjustGeometry(g, IDENTITY_OVERLAY, anchor), g);
|
||||
});
|
||||
|
||||
test('a shift moves every vertex by exactly that distance', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
for (const g of geoms) {
|
||||
const moved = adjustGeometry(g, { ...IDENTITY_OVERLAY, dxMeters: 10 }, anchor);
|
||||
const a = g.type === 'Polygon' ? g.coordinates[0] : g.coordinates;
|
||||
const b = moved.type === 'Polygon' ? moved.coordinates[0] : moved.coordinates;
|
||||
for (let i = 0; i < a.length; i++) {
|
||||
assert.ok(Math.abs(distanceMeters(a[i], b[i]) - 10) < 0.05, 'shift distance');
|
||||
assert.ok(b[i][0] > a[i][0], 'positive dxMeters must move east');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
test('rotation is rigid about the anchor and 360° returns home', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
const g = geoms.find((x) => x.type === 'LineString') as Extract<
|
||||
AreaGeometry,
|
||||
{ type: 'LineString' }
|
||||
>;
|
||||
|
||||
const spun = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 360 }, anchor) as typeof g;
|
||||
for (let i = 0; i < g.coordinates.length; i++) {
|
||||
assert.ok(distanceMeters(g.coordinates[i], spun.coordinates[i]) < 0.01, '360° round trip');
|
||||
}
|
||||
|
||||
const rot = adjustGeometry(g, { ...IDENTITY_OVERLAY, rotationDeg: 5 }, anchor) as typeof g;
|
||||
for (let i = 0; i < g.coordinates.length; i++) {
|
||||
const r0 = distanceMeters(anchor, g.coordinates[i]);
|
||||
const r1 = distanceMeters(anchor, rot.coordinates[i]);
|
||||
assert.ok(Math.abs(r0 - r1) < 0.5, `rotation changed radius ${r0.toFixed(1)} -> ${r1.toFixed(1)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('scale is anchored and proportional', () => {
|
||||
const anchor = overlayAnchor(geoms);
|
||||
const far = (geoms.find((x) => x.type === 'LineString') as any).coordinates[0] as LonLat;
|
||||
const scaled = adjustGeometry(
|
||||
{ type: 'LineString', coordinates: [anchor, far] },
|
||||
{ ...IDENTITY_OVERLAY, scale: 2 },
|
||||
anchor,
|
||||
) as Extract<AreaGeometry, { type: 'LineString' }>;
|
||||
|
||||
assert.ok(distanceMeters(scaled.coordinates[0], anchor) < 0.01, 'the anchor must not move');
|
||||
const before = distanceMeters(anchor, far);
|
||||
const after = distanceMeters(anchor, scaled.coordinates[1]);
|
||||
assert.ok(Math.abs(after - 2 * before) < 0.5, `×2: ${before.toFixed(1)} -> ${after.toFixed(1)}`);
|
||||
});
|
||||
504
package-lock.json
generated
504
package-lock.json
generated
|
|
@ -40,6 +40,7 @@
|
|||
"devDependencies": {
|
||||
"@types/react": "~19.0.0",
|
||||
"babel-plugin-module-resolver": "^5.0.2",
|
||||
"tsx": "^4.23.12",
|
||||
"typescript": "~5.4.0"
|
||||
}
|
||||
},
|
||||
|
|
@ -1599,6 +1600,448 @@
|
|||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/aix-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/aix-ppc64/-/aix-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"aix"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm/-/android-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-arm64/-/android-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-5YfKeeI8qWfBZIX+u2xZC3Zlb3Os/gLS2sbEKM+I4ZOcsWmHS2WLysCcQZDAFRslDUU5Oiq44gf6PYN1vGwG5A==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/android-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/android-x64/-/android-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-O387ite7SzUyCcy3JQX4P4bLtEA7bLLkx+esve5JHnyYfNTxcVpXZo9jhdB0lTKN44gztELTdU7nS8Nr16Fs1Q==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"android"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-arm64/-/darwin-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n4KqkOQrraxHJcgjM1RvwbigfQKIKJVpM7xp+KsxiyUSrRdIXnt73VhrPAx0fV44hgfmIVKjxMN9J1t5jySVkw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/darwin-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/darwin-x64/-/darwin-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-uq6suIWYP37qzGddBKPw5QEQPi6HiLGsO7UmkpfyaYNQ3D+rN6w6WfwH+nuqcGXWvawGwxOEroO4YGnFh95azw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"darwin"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-arm64/-/freebsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-n+I0BTSRIoy+d6RPKnEVwql5UwBJolytvY4mAOIEJorKlqgPII8ix6slVVrfZ5Tnj7glIZvloylbB/EJPMWEXw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/freebsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/freebsd-x64/-/freebsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-78XJTJkvPs0kz2w61301PJjXl4g7q3JqiYMZ/M/yVI73EHBrCRTgkhu9oqG7vPqq+a/yadEW8aD+agKlk5xrmg==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"freebsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm/-/linux-arm-0.28.2.tgz",
|
||||
"integrity": "sha512-XlDnu2q5yoqems+xay6wSAcg9DDD7K9RLKZEBOMZm3ckNpJBvOX20tSfby8KfrrhINDyv9V2YVZKY/SpoGJI8w==",
|
||||
"cpu": [
|
||||
"arm"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-arm64/-/linux-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-pW4AC0P3it8c7do9MVM4p51FzHzdM/TZrerurgRcHJ2WTa1VQ1CIq18xncfpBJw4ojkiZZrKW2yIBWBP92j6Ug==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ia32/-/linux-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-CYbnj78HsIeA+DhgUKgFCfvNsTHFhMMrinUrMZpDXJXKN8T3XViTZ/+wtHeVxEWY8ewSzTFN+nRmSwO2tZaLUQ==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-loong64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-loong64/-/linux-loong64-0.28.2.tgz",
|
||||
"integrity": "sha512-buwkd8nsph4R+ajRvw0qM5Hja/TXQow3ptzWO2EbG/cqcIkHloRrdlBtQlshyYGTNFvfkfJ5tpPLVkY4DtsPfQ==",
|
||||
"cpu": [
|
||||
"loong64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-mips64el": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-mips64el/-/linux-mips64el-0.28.2.tgz",
|
||||
"integrity": "sha512-ZVykbDyk7519VwiNb9Lcj9m8XM6v5V9uKPvrEMkkEedVewf+0itkhahp4HDpgERXhwLRpWFypsGbG/J8s0QjJA==",
|
||||
"cpu": [
|
||||
"mips64el"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-ppc64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-ppc64/-/linux-ppc64-0.28.2.tgz",
|
||||
"integrity": "sha512-CAXl+Dtd9UUuJd8pKKdwh6MLm3MUMiqMPmhZ3tTSXPqfyQ3vDl6R5hZdZ/kYojK4ofXtdfSv1tFq8XzWx3heNQ==",
|
||||
"cpu": [
|
||||
"ppc64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-riscv64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-riscv64/-/linux-riscv64-0.28.2.tgz",
|
||||
"integrity": "sha512-GeXCej4IQtU1B+QlDV8W/RRvbzI3O/Stss+/bCXv4lZls5WGRtu2a+3JkA3i4qIUlMXpcHebWpF8AkJhATowuA==",
|
||||
"cpu": [
|
||||
"riscv64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-s390x": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-s390x/-/linux-s390x-0.28.2.tgz",
|
||||
"integrity": "sha512-3H1weTYZPxt/WOhByszQZybS9w5lKzUn1FDMsgEChbHWQwHYQQRfBxgCcZvPhjHfKyJjIievvMmEUawJrdY9Dg==",
|
||||
"cpu": [
|
||||
"s390x"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/linux-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/linux-x64/-/linux-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-4xTZr1FUmSoQW4XIWmit3tzQrUTZM+N3P0XV8xROKYF50XfI7xeO90+1bZvNwxIufQ9hDQVRJH5YhgPVF8A/HQ==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"linux"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-arm64/-/netbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-sSATRjPeDBg3pdgHoQfoYBob11Kk1FGa9lui5RIHZCoCkJa9QKlvl3/vKz2usCmYYjs7ymJR/2Nnsqe+Hjt5nw==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/netbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/netbsd-x64/-/netbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-lqnzCV+mM0gIADaKihiCg6ifgfU2L3h5E33rNQBN1Y4MaVGnzryzmvvf7UHxprpQdE8hpqLolJ9Rl+SkIRDpyw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"netbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-arm64/-/openbsd-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-AL2qJILH7lNjrDmCQDvdxMfAUIv8KMNZOvrwAQ8i8//ntL9FflhOyMJ8OZSMBb8/AWXe3/5v5S20y3zCoZWKoQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openbsd-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openbsd-x64/-/openbsd-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-QtiuPytchRyC4rwUKhexJdQKvDuZ6hWloi3igqPQNUJCS1/v9EiO3UTOXR6A3FoMo4fnAKbWJdqaIwhOzh8qEw==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openbsd"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/openharmony-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/openharmony-arm64/-/openharmony-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-WkhYDmpTjLvGlScA1rwjRUmhl4k8oXR3cIbtqWmELgU/dFeHHlEllxDvdWcNJV9rbzCexB5vz8gtNewWLgCT7Q==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"openharmony"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/sunos-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/sunos-x64/-/sunos-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-GPMSkTOtMnv2U2F8gxe4Io6qmVs+YKyp832Etqqxr0hFngmXQ3rzwytelm3GIn7T4VviRUlf3sOgBOiTdvaf7g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"sunos"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-arm64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-arm64/-/win32-arm64-0.28.2.tgz",
|
||||
"integrity": "sha512-PIhhEkE9uPBleRBrQEJpUn7MBnibZzbGzYWPmY3x+YoVg/95zbjB4CxPPOQ8l5tYYM4mMaCthF8/1DIfBQQyWQ==",
|
||||
"cpu": [
|
||||
"arm64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-ia32": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-ia32/-/win32-ia32-0.28.2.tgz",
|
||||
"integrity": "sha512-YmJbfTlvU7Sdn9BB+4PRES4oB6pxgS37MAONj+hBr/cpXS1aBPKXxNnDbu+QCWPj0o9dgyxeq79g6c5P8KeuYA==",
|
||||
"cpu": [
|
||||
"ia32"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@esbuild/win32-x64": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/@esbuild/win32-x64/-/win32-x64-0.28.2.tgz",
|
||||
"integrity": "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g==",
|
||||
"cpu": [
|
||||
"x64"
|
||||
],
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"optional": true,
|
||||
"os": [
|
||||
"win32"
|
||||
],
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/@expo/cli": {
|
||||
"version": "0.24.24",
|
||||
"resolved": "https://registry.npmjs.org/@expo/cli/-/cli-0.24.24.tgz",
|
||||
|
|
@ -4502,6 +4945,48 @@
|
|||
"node": ">= 0.4"
|
||||
}
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
"version": "0.28.2",
|
||||
"resolved": "https://registry.npmjs.org/esbuild/-/esbuild-0.28.2.tgz",
|
||||
"integrity": "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA==",
|
||||
"dev": true,
|
||||
"hasInstallScript": true,
|
||||
"license": "MIT",
|
||||
"bin": {
|
||||
"esbuild": "bin/esbuild"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"@esbuild/aix-ppc64": "0.28.2",
|
||||
"@esbuild/android-arm": "0.28.2",
|
||||
"@esbuild/android-arm64": "0.28.2",
|
||||
"@esbuild/android-x64": "0.28.2",
|
||||
"@esbuild/darwin-arm64": "0.28.2",
|
||||
"@esbuild/darwin-x64": "0.28.2",
|
||||
"@esbuild/freebsd-arm64": "0.28.2",
|
||||
"@esbuild/freebsd-x64": "0.28.2",
|
||||
"@esbuild/linux-arm": "0.28.2",
|
||||
"@esbuild/linux-arm64": "0.28.2",
|
||||
"@esbuild/linux-ia32": "0.28.2",
|
||||
"@esbuild/linux-loong64": "0.28.2",
|
||||
"@esbuild/linux-mips64el": "0.28.2",
|
||||
"@esbuild/linux-ppc64": "0.28.2",
|
||||
"@esbuild/linux-riscv64": "0.28.2",
|
||||
"@esbuild/linux-s390x": "0.28.2",
|
||||
"@esbuild/linux-x64": "0.28.2",
|
||||
"@esbuild/netbsd-arm64": "0.28.2",
|
||||
"@esbuild/netbsd-x64": "0.28.2",
|
||||
"@esbuild/openbsd-arm64": "0.28.2",
|
||||
"@esbuild/openbsd-x64": "0.28.2",
|
||||
"@esbuild/openharmony-arm64": "0.28.2",
|
||||
"@esbuild/sunos-x64": "0.28.2",
|
||||
"@esbuild/win32-arm64": "0.28.2",
|
||||
"@esbuild/win32-ia32": "0.28.2",
|
||||
"@esbuild/win32-x64": "0.28.2"
|
||||
}
|
||||
},
|
||||
"node_modules/escalade": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/escalade/-/escalade-3.2.0.tgz",
|
||||
|
|
@ -9151,6 +9636,25 @@
|
|||
"integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==",
|
||||
"license": "0BSD"
|
||||
},
|
||||
"node_modules/tsx": {
|
||||
"version": "4.23.12",
|
||||
"resolved": "https://registry.npmjs.org/tsx/-/tsx-4.23.12.tgz",
|
||||
"integrity": "sha512-FDf4L4sYzKtzWYhU/Xm0AQFdTjdIxNo9ElTf2mxXM6k8YMHXzYUe4yODVaXP4V9uMFbVg8c0qyBccK2OOxb45Q==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"esbuild": "~0.28.0"
|
||||
},
|
||||
"bin": {
|
||||
"tsx": "dist/cli.mjs"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"optionalDependencies": {
|
||||
"fsevents": "~2.3.3"
|
||||
}
|
||||
},
|
||||
"node_modules/type-detect": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/type-detect/-/type-detect-4.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -1,6 +1,14 @@
|
|||
import Fastify, { type FastifyReply, type FastifyRequest } from 'fastify';
|
||||
import rateLimit from '@fastify/rate-limit';
|
||||
import { LabelDb, LABEL_KINDS, isLabelKind, type ZoneLabel } from './db.js';
|
||||
import {
|
||||
LabelDb,
|
||||
LABEL_KINDS,
|
||||
isLabelKind,
|
||||
isAreaKind,
|
||||
AREA_KINDS,
|
||||
type ZoneLabel,
|
||||
type ParkingArea,
|
||||
} from './db.js';
|
||||
import { AbuseGuard, safeEqual } from './auth.js';
|
||||
|
||||
export interface BuildOptions {
|
||||
|
|
@ -143,6 +151,81 @@ export async function buildApp(opts: BuildOptions) {
|
|||
},
|
||||
);
|
||||
|
||||
// ---- City parking-map areas ---------------------------------------------
|
||||
// The colour-coded areas from the city's printed Downtown & Waterfront parking
|
||||
// map, georeferenced. Purely local geography: none of this touches ParkSmarter,
|
||||
// which is the point — tracking time on these spots must work with no IPS call.
|
||||
|
||||
const num = (v: unknown, fallback: number): number => {
|
||||
const n = Number(v);
|
||||
return Number.isFinite(n) ? n : fallback;
|
||||
};
|
||||
|
||||
app.get('/api/areas', async () => ({
|
||||
areas: db.allAreas(),
|
||||
overlay: db.getOverlay(),
|
||||
count: db.areaCount(),
|
||||
updatedAt: db.areasUpdatedAt(),
|
||||
}));
|
||||
|
||||
// Replace-all, not upsert: the areas come from one source document, so a partial
|
||||
// update would leave a map matching neither the old print nor the new one.
|
||||
app.put(
|
||||
'/api/areas',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: { areas?: unknown[] } }>, reply) => {
|
||||
const input = Array.isArray(req.body?.areas) ? req.body!.areas : null;
|
||||
if (!input) return reply.code(400).send({ error: 'areas_required' });
|
||||
|
||||
const areas: ParkingArea[] = [];
|
||||
for (const raw of input as Array<Record<string, unknown>>) {
|
||||
if (raw == null || raw.id == null) continue;
|
||||
if (!isAreaKind(raw.kind)) {
|
||||
return reply.code(400).send({ error: 'bad_kind', id: raw.id, allowed: AREA_KINDS });
|
||||
}
|
||||
const g = raw.geometry as { type?: unknown } | null;
|
||||
if (!g || (g.type !== 'LineString' && g.type !== 'Polygon')) {
|
||||
return reply.code(400).send({ error: 'bad_geometry', id: raw.id });
|
||||
}
|
||||
areas.push({
|
||||
id: String(raw.id),
|
||||
kind: raw.kind,
|
||||
name: String(raw.name ?? raw.id),
|
||||
label: String(raw.label ?? raw.kind),
|
||||
legend: String(raw.legend ?? ''),
|
||||
hours: num(raw.hours, 0),
|
||||
color: String(raw.color ?? '#888888'),
|
||||
shape: g.type === 'Polygon' ? 'polygon' : 'line',
|
||||
geometry: g,
|
||||
// Absent means "let the client decide by category"; only an explicit
|
||||
// boolean overrides a specific lot.
|
||||
requiresAccount: typeof raw.requiresAccount === 'boolean' ? raw.requiresAccount : null,
|
||||
});
|
||||
}
|
||||
return { replaced: db.replaceAreas(areas), total: db.areaCount() };
|
||||
},
|
||||
);
|
||||
|
||||
// Whole-overlay alignment correction, set from the phone against a live GPS fix.
|
||||
app.put(
|
||||
'/api/areas/overlay',
|
||||
{ preHandler: requireAdmin, ...writeLimit },
|
||||
async (req: FastifyRequest<{ Body: Record<string, unknown> }>, reply) => {
|
||||
const b = req.body ?? {};
|
||||
const scale = num(b.scale, 1);
|
||||
if (scale <= 0.5 || scale >= 2) {
|
||||
// A fit that needs more than a ±2x correction is a broken fit, not a nudge.
|
||||
return reply.code(400).send({ error: 'scale_out_of_range' });
|
||||
}
|
||||
return db.setOverlay({
|
||||
dxMeters: num(b.dxMeters, 0),
|
||||
dyMeters: num(b.dyMeters, 0),
|
||||
scale,
|
||||
rotationDeg: num(b.rotationDeg, 0),
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
app.addHook('onClose', async () => db.close());
|
||||
return app;
|
||||
}
|
||||
|
|
|
|||
175
server/src/db.ts
175
server/src/db.ts
|
|
@ -9,6 +9,62 @@ export function isLabelKind(v: unknown): v is LabelKind {
|
|||
return typeof v === 'string' && (LABEL_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/** The five categories on the city's printed Downtown & Waterfront parking map. */
|
||||
export type AreaKind = 'green_lot' | 'free_2h' | 'limit_3h' | 'limit_4h' | 'no_limit';
|
||||
export const AREA_KINDS: AreaKind[] = ['green_lot', 'free_2h', 'limit_3h', 'limit_4h', 'no_limit'];
|
||||
|
||||
export function isAreaKind(v: unknown): v is AreaKind {
|
||||
return typeof v === 'string' && (AREA_KINDS as string[]).includes(v);
|
||||
}
|
||||
|
||||
/**
|
||||
* One coloured area from the city map: an on-street segment (LineString) or a
|
||||
* city lot (Polygon). These are city geography, unrelated to ParkSmarter zones —
|
||||
* nothing here ever reaches the IPS API.
|
||||
*/
|
||||
export interface ParkingArea {
|
||||
id: string;
|
||||
kind: AreaKind;
|
||||
name: string;
|
||||
label: string;
|
||||
legend: string;
|
||||
hours: number;
|
||||
color: string;
|
||||
shape: 'line' | 'polygon';
|
||||
/** GeoJSON geometry (LineString or Polygon), lon/lat. */
|
||||
geometry: unknown;
|
||||
/**
|
||||
* Overrides the client's by-category default (paid city lots need a
|
||||
* ParkSmarter account, free time-limited streets don't). Null means "use the
|
||||
* default"; set it only to correct a specific lot.
|
||||
*/
|
||||
requiresAccount: boolean | null;
|
||||
}
|
||||
|
||||
/**
|
||||
* A whole-overlay correction. The map was georeferenced by fitting it to OSM, which
|
||||
* is good to a few metres but not perfect; this lets the alignment be nudged from the
|
||||
* phone against a live GPS fix and persisted, with no app release.
|
||||
*
|
||||
* Offsets are ground metres (east/north); scale and rotation apply about the
|
||||
* overlay's own centroid.
|
||||
*/
|
||||
export interface OverlayAdjust {
|
||||
dxMeters: number;
|
||||
dyMeters: number;
|
||||
scale: number;
|
||||
rotationDeg: number;
|
||||
updatedAt: number;
|
||||
}
|
||||
|
||||
export const IDENTITY_OVERLAY: OverlayAdjust = {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
};
|
||||
|
||||
export interface ZoneLabel {
|
||||
zoneId: string;
|
||||
customerId: string | null;
|
||||
|
|
@ -70,7 +126,126 @@ export class LabelDb {
|
|||
data TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS parking_areas (
|
||||
id TEXT PRIMARY KEY,
|
||||
kind TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL,
|
||||
hours REAL NOT NULL,
|
||||
color TEXT NOT NULL,
|
||||
shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
CREATE TABLE IF NOT EXISTS map_overlay (
|
||||
id TEXT PRIMARY KEY,
|
||||
dx_meters REAL NOT NULL,
|
||||
dy_meters REAL NOT NULL,
|
||||
scale REAL NOT NULL,
|
||||
rotation REAL NOT NULL,
|
||||
updated_at INTEGER NOT NULL
|
||||
);
|
||||
`);
|
||||
|
||||
// CREATE TABLE IF NOT EXISTS won't add a column to a table that already
|
||||
// exists, so added columns need an explicit migration.
|
||||
this.addColumnIfMissing('parking_areas', 'requires_account', 'INTEGER');
|
||||
}
|
||||
|
||||
private addColumnIfMissing(table: string, column: string, type: string): void {
|
||||
const cols = this.db.prepare(`PRAGMA table_info(${table})`).all() as Array<{ name: string }>;
|
||||
if (cols.some((c) => c.name === column)) return;
|
||||
this.db.exec(`ALTER TABLE ${table} ADD COLUMN ${column} ${type}`);
|
||||
}
|
||||
|
||||
/* ------------------------------------------------ city parking-map areas */
|
||||
|
||||
allAreas(): ParkingArea[] {
|
||||
const rows = this.db
|
||||
.prepare('SELECT * FROM parking_areas ORDER BY id')
|
||||
.all() as Array<Record<string, any>>;
|
||||
return rows.map((r) => ({
|
||||
id: r.id,
|
||||
kind: r.kind as AreaKind,
|
||||
name: r.name,
|
||||
label: r.label,
|
||||
legend: r.legend,
|
||||
hours: r.hours,
|
||||
color: r.color,
|
||||
shape: r.shape as 'line' | 'polygon',
|
||||
geometry: JSON.parse(r.geometry),
|
||||
// SQLite has no boolean; null stays null so the client applies its default.
|
||||
requiresAccount: r.requires_account == null ? null : !!r.requires_account,
|
||||
}));
|
||||
}
|
||||
|
||||
areaCount(): number {
|
||||
return (this.db.prepare('SELECT COUNT(*) AS n FROM parking_areas').get() as { n: number }).n;
|
||||
}
|
||||
|
||||
/** Newest updated_at across areas — the app uses it to skip redundant refreshes. */
|
||||
areasUpdatedAt(): number {
|
||||
const r = this.db.prepare('SELECT MAX(updated_at) AS t FROM parking_areas').get() as {
|
||||
t: number | null;
|
||||
};
|
||||
return r.t ?? 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Replace the whole area set in one transaction. The areas come from a single
|
||||
* source document, so a partial update would leave a map that matches neither
|
||||
* the old print nor the new one.
|
||||
*/
|
||||
replaceAreas(areas: ParkingArea[]): number {
|
||||
const now = Date.now();
|
||||
const insert = this.db.prepare(
|
||||
`INSERT INTO parking_areas
|
||||
(id, kind, name, label, legend, hours, color, shape, geometry, requires_account, updated_at)
|
||||
VALUES
|
||||
(@id, @kind, @name, @label, @legend, @hours, @color, @shape, @geometry, @requiresAccount, @updatedAt)`,
|
||||
);
|
||||
this.db.transaction((items: ParkingArea[]) => {
|
||||
this.db.prepare('DELETE FROM parking_areas').run();
|
||||
for (const a of items) {
|
||||
insert.run({
|
||||
...a,
|
||||
geometry: JSON.stringify(a.geometry),
|
||||
requiresAccount: a.requiresAccount == null ? null : a.requiresAccount ? 1 : 0,
|
||||
updatedAt: now,
|
||||
});
|
||||
}
|
||||
})(areas);
|
||||
return areas.length;
|
||||
}
|
||||
|
||||
getOverlay(): OverlayAdjust {
|
||||
const r = this.db.prepare("SELECT * FROM map_overlay WHERE id = 'default'").get() as
|
||||
| Record<string, any>
|
||||
| undefined;
|
||||
if (!r) return IDENTITY_OVERLAY;
|
||||
return {
|
||||
dxMeters: r.dx_meters,
|
||||
dyMeters: r.dy_meters,
|
||||
scale: r.scale,
|
||||
rotationDeg: r.rotation,
|
||||
updatedAt: r.updated_at,
|
||||
};
|
||||
}
|
||||
|
||||
setOverlay(o: Omit<OverlayAdjust, 'updatedAt'>): OverlayAdjust {
|
||||
const updatedAt = Date.now();
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO map_overlay (id, dx_meters, dy_meters, scale, rotation, updated_at)
|
||||
VALUES ('default', ?, ?, ?, ?, ?)
|
||||
ON CONFLICT(id) DO UPDATE SET
|
||||
dx_meters = excluded.dx_meters, dy_meters = excluded.dy_meters,
|
||||
scale = excluded.scale, rotation = excluded.rotation,
|
||||
updated_at = excluded.updated_at`,
|
||||
)
|
||||
.run(o.dxMeters, o.dyMeters, o.scale, o.rotationDeg, updatedAt);
|
||||
return { ...o, updatedAt };
|
||||
}
|
||||
|
||||
/** Upsert mirrored parking areas (full Zone JSON in `data`). Returns count. */
|
||||
|
|
|
|||
248
server/test/areas.test.ts
Normal file
248
server/test/areas.test.ts
Normal file
|
|
@ -0,0 +1,248 @@
|
|||
import { test } from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { buildApp, type BuildOptions } from '../src/app.ts';
|
||||
|
||||
const TOKEN = 'test-admin-token-0123456789';
|
||||
const auth = { authorization: `Bearer ${TOKEN}` };
|
||||
|
||||
const make = (o: Partial<BuildOptions> = {}) =>
|
||||
buildApp({ adminToken: TOKEN, dbPath: ':memory:', ...o });
|
||||
|
||||
const line = (id: string, kind = 'free_2h') => ({
|
||||
id,
|
||||
kind,
|
||||
name: `${id} name`,
|
||||
label: '2-hour free',
|
||||
legend: 'Permits not valid',
|
||||
hours: 2,
|
||||
color: '#d367cc',
|
||||
geometry: {
|
||||
type: 'LineString',
|
||||
coordinates: [
|
||||
[-116.5535, 48.2766],
|
||||
[-116.5525, 48.2766],
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
test('areas are public to read and admin-only to replace', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.deepEqual(r.json().areas, []);
|
||||
assert.equal(r.json().count, 0);
|
||||
|
||||
r = await app.inject({ method: 'PUT', url: '/api/areas', payload: { areas: [line('sp-001')] } });
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002', 'limit_3h')] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 2);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
const areas = r.json().areas;
|
||||
assert.equal(areas.length, 2);
|
||||
assert.equal(areas[0].id, 'sp-001');
|
||||
assert.equal(areas[0].kind, 'free_2h');
|
||||
assert.equal(areas[0].shape, 'line');
|
||||
// Geometry survives the round-trip as real GeoJSON, not a string.
|
||||
assert.deepEqual(areas[0].geometry.coordinates[0], [-116.5535, 48.2766]);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('replace is wholesale — stale areas do not survive', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-001'), line('sp-002')] },
|
||||
});
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [line('sp-003')] },
|
||||
});
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-003');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('polygons are accepted; bad kinds and geometries are rejected', async () => {
|
||||
const app = await make();
|
||||
|
||||
const lot = {
|
||||
id: 'sp-022',
|
||||
kind: 'green_lot',
|
||||
name: 'Lot off Oak St',
|
||||
label: 'City lot',
|
||||
legend: 'Paid hourly or permit',
|
||||
hours: 2,
|
||||
color: '#75b259',
|
||||
geometry: {
|
||||
type: 'Polygon',
|
||||
coordinates: [
|
||||
[
|
||||
[-116.554, 48.2766],
|
||||
[-116.553, 48.2766],
|
||||
[-116.553, 48.2772],
|
||||
[-116.554, 48.2766],
|
||||
],
|
||||
],
|
||||
},
|
||||
};
|
||||
let r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [lot] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().replaced, 1);
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().areas[0].shape, 'polygon');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), kind: 'free_9h' }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_kind');
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), geometry: { type: 'Point', coordinates: [0, 0] } }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 400);
|
||||
assert.equal(r.json().error, 'bad_geometry');
|
||||
|
||||
// A rejected batch must not have clobbered the good one.
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().count, 1);
|
||||
assert.equal(r.json().areas[0].id, 'sp-022');
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('requiresAccount is null unless explicitly overridden', async () => {
|
||||
const app = await make();
|
||||
|
||||
await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: {
|
||||
areas: [
|
||||
line('sp-001'), // no override -> client decides by category
|
||||
{ ...line('sp-002', 'green_lot'), requiresAccount: false }, // a lot that needs no account
|
||||
{ ...line('sp-003'), requiresAccount: true },
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
const by = Object.fromEntries(areas.map((a: any) => [a.id, a.requiresAccount]));
|
||||
assert.equal(by['sp-001'], null, 'no override should stay null, not become false');
|
||||
assert.equal(by['sp-002'], false);
|
||||
assert.equal(by['sp-003'], true);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('a database created before requires_account existed still works', async () => {
|
||||
// Simulate an older deployment: build the table without the column, then let
|
||||
// the migration add it. CREATE TABLE IF NOT EXISTS alone would not.
|
||||
const Database = (await import('better-sqlite3')).default;
|
||||
const file = `/tmp/bbp-migrate-${process.pid}.db`;
|
||||
const raw = new Database(file);
|
||||
raw.exec(`CREATE TABLE parking_areas (
|
||||
id TEXT PRIMARY KEY, kind TEXT NOT NULL, name TEXT NOT NULL, label TEXT NOT NULL,
|
||||
legend TEXT NOT NULL, hours REAL NOT NULL, color TEXT NOT NULL, shape TEXT NOT NULL,
|
||||
geometry TEXT NOT NULL, updated_at INTEGER NOT NULL)`);
|
||||
raw.close();
|
||||
|
||||
const app = await make({ dbPath: file });
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas',
|
||||
headers: auth,
|
||||
payload: { areas: [{ ...line('sp-009'), requiresAccount: true }] },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
const areas = (await app.inject({ method: 'GET', url: '/api/areas' })).json().areas;
|
||||
assert.equal(areas[0].requiresAccount, true);
|
||||
|
||||
await app.close();
|
||||
(await import('node:fs')).rmSync(file, { force: true });
|
||||
});
|
||||
|
||||
test('overlay defaults to identity and round-trips', async () => {
|
||||
const app = await make();
|
||||
|
||||
let r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.deepEqual(r.json().overlay, {
|
||||
dxMeters: 0,
|
||||
dyMeters: 0,
|
||||
scale: 1,
|
||||
rotationDeg: 0,
|
||||
updatedAt: 0,
|
||||
});
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
payload: { dxMeters: 3 },
|
||||
});
|
||||
assert.equal(r.statusCode, 401);
|
||||
|
||||
r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { dxMeters: 3.5, dyMeters: -2, scale: 1.01, rotationDeg: 0.4 },
|
||||
});
|
||||
assert.equal(r.statusCode, 200);
|
||||
assert.equal(r.json().dxMeters, 3.5);
|
||||
assert.ok(r.json().updatedAt > 0);
|
||||
|
||||
r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.dyMeters, -2);
|
||||
assert.equal(r.json().overlay.rotationDeg, 0.4);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
|
||||
test('an absurd overlay scale is refused rather than stored', async () => {
|
||||
const app = await make();
|
||||
|
||||
for (const scale of [0, 0.4, 2, 5]) {
|
||||
const r = await app.inject({
|
||||
method: 'PUT',
|
||||
url: '/api/areas/overlay',
|
||||
headers: auth,
|
||||
payload: { scale },
|
||||
});
|
||||
assert.equal(r.statusCode, 400, `scale ${scale} should be refused`);
|
||||
}
|
||||
|
||||
const r = await app.inject({ method: 'GET', url: '/api/areas' });
|
||||
assert.equal(r.json().overlay.scale, 1);
|
||||
|
||||
await app.close();
|
||||
});
|
||||
72
tools/citymap/README.md
Normal file
72
tools/citymap/README.md
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
# Georeferencing the city parking map
|
||||
|
||||
Turns the City of Sandpoint's printed **Downtown & Waterfront Public Parking** PDF into
|
||||
`app/src/features/citymap/parkingAreas.json` — the colour-coded overlay the app draws and
|
||||
hit-tests against.
|
||||
|
||||
Run this again when the city publishes a new edition of the map.
|
||||
|
||||
## Why it needs doing at all
|
||||
|
||||
The PDF carries **no** georeferencing metadata (no `/Measure`, `/GPTS`, `/LPTS`, `/GEO`,
|
||||
`/Viewport`, `/GCS`). It is a north-up Web Mercator screenshot of a slippy map with vector
|
||||
parking stripes drawn on top, so page coordinates relate to the world by a plain affine
|
||||
transform — which has to be recovered by fitting the drawing to something we already know
|
||||
the coordinates of. That something is OpenStreetMap's street centrelines.
|
||||
|
||||
Two structural details cost the most time, so they are worth knowing up front:
|
||||
|
||||
- `pdftocairo` writes each **stroked** street segment with its own `matrix()` transform and
|
||||
*local* coordinates, while **filled** lots are in absolute page coordinates. Both have to
|
||||
be handled or the streets land in a heap near the origin.
|
||||
- The legend swatches are drawn in the same five colours as the real geometry. They are
|
||||
identified by stroke-width 7 inside the legend card's x-band and dropped.
|
||||
|
||||
## Pipeline
|
||||
|
||||
```bash
|
||||
# 0. deps: poppler-utils (pdftocairo, pdftotext, pdfimages), python3, curl
|
||||
pdftocairo -svg downtown_and_waterfront_public_parking_map.pdf map.svg
|
||||
|
||||
# 1. vector geometry -> page coordinates, bucketed by the legend's five colours
|
||||
python3 extract_map.py map.svg map_page_coords.json
|
||||
|
||||
# 2. OSM street centrelines for downtown Sandpoint
|
||||
curl -s --data-binary @roads.overpass https://overpass-api.de/api/interpreter -o osm.json
|
||||
|
||||
# 3. fit page -> Web Mercator against named streets; prints per-street residuals
|
||||
python3 georef.py # writes fit_raw.json
|
||||
|
||||
# 4. apply the fit, name each area from OSM, verify, emit the GeoJSON
|
||||
python3 build_geojson.py # writes parking_areas.geojson
|
||||
|
||||
cp parking_areas.geojson ../../app/src/features/citymap/parkingAreas.json
|
||||
```
|
||||
|
||||
## What "good" looks like
|
||||
|
||||
`georef.py` prints a residual per control street and `build_geojson.py` prints how far each
|
||||
on-street segment sits from the nearest OSM road. The current edition fits to:
|
||||
|
||||
| Check | Result |
|
||||
| --- | --- |
|
||||
| X control residual (avenues) | **RMS 4.1 m** |
|
||||
| Y control residual (streets) | **RMS 3.5 m** |
|
||||
| On-street segments vs nearest OSM road | **mean 4.0 m**, 40 of 42 under 10 m |
|
||||
|
||||
The two segments over 10 m (`sp-039`, `sp-040`) are correct, not errors: they are angled
|
||||
bays along the old rail corridor that sit on no named road at all — the nearest way is a
|
||||
service alley 19 m off. Anything much worse than the table above means a control street was
|
||||
mis-identified; `georef.py`'s per-street residuals will say which.
|
||||
|
||||
Residual error is also correctable after the fact without re-running any of this — the app's
|
||||
**Account → Align city map** screen nudges the whole overlay against a live GPS fix and
|
||||
persists the correction.
|
||||
|
||||
## Control points
|
||||
|
||||
`georef.py` maps page grid lines to OSM street names by hand (`AVENUES` / `STREETS`). Sandpoint's
|
||||
grid **jogs** between its north and south halves — North 2nd Ave and South 2nd Ave are 38 m
|
||||
apart — so each control street is measured only over the span its page segment actually
|
||||
covers, and both halves are used as independent control points. That jog is a useful sanity
|
||||
check: the page shows the same 9.3 pt offset, which at the fitted scale is 38 m.
|
||||
190
tools/citymap/build_geojson.py
Normal file
190
tools/citymap/build_geojson.py
Normal file
|
|
@ -0,0 +1,190 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Apply the fitted transform and emit the final parking-areas GeoJSON.
|
||||
|
||||
Also verifies the result the only way that matters: every stroked segment should
|
||||
land on an actual road, so measure each one's distance to the nearest OSM road
|
||||
centreline. Lots are skipped in that check — they are off-street by definition.
|
||||
|
||||
Each feature gets a human name from OSM (the street it runs along, plus the two
|
||||
cross streets it lies between) so the app can list areas without the map.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
COS = math.cos(math.radians(48.278))
|
||||
|
||||
fit = json.load(open("fit_raw.json"))
|
||||
SX, TX, SY, TY = fit["sx"], fit["tx"], fit["sy"], fit["ty"]
|
||||
|
||||
|
||||
def to_merc(x, y):
|
||||
return (SX * x + TX, SY * y + TY)
|
||||
|
||||
|
||||
def to_lonlat(x, y):
|
||||
X, Y = to_merc(x, y)
|
||||
return (round(math.degrees(X / R), 7), round(math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), 7))
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def dist_to_seg(p, a, b):
|
||||
px, py = p
|
||||
ax, ay = a
|
||||
bx, by = b
|
||||
dx, dy = bx - ax, by - ay
|
||||
L = dx * dx + dy * dy
|
||||
t = 0.0 if L == 0 else max(0.0, min(1.0, ((px - ax) * dx + (py - ay) * dy) / L))
|
||||
return math.hypot(px - (ax + t * dx), py - (ay + t * dy))
|
||||
|
||||
|
||||
# ---------------------------------------------------------------- OSM roads
|
||||
osm = json.load(open("osm.json"))
|
||||
SKIP = {"footway", "path", "cycleway", "steps", "track", "service"}
|
||||
roads = [] # (a, b, name) in mercator
|
||||
for w in osm["elements"]:
|
||||
t = w.get("tags", {})
|
||||
if t.get("highway") in SKIP or "geometry" not in w:
|
||||
continue
|
||||
g = [merc(p["lat"], p["lon"]) for p in w["geometry"]]
|
||||
nm = t.get("name")
|
||||
for a, b in zip(g, g[1:]):
|
||||
roads.append((a, b, nm))
|
||||
named = [r for r in roads if r[2]]
|
||||
|
||||
SHORT = [
|
||||
("North ", "N "), ("South ", "S "), ("East ", "E "), ("West ", "W "),
|
||||
(" Street", " St"), (" Avenue", " Ave"), (" Boulevard", " Blvd"),
|
||||
(" Road", " Rd"), (" Drive", " Dr"), (" Lane", " Ln"), (" Bridge", " Brg"),
|
||||
]
|
||||
|
||||
|
||||
def short(n):
|
||||
for a, b in SHORT:
|
||||
n = n.replace(a, b)
|
||||
return n
|
||||
|
||||
|
||||
def nearest_name(p, exclude=None, limit=60.0):
|
||||
best, bestd = None, limit
|
||||
for a, b, nm in named:
|
||||
if nm == exclude:
|
||||
continue
|
||||
d = dist_to_seg(p, a, b)
|
||||
if d < bestd:
|
||||
best, bestd = nm, d
|
||||
return best
|
||||
|
||||
|
||||
def describe(pts_merc, is_line):
|
||||
"""'N 3rd Ave · Cedar St to Oak St' for a segment, or the nearest road for a lot."""
|
||||
mid = pts_merc[len(pts_merc) // 2]
|
||||
on = nearest_name(mid) if is_line else None
|
||||
if not is_line:
|
||||
near = nearest_name(mid, limit=200.0)
|
||||
return f"Lot off {short(near)}" if near else "City lot"
|
||||
ends = [pts_merc[0], pts_merc[-1]]
|
||||
cross = []
|
||||
for e in ends:
|
||||
c = nearest_name(e, exclude=on, limit=45.0)
|
||||
if c and short(c) not in cross:
|
||||
cross.append(short(c))
|
||||
if not on:
|
||||
# Bays along the old rail corridor sit on no named road — describe them
|
||||
# by what they are near rather than inventing a street.
|
||||
near = nearest_name(mid, limit=150.0)
|
||||
return f"Off-street bays near {short(near)}" if near else "Off-street bays"
|
||||
base = short(on)
|
||||
if len(cross) == 2:
|
||||
return f"{base} · {cross[0]} to {cross[1]}"
|
||||
if len(cross) == 1:
|
||||
return f"{base} · at {cross[0]}"
|
||||
return base
|
||||
|
||||
|
||||
# --------------------------------------------------------------- build output
|
||||
page = json.load(open("map_page_coords.json"))
|
||||
|
||||
|
||||
def is_legend(f):
|
||||
"""The five legend swatches: stroke-width 7 sitting in the legend card's x-band."""
|
||||
x0, y0, x1, y1 = f["bbox"]
|
||||
return f["strokeWidth"] > 5 and 25 < x0 < 28 and 60 < y0 < 130
|
||||
|
||||
|
||||
# kind -> (short label, legend text, default tracked hours, colour)
|
||||
KINDS = {
|
||||
"green_lot": ("City lot", "Paid hourly or permit", 2, "#75b259"),
|
||||
"free_2h": ("2-hour free", "Permits not valid", 2, "#d367cc"),
|
||||
"limit_3h": ("3-hour", "3-hour or permit", 3, "#ccc542"),
|
||||
"limit_4h": ("4-hour", "4-hour or permit", 4, "#f78b08"),
|
||||
"no_limit": ("No time limit", "No posted time limit", 0, "#c3c4c2"),
|
||||
}
|
||||
|
||||
features = []
|
||||
n_legend = 0
|
||||
for i, f in enumerate(page["features"]):
|
||||
if is_legend(f):
|
||||
n_legend += 1
|
||||
continue
|
||||
is_line = f["geom"] == "line"
|
||||
pm = [to_merc(x, y) for x, y in f["points"]]
|
||||
coords = [to_lonlat(x, y) for x, y in f["points"]]
|
||||
if is_line:
|
||||
geom = {"type": "LineString", "coordinates": coords}
|
||||
else:
|
||||
if coords[0] != coords[-1]:
|
||||
coords.append(coords[0])
|
||||
geom = {"type": "Polygon", "coordinates": [coords]}
|
||||
label, legend, hours, color = KINDS[f["kind"]]
|
||||
features.append(
|
||||
{
|
||||
"type": "Feature",
|
||||
"id": f"sp-{i:03d}",
|
||||
"geometry": geom,
|
||||
"properties": {
|
||||
"id": f"sp-{i:03d}",
|
||||
"kind": f["kind"],
|
||||
"label": label,
|
||||
"legend": legend,
|
||||
"hours": hours,
|
||||
"color": color,
|
||||
"shape": "line" if is_line else "polygon",
|
||||
"name": describe(pm, is_line),
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
fc = {
|
||||
"type": "FeatureCollection",
|
||||
"features": features,
|
||||
"metadata": {
|
||||
"source": "City of Sandpoint — Downtown & Waterfront Public Parking map",
|
||||
"generated": "from downtown_and_waterfront_public_parking_map.pdf",
|
||||
"georeference": "affine page->WebMercator fitted to OSM street centrelines",
|
||||
},
|
||||
}
|
||||
json.dump(fc, open("parking_areas.geojson", "w"), indent=1)
|
||||
print(f"{len(features)} features written ({n_legend} legend swatches dropped)\n")
|
||||
for f in features:
|
||||
p = f["properties"]
|
||||
print(f" {p['id']} {p['kind']:10s} {p['shape']:7s} {p['name']}")
|
||||
|
||||
# ---- verification: distance from each on-street segment to the nearest road
|
||||
worst = []
|
||||
for f in features:
|
||||
if f["properties"]["shape"] != "line":
|
||||
continue
|
||||
ds = [min(dist_to_seg(merc(lat, lon), a, b) for a, b, _ in roads) * COS
|
||||
for lon, lat in f["geometry"]["coordinates"]]
|
||||
worst.append((max(ds), sum(ds) / len(ds), f["properties"]["id"], f["properties"]["kind"]))
|
||||
|
||||
worst.sort(reverse=True)
|
||||
print(f"\non-street segments: {len(worst)}, mean offset from nearest road = "
|
||||
f"{sum(m for _, m, _, _ in worst)/len(worst):.1f} m")
|
||||
print(f"segments with mean offset > 10 m: {sum(1 for _, m, _, _ in worst if m > 10)}")
|
||||
for mx, mn, fid, kind in worst[:4]:
|
||||
print(f" worst: {fid} {kind:10s} max={mx:6.1f} m mean={mn:6.1f} m")
|
||||
195
tools/citymap/extract_map.py
Normal file
195
tools/citymap/extract_map.py
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Extract the color-coded parking geometry from the city's parking-map PDF.
|
||||
|
||||
pdftocairo emits every street segment as a stroked <path> carrying its own
|
||||
matrix() transform with local coordinates, and every lot as an absolute filled
|
||||
<path>. So: parse the path, flatten curves, push through the path's own matrix,
|
||||
and bucket by the exact colour pdftocairo wrote.
|
||||
|
||||
Output is GeoJSON-shaped but still in SVG *page* coordinates (y down); the
|
||||
georeferencing step turns that into lat/lon.
|
||||
"""
|
||||
import json
|
||||
import re
|
||||
import sys
|
||||
from xml.etree import ElementTree as ET
|
||||
|
||||
SVG = sys.argv[1] if len(sys.argv) > 1 else "/tmp/map.svg"
|
||||
OUT = sys.argv[2] if len(sys.argv) > 2 else "map_page_coords.json"
|
||||
|
||||
# Colours exactly as pdftocairo writes them, mapped to the map legend.
|
||||
COLORS = {
|
||||
"rgb(76.499939%, 76.899719%, 76.098633%)": "no_limit",
|
||||
"rgb(96.899414%, 54.499817%, 3.09906%)": "limit_4h",
|
||||
"rgb(79.998779%, 77.2995%, 25.898743%)": "limit_3h",
|
||||
"rgb(82.699585%, 40.39917%, 79.998779%)": "free_2h",
|
||||
"rgb(45.899963%, 69.799805%, 34.899902%)": "green_lot",
|
||||
}
|
||||
|
||||
NUM = re.compile(r"[-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?")
|
||||
|
||||
|
||||
def parse_matrix(s):
|
||||
"""matrix(a,b,c,d,e,f) -> tuple. Identity when absent."""
|
||||
if not s:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
m = re.search(r"matrix\s*\(([^)]*)\)", s)
|
||||
if not m:
|
||||
return (1.0, 0.0, 0.0, 1.0, 0.0, 0.0)
|
||||
v = [float(x) for x in NUM.findall(m.group(1))]
|
||||
return tuple(v[:6])
|
||||
|
||||
|
||||
def apply(mtx, x, y):
|
||||
a, b, c, d, e, f = mtx
|
||||
return (a * x + c * y + e, b * x + d * y + f)
|
||||
|
||||
|
||||
def bezier(p0, p1, p2, p3, steps=8):
|
||||
"""Flatten a cubic to points. The map's curves are gentle; 8 is plenty."""
|
||||
out = []
|
||||
for i in range(1, steps + 1):
|
||||
t = i / steps
|
||||
u = 1 - t
|
||||
out.append(
|
||||
(
|
||||
u * u * u * p0[0] + 3 * u * u * t * p1[0] + 3 * u * t * t * p2[0] + t * t * t * p3[0],
|
||||
u * u * u * p0[1] + 3 * u * u * t * p1[1] + 3 * u * t * t * p2[1] + t * t * t * p3[1],
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
|
||||
def parse_path(d):
|
||||
"""Return a list of subpaths [(points, closed)] in the path's local space."""
|
||||
tokens = re.findall(r"([MmLlHhVvCcSsZz])|([-+]?(?:\d*\.\d+|\d+\.?)(?:[eE][-+]?\d+)?)", d)
|
||||
subpaths, pts = [], []
|
||||
cur = (0.0, 0.0)
|
||||
start = (0.0, 0.0)
|
||||
cmd = None
|
||||
i = 0
|
||||
flat = [(c, n) for c, n in tokens]
|
||||
|
||||
def nums(k):
|
||||
nonlocal i
|
||||
vals = []
|
||||
while len(vals) < k and i < len(flat) and flat[i][1]:
|
||||
vals.append(float(flat[i][1]))
|
||||
i += 1
|
||||
return vals
|
||||
|
||||
while i < len(flat):
|
||||
c, n = flat[i]
|
||||
if c:
|
||||
cmd = c
|
||||
i += 1
|
||||
elif cmd is None:
|
||||
i += 1
|
||||
continue
|
||||
if cmd in "Zz":
|
||||
if pts:
|
||||
subpaths.append((pts, True))
|
||||
pts = []
|
||||
cur = start
|
||||
cmd = None
|
||||
continue
|
||||
if cmd in "Mm":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
pts = []
|
||||
cur = (v[0], v[1]) if cmd == "M" else (cur[0] + v[0], cur[1] + v[1])
|
||||
start = cur
|
||||
pts = [cur]
|
||||
cmd = "L" if cmd == "M" else "l"
|
||||
elif cmd in "Ll":
|
||||
v = nums(2)
|
||||
if len(v) < 2:
|
||||
break
|
||||
cur = (v[0], v[1]) if cmd == "L" else (cur[0] + v[0], cur[1] + v[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Hh":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (v[0], cur[1]) if cmd == "H" else (cur[0] + v[0], cur[1])
|
||||
pts.append(cur)
|
||||
elif cmd in "Vv":
|
||||
v = nums(1)
|
||||
if not v:
|
||||
break
|
||||
cur = (cur[0], v[0]) if cmd == "V" else (cur[0], cur[1] + v[0])
|
||||
pts.append(cur)
|
||||
elif cmd in "Cc":
|
||||
v = nums(6)
|
||||
if len(v) < 6:
|
||||
break
|
||||
if cmd == "C":
|
||||
p1, p2, p3 = (v[0], v[1]), (v[2], v[3]), (v[4], v[5])
|
||||
else:
|
||||
p1 = (cur[0] + v[0], cur[1] + v[1])
|
||||
p2 = (cur[0] + v[2], cur[1] + v[3])
|
||||
p3 = (cur[0] + v[4], cur[1] + v[5])
|
||||
pts.extend(bezier(cur, p1, p2, p3))
|
||||
cur = p3
|
||||
else:
|
||||
i += 1
|
||||
if pts:
|
||||
subpaths.append((pts, False))
|
||||
return subpaths
|
||||
|
||||
|
||||
def main():
|
||||
tree = ET.parse(SVG)
|
||||
root = tree.getroot()
|
||||
ns = "{http://www.w3.org/2000/svg}"
|
||||
|
||||
features = []
|
||||
for el in root.iter(ns + "path"):
|
||||
stroke = (el.get("stroke") or "").strip()
|
||||
fill = (el.get("fill") or "").strip()
|
||||
kind = COLORS.get(stroke) or COLORS.get(fill)
|
||||
if not kind:
|
||||
continue
|
||||
is_stroke = stroke in COLORS
|
||||
mtx = parse_matrix(el.get("transform"))
|
||||
width = float(el.get("stroke-width") or 0)
|
||||
for local, closed in parse_path(el.get("d") or ""):
|
||||
world = [apply(mtx, x, y) for x, y in local]
|
||||
if len(world) < 2:
|
||||
continue
|
||||
xs = [p[0] for p in world]
|
||||
ys = [p[1] for p in world]
|
||||
features.append(
|
||||
{
|
||||
"kind": kind,
|
||||
"geom": "line" if is_stroke else "polygon",
|
||||
"closed": closed,
|
||||
"strokeWidth": width,
|
||||
"bbox": [min(xs), min(ys), max(xs), max(ys)],
|
||||
"points": [[round(x, 3), round(y, 3)] for x, y in world],
|
||||
}
|
||||
)
|
||||
|
||||
with open(OUT, "w") as fh:
|
||||
json.dump({"viewBox": [0, 0, 491.87, 529.043], "features": features}, fh, indent=1)
|
||||
|
||||
from collections import Counter
|
||||
print(f"{len(features)} features -> {OUT}")
|
||||
for (k, g), n in sorted(Counter((f["kind"], f["geom"]) for f in features).items()):
|
||||
print(f" {k:10s} {g:8s} {n}")
|
||||
# Where are they? Legend swatches cluster in one corner; real geometry spreads out.
|
||||
print("\nbbox spread by kind:")
|
||||
for k in COLORS.values():
|
||||
fs = [f for f in features if f["kind"] == k]
|
||||
if not fs:
|
||||
continue
|
||||
print(
|
||||
f" {k:10s} x[{min(f['bbox'][0] for f in fs):7.1f},{max(f['bbox'][2] for f in fs):7.1f}] "
|
||||
f"y[{min(f['bbox'][1] for f in fs):7.1f},{max(f['bbox'][3] for f in fs):7.1f}]"
|
||||
)
|
||||
|
||||
|
||||
main()
|
||||
114
tools/citymap/georef.py
Normal file
114
tools/citymap/georef.py
Normal file
|
|
@ -0,0 +1,114 @@
|
|||
#!/usr/bin/env python3
|
||||
"""Georeference the parking-map page coordinates against OSM.
|
||||
|
||||
The base map is a north-up Web Mercator screenshot, so page -> mercator is a
|
||||
uniform scale plus a translation (3 free params, not 6). Control points are
|
||||
street centrelines identified by name: an avenue pins X, a street pins Y.
|
||||
|
||||
Streets in Sandpoint jog between the north and south halves of the grid, so each
|
||||
control street's mercator coordinate is measured only over the span the page
|
||||
segment actually covers, not over the whole way.
|
||||
"""
|
||||
import json
|
||||
import math
|
||||
|
||||
R = 6378137.0
|
||||
|
||||
|
||||
def merc(lat, lon):
|
||||
return (math.radians(lon) * R, math.log(math.tan(math.pi / 4 + math.radians(lat) / 2)) * R)
|
||||
|
||||
|
||||
def unmerc(X, Y):
|
||||
return (math.degrees(2 * math.atan(math.exp(Y / R)) - math.pi / 2), math.degrees(X / R))
|
||||
|
||||
|
||||
osm = json.load(open("osm.json"))
|
||||
ways = {}
|
||||
for w in osm["elements"]:
|
||||
n = w.get("tags", {}).get("name")
|
||||
if not n or "geometry" not in w:
|
||||
continue
|
||||
ways.setdefault(n, []).append([merc(p["lat"], p["lon"]) for p in w["geometry"]])
|
||||
|
||||
|
||||
def centreline(name, axis, lo, hi):
|
||||
"""Mean coordinate on `axis` of `name`, over the other axis' [lo,hi] window."""
|
||||
other = 1 - axis
|
||||
vals = []
|
||||
for g in ways.get(name, []):
|
||||
for (x0, y0), (x1, y1) in zip(g, g[1:]):
|
||||
p0, p1 = (x0, y0), (x1, y1)
|
||||
if not (lo <= p0[other] <= hi or lo <= p1[other] <= hi):
|
||||
continue
|
||||
vals.append((p0[axis] + p1[axis]) / 2)
|
||||
return sum(vals) / len(vals) if vals else None
|
||||
|
||||
|
||||
# Page grid lines read off the rendered map, with the mercator window each spans.
|
||||
# X window for E-W streets / Y window for N-S avenues, in mercator metres.
|
||||
XW = (-12974700, -12974000) # 5th Ave .. 1st Ave
|
||||
YW = (6152200, 6153300) # Lake St .. Poplar St
|
||||
|
||||
AVENUES = [ # page x, OSM name
|
||||
(83.1, "North 5th Avenue"),
|
||||
(120.0, "North 4th Avenue"),
|
||||
(163.8, "North 3rd Avenue"),
|
||||
(207.7, "North 2nd Avenue"),
|
||||
(235.7, "North 1st Avenue"),
|
||||
(164.3, "South 3rd Avenue"),
|
||||
(198.4, "South 2nd Avenue"),
|
||||
]
|
||||
STREETS = [ # page y, OSM name
|
||||
(184.4, "Poplar Street"),
|
||||
(228.3, "Alder Street"),
|
||||
(272.3, "Cedar Street"),
|
||||
(314.9, "Oak Street"),
|
||||
(359.1, "Church Street"),
|
||||
(398.0, "Pine Street"),
|
||||
(438.2, "Lake Street"),
|
||||
(492.1, "Superior Street"),
|
||||
]
|
||||
|
||||
|
||||
def fit(pairs, flip):
|
||||
"""Least-squares v = s*p + t. Returns (s, t, residuals)."""
|
||||
n = len(pairs)
|
||||
sp = sum(p for p, v in pairs)
|
||||
sv = sum(v for p, v in pairs)
|
||||
spp = sum(p * p for p, v in pairs)
|
||||
spv = sum(p * v for p, v in pairs)
|
||||
s = (n * spv - sp * sv) / (n * spp - sp * sp)
|
||||
t = (sv - s * sp) / n
|
||||
return s, t, [(p, v, s * p + t - v) for p, v in pairs]
|
||||
|
||||
|
||||
ax = [(px, centreline(n, 0, *YW)) for px, n in AVENUES]
|
||||
ay = [(py, centreline(n, 1, *XW)) for py, n in STREETS]
|
||||
print("control points (mercator metres):")
|
||||
for (px, n), (_, v) in zip(AVENUES, ax):
|
||||
print(f" x {px:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
for (py, n), (_, v) in zip(STREETS, ay):
|
||||
print(f" y {py:7.1f} {n:20s} {v if v is None else round(v,1)}")
|
||||
|
||||
ax = [(p, v) for p, v in ax if v is not None]
|
||||
ay = [(p, v) for p, v in ay if v is not None]
|
||||
|
||||
COS = math.cos(math.radians(48.278)) # mercator metres -> ground metres here
|
||||
|
||||
|
||||
def report(label, pairs, names):
|
||||
s, t, res = fit(pairs, False)
|
||||
print(f"\n{label}: scale={s:.4f} merc-m/pt ({abs(s)*COS:.4f} ground-m/pt), offset={t:.1f}")
|
||||
for (p, v, r), nm in zip(res, names):
|
||||
print(f" {nm:20s} page={p:7.1f} residual={r*COS:7.1f} ground-m")
|
||||
rms = math.sqrt(sum(r * r for _, _, r in res) / len(res)) * COS
|
||||
print(f" RMS = {rms:.1f} ground-m")
|
||||
return s, t, rms
|
||||
|
||||
|
||||
sx, tx, rx = report("X (avenues)", ax, [n for _, n in AVENUES])
|
||||
sy, ty, ry = report("Y (streets)", ay, [n for _, n in STREETS])
|
||||
print(f"\nscale ratio |sy/sx| = {abs(sy/sx):.4f} (1.0 == truly uniform / north-up)")
|
||||
|
||||
json.dump({"sx": sx, "tx": tx, "sy": sy, "ty": ty}, open("fit_raw.json", "w"), indent=1)
|
||||
3
tools/citymap/roads.overpass
Normal file
3
tools/citymap/roads.overpass
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
[out:json][timeout:60];
|
||||
way["highway"](48.2600,-116.5750,48.2900,-116.5300);
|
||||
out geom;
|
||||
Loading…
Add table
Add a link
Reference in a new issue