From 1b80bbb6ff573dcfb95565422b7f42f9e46b15e1 Mon Sep 17 00:00:00 2001 From: pjaudiomv Date: Thu, 3 Sep 2026 10:01:44 -0400 Subject: [PATCH] add version --- CHANGELOG.md | 6 ++ eslint.config.js | 12 ++- package-lock.json | 8 +- package.json | 4 +- src/app.d.ts | 6 ++ src/lib/maps/provider.ts | 49 ++++++++- src/routes/contact/+page.svelte | 2 + src/routes/map-search/+page.svelte | 156 +++++++++++++++++++++++------ vite.config.ts | 53 ++++++++++ 9 files changed, 253 insertions(+), 43 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 38df86b..0ff2b02 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,11 @@ # Changelog +## 6.1.1 (UNRELEASED) + +- Reader pan: Native gesture detection across Apple + Google Maps. +- Fit bounds: Jump searches now frame result pins; area searches stay in place. +- Theme sync: Maps now follow the app/system light/dark theme. + ## 6.1.0 (August 31, 2026) - Meetings now use Apple Maps on iPhone and iPad. diff --git a/eslint.config.js b/eslint.config.js index 3cae4ab..2f24f87 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -15,11 +15,13 @@ export default ts.config( globals: { ...globals.browser, ...globals.node, - // The Maps SDK namespace, from @types/google.maps. Declared here because - // `no-undef` cannot see a TypeScript type annotation as anything other - // than an undefined identifier, and it is a genuine runtime global once - // the SDK has loaded. - google: 'readonly' + // `google` is the ambient Google Maps namespace from @types/google.maps. It + // is used in type positions only, but ESLint cannot tell those apart. + // `__GIT_SHA__` is substituted by Vite's `define` at build time, so it is a + // real global at runtime but exists in no file for ESLint to find. + google: 'readonly', + __GIT_SHA__: 'readonly', + __APP_VERSION__: 'readonly' } } }, diff --git a/package-lock.json b/package-lock.json index 40e8f45..2d15151 100644 --- a/package-lock.json +++ b/package-lock.json @@ -21,7 +21,7 @@ "@capacitor/status-bar": "^8.0.3", "@googlemaps/js-api-loader": "^2.1.1", "@lucide/svelte": "^1.37.0", - "capacitor-plugin-apple-maps": "^0.3.4" + "capacitor-plugin-apple-maps": "^0.5.0" }, "devDependencies": { "@capacitor/cli": "^8.5.0", @@ -5494,9 +5494,9 @@ "license": "CC-BY-4.0" }, "node_modules/capacitor-plugin-apple-maps": { - "version": "0.3.4", - "resolved": "https://registry.npmjs.org/capacitor-plugin-apple-maps/-/capacitor-plugin-apple-maps-0.3.4.tgz", - "integrity": "sha512-Z4zFaDzZTRz6/DDrPqT2oZXcoF2FyGdVaamndvW0JJph0dJVgSInLbNJGjBMzBe/QRGLOobbCqYpR0hJ+jZizg==", + "version": "0.5.0", + "resolved": "https://registry.npmjs.org/capacitor-plugin-apple-maps/-/capacitor-plugin-apple-maps-0.5.0.tgz", + "integrity": "sha512-hqTktz/k3F7dTVcDMeJ/Ij7bqp6yoEeJp7m2YS6GMtZZEuMvZDUIt3rQ7wYoyS1JvCJY/rged5DmauKpaV8t0A==", "license": "MIT", "peerDependencies": { "@capacitor/core": ">=8.0.0" diff --git a/package.json b/package.json index ce59799..35b9a7f 100644 --- a/package.json +++ b/package.json @@ -1,7 +1,7 @@ { "name": "bmlt-search", "private": true, - "version": "6.1.0", + "version": "6.1.1", "license": "MIT", "type": "module", "author": "BMLT Enabled", @@ -96,6 +96,6 @@ "@capacitor/status-bar": "^8.0.3", "@googlemaps/js-api-loader": "^2.1.1", "@lucide/svelte": "^1.37.0", - "capacitor-plugin-apple-maps": "^0.3.4" + "capacitor-plugin-apple-maps": "^0.5.0" } } diff --git a/src/app.d.ts b/src/app.d.ts index 4763f58..7872513 100644 --- a/src/app.d.ts +++ b/src/app.d.ts @@ -19,6 +19,12 @@ declare global { // interface Platform {} } + /** Short commit this bundle was built from; see `gitCommit` in vite.config.ts. */ + const __GIT_SHA__: string; + + /** Release version, from the git tag; see `appVersion` in vite.config.ts. */ + const __APP_VERSION__: string; + /** * The Maps keys, read from `import.meta.env` rather than `$env/static/public` * — see src/lib/maps/keys.ts for why. diff --git a/src/lib/maps/provider.ts b/src/lib/maps/provider.ts index 0f31317..24d723c 100644 --- a/src/lib/maps/provider.ts +++ b/src/lib/maps/provider.ts @@ -1,4 +1,4 @@ -import { GoogleMap } from '@capacitor/google-maps'; +import { GoogleMap, LatLngBounds } from '@capacitor/google-maps'; import { AppleMap } from 'capacitor-plugin-apple-maps'; import { platform } from '../native'; import { mapKey } from './keys'; @@ -58,15 +58,26 @@ export interface ProviderMarker { export interface CreateMapOptions { id: string; element: HTMLElement; - config: { center: LatLng; zoom: number; minZoom?: number }; + config: { center: LatLng; zoom: number; minZoom?: number; colorScheme?: 'light' | 'dark' }; } /** Provider-neutral handle over the native map, exposing only what the route needs. */ export interface MapHandle { setOnCameraIdleListener(callback: (data: CameraIdleData) => void): Promise; + /** + * Fires once as the camera starts moving, before the ensuing idle, with the + * plugin's authoritative `isGesture` flag: true for a user pan/zoom, false for + * a programmatic move (our own `setCamera`/`fitBounds`, or the provider + * recentring itself on a marker tap). Both plugins expose it identically. + */ + setOnCameraMoveStartedListener(callback: (isGesture: boolean) => void): Promise; setOnMarkerClickListener(callback: (data: MarkerClickData) => void): Promise; getMapBounds(): Promise; setCamera(config: { coordinate?: LatLng; zoom?: number }): Promise; + /** Frame the camera to enclose every coordinate. `padding` is an edge inset in pixels. */ + fitBounds(coordinates: LatLng[], padding?: number): Promise; + /** Match the map's appearance to a light/dark scheme. No-op where the provider has no such control (Google). */ + setColorScheme(scheme: 'light' | 'dark'): Promise; addMarkers(markers: ProviderMarker[]): Promise; removeMarkers(ids: string[]): Promise; enableClustering(): Promise; @@ -74,6 +85,29 @@ export interface MapHandle { destroy(): Promise; } +/** + * Bounding box of a set of coordinates, in the {southwest, center, northeast} + * shape Google's `fitBounds` wants. Apple's `fitBounds` takes the raw `LatLng[]` + * and computes this itself, so this is only needed on the Google branch. + */ +function boundsOf(coordinates: LatLng[]): ProviderBounds { + let minLat = Infinity; + let maxLat = -Infinity; + let minLng = Infinity; + let maxLng = -Infinity; + for (const { lat, lng } of coordinates) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + } + return { + southwest: { lat: minLat, lng: minLng }, + northeast: { lat: maxLat, lng: maxLng }, + center: { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 } + }; +} + /** Normalise either provider's bounds object into {center, southwest, northeast}. */ function normaliseBounds(bounds: ProviderBounds): ProviderBounds { return { @@ -104,9 +138,14 @@ export async function createMap(options: CreateMapOptions): Promise { }); return { setOnCameraIdleListener: (cb) => map.setOnCameraIdleListener((d) => cb({ latitude: d.latitude, longitude: d.longitude, zoom: d.zoom, bounds: normaliseBounds(d.bounds) })), + setOnCameraMoveStartedListener: (cb) => map.setOnCameraMoveStartedListener((d) => cb(d.isGesture)), setOnMarkerClickListener: (cb) => map.setOnMarkerClickListener((d) => cb({ markerId: d.markerId })), getMapBounds: async () => normaliseBounds(await map.getMapBounds()), setCamera: (config) => map.setCamera(config), + // Apple's fitBounds takes the raw coordinates and frames them to the real + // viewport aspect ratio (setVisibleMapRect(_:edgePadding:)). + fitBounds: (coordinates, padding) => map.fitBounds(coordinates, padding), + setColorScheme: (scheme) => map.setColorScheme(scheme), // MapKit sizes the annotation image; without a size the raw PNG pixels are // used, which is tiny on a hi-DPI screen. 60×72 keeps the pin art's ~0.83 // aspect (the source is 83×100) and matches the marker size in the @@ -130,9 +169,15 @@ export async function createMap(options: CreateMapOptions): Promise { return { setOnCameraIdleListener: (cb) => map.setOnCameraIdleListener((d) => cb({ latitude: d.latitude, longitude: d.longitude, zoom: d.zoom, bounds: normaliseBounds(d.bounds as unknown as ProviderBounds) })), + setOnCameraMoveStartedListener: (cb) => map.setOnCameraMoveStartedListener((d) => cb(d.isGesture)), setOnMarkerClickListener: (cb) => map.setOnMarkerClickListener((d) => cb({ markerId: d.markerId })), getMapBounds: async () => normaliseBounds((await map.getMapBounds()) as unknown as ProviderBounds), setCamera: (config) => map.setCamera(config), + // Google's fitBounds wants a LatLngBounds; build it from the pins' box. + fitBounds: (coordinates, padding) => map.fitBounds(new LatLngBounds(boundsOf(coordinates)), padding), + // Google Maps has no runtime light/dark toggle here; the app follows the + // system scheme via CSS on the web/Android paths. + setColorScheme: () => Promise.resolve(), // The Google pin art is anchored by its tip: half its width across, its full // height down — the value the route used before this abstraction existed. addMarkers: (markers) => map.addMarkers(markers.map((m) => ({ coordinate: m.coordinate, iconUrl: m.iconUrl, iconAnchor: { x: 15, y: 45 } }))), diff --git a/src/routes/contact/+page.svelte b/src/routes/contact/+page.svelte index 3b76afb..461f6fc 100644 --- a/src/routes/contact/+page.svelte +++ b/src/routes/contact/+page.svelte @@ -62,3 +62,5 @@ {/each} + +

v{__APP_VERSION__} ({__GIT_SHA__})

diff --git a/src/routes/map-search/+page.svelte b/src/routes/map-search/+page.svelte index 36abdf8..293570c 100644 --- a/src/routes/map-search/+page.svelte +++ b/src/routes/map-search/+page.svelte @@ -52,6 +52,38 @@ /** Camera-idle fires repeatedly through a fling; only the last one matters. */ const IDLE_DEBOUNCE_MS = 400; + /** Edge inset, in pixels, kept clear of pins when framing a search's results. */ + const FIT_PADDING_PX = 48; + + /** + * A single result has no extent to frame, so `fitBounds` would zoom to the + * maximum. Centre on it at this zoom instead — close, but with context around. + */ + const SINGLE_PIN_ZOOM = 13; + + /** + * Keep the native map's light/dark appearance in step with the app, which + * follows the system scheme via `prefers-color-scheme` (see app.css). Apple + * Maps honours this; the Google branch's `setColorScheme` is a no-op. + */ + function currentColorScheme(): 'light' | 'dark' { + return typeof window !== 'undefined' && window.matchMedia('(prefers-color-scheme: dark)').matches ? 'dark' : 'light'; + } + + let schemeQuery: MediaQueryList | null = null; + function onColorSchemeChange() { + void map?.setColorScheme(currentColorScheme()); + } + function watchColorScheme() { + if (typeof window === 'undefined' || !window.matchMedia) return; + schemeQuery = window.matchMedia('(prefers-color-scheme: dark)'); + schemeQuery.addEventListener('change', onColorSchemeChange); + } + function unwatchColorScheme() { + schemeQuery?.removeEventListener('change', onColorSchemeChange); + schemeQuery = null; + } + let map: MapHandle | null = null; let mapElement = $state(null); let error = $state(''); @@ -94,11 +126,17 @@ let idleTimer: ReturnType | undefined; let searchSequence = 0; /** - * Set while we move the camera ourselves, so the camera-idle it may (or may - * not) produce is ignored. Our own moves always search explicitly via - * `searchCurrentView()` afterwards; only a user gesture searches through idle. + * Whether the move that is about to settle was a user gesture, as reported by + * the plugin's `onCameraMoveStarted`. Only a gesture-driven idle offers to + * "search this area"; a programmatic move (our `setCamera`/`fitBounds`, or the + * provider recentring itself on a marker tap) reports `false` and is ignored. + * + * This replaces the old inference — treating *any* non-programmatic idle as a + * reader pan — which misfired on self-triggered moves like a min-zoom bounce. + * Defaults to `false` so an idle with no preceding move-started never spuriously + * arms the button. */ - let programmaticMove = false; + let lastMoveWasGesture = false; /** * True once the native view exists and is safe to drive. We set it a couple of @@ -136,6 +174,7 @@ }); async function teardown() { + unwatchColorScheme(); const instance = map; map = null; mapReady = false; @@ -169,8 +208,9 @@ if (settings.location) { // Search the opening view straight away — the map does not emit a - // reliable idle for its initial region, so we cannot wait for one. - await searchCurrentView(); + // reliable idle for its initial region, so we cannot wait for one. Frame + // the results: opening at the saved spot is itself a jump to a location. + await searchCurrentView(11, true); } else { // No stored location: refine to the device fix without blocking the map // appearing, and let that path run the first search where the reader is. @@ -189,12 +229,13 @@ // where the fallback centre was. searchedCentre = null; await moveCamera({ coordinate: fix, zoom: 11 }); - // The move may not emit an idle (see mapReady), so search the new view now. - await searchCurrentView(); + // The move may not emit an idle (see mapReady), so search the new view now, + // and frame the results around the reader's location. + await searchCurrentView(11, true); } catch { // No fix: search the fallback view that is already on screen, so the reader - // still gets meetings rather than an empty map. - await searchCurrentView(); + // still gets meetings rather than an empty map, framed around the results. + await searchCurrentView(11, true); } } @@ -243,16 +284,23 @@ map = await createNativeMap({ id: 'bmlt-map', element: mapElement, - config: { center: centre, zoom: 11, minZoom: MIN_SEARCH_ZOOM } + // colorScheme applied at create time so the map paints in the right theme + // from its first frame, not a beat later. Kept in sync below. + config: { center: centre, zoom: 11, minZoom: MIN_SEARCH_ZOOM, colorScheme: currentColorScheme() } }); + watchColorScheme(); + + // Authoritative from the plugin, and it fires before the idle: was the move + // that is settling a user gesture, or one we (or the provider) made? Only a + // gesture should offer to search the new area. + await map.setOnCameraMoveStartedListener((isGesture) => (lastMoveWasGesture = isGesture)); await map.setOnCameraIdleListener((data) => { // Idle handles user gestures only. Our own moves search explicitly, so the - // idle they may emit is swallowed here — otherwise a programmatic recentre - // (marker tap, locate, place pick) would fire a second, unwanted search. - const wasProgrammatic = programmaticMove; - programmaticMove = false; - if (wasProgrammatic) return; + // idle they emit is swallowed here — otherwise a programmatic recentre + // (marker tap, locate, place pick, fit-to-pins) would fire a second, + // unwanted search. + if (!lastMoveWasGesture) return; // Normalise the provider payload into the {zoom, bounds:{center, southwest}} // shape the rest of the route uses. @@ -298,12 +346,11 @@ return; } try { - programmaticMove = true; await map.setCamera(config); } catch { // A destroyed or not-yet-rendered map. Nothing to recover, and it must not - // take the app down. - programmaticMove = false; + // take the app down. The move-started listener already reported this move + // as non-gesture, so no idle it emits will be mistaken for a reader pan. } } @@ -354,19 +401,25 @@ * reliably send for non-gesture region changes. `getMapBounds()` returns the * real visible rectangle, so the search covers exactly what is on screen. */ - async function searchCurrentView(zoom = 11) { + async function searchCurrentView(zoom = 11, frame = false) { if (!map) return; try { const bounds = await map.getMapBounds(); const event: CameraEvent = { zoom, bounds: { center: bounds.center, southwest: bounds.southwest } }; updateSearchBias(event.bounds.center, event.bounds.southwest); - await runSearch(event); + await runSearch(event, frame); } catch { // The view can be torn down mid-flight; a failed bounds read is not fatal. } } - async function runSearch(event: CameraEvent) { + /** + * `frame` reframes the camera to enclose the pins this search draws. Set on + * "jump" searches (place pick, locate, initial load) where the reader has just + * teleported and framing the results beats the fixed opening zoom. It is left + * off for "Search this area", so refining in place keeps the reader's view. + */ + async function runSearch(event: CameraEvent, frame = false) { if (!map) return; const radiusKm = Math.ceil(distanceKm(event.bounds.center, event.bounds.southwest)); @@ -380,9 +433,10 @@ try { const meetings = await meetingsWithinRadius(event.bounds.center.lat, event.bounds.center.lng, radiusKm, MAP_VENUE_TYPES); if (sequence !== searchSequence || !map) return; - await drawMarkers(meetings); + const coords = await drawMarkers(meetings); searchedCentre = event.bounds.center; canSearchArea = false; + if (frame) await frameToPins(coords); } catch { if (sequence === searchSequence) error = t('LOAD_ERROR'); } finally { @@ -390,8 +444,48 @@ } } - async function drawMarkers(meetings: RawMeeting[]) { - if (!map) return; + /** + * Frame the camera to the search's results using the plugin's native + * `fitBounds`, which frames to the real viewport aspect ratio. This is a + * programmatic move, so the move-started listener reports it as non-gesture and + * the idle it emits never arms "Search this area". `searchedCentre` is then + * re-based to the framed centre, so a later pan measures drift from what the + * reader actually sees rather than the pre-fit search centre. + */ + async function frameToPins(coords: LatLng[]) { + if (!map || coords.length === 0) return; + try { + if (coords.length === 1) { + // No extent to frame; centre on the lone pin instead of zooming to max. + await map.setCamera({ coordinate: coords[0], zoom: SINGLE_PIN_ZOOM }); + searchedCentre = coords[0]; + } else { + await map.fitBounds(coords, FIT_PADDING_PX); + searchedCentre = boundsCentre(coords); + } + } catch { + // Map torn down mid-flight; framing is best-effort. + } + } + + /** Centre of the bounding box of a set of coordinates. */ + function boundsCentre(coords: LatLng[]): LatLng { + let minLat = Infinity; + let maxLat = -Infinity; + let minLng = Infinity; + let maxLng = -Infinity; + for (const { lat, lng } of coords) { + minLat = Math.min(minLat, lat); + maxLat = Math.max(maxLat, lat); + minLng = Math.min(minLng, lng); + maxLng = Math.max(maxLng, lng); + } + return { lat: (minLat + maxLat) / 2, lng: (minLng + maxLng) / 2 }; + } + + /** Draws the pins for a result set and returns their coordinates (for framing). */ + async function drawMarkers(meetings: RawMeeting[]): Promise { + if (!map) return []; if (placedMarkerIds.length > 0) { await map.removeMarkers(placedMarkerIds); @@ -401,7 +495,7 @@ } const markers = buildMarkers(meetings); - if (markers.length === 0) return; + if (markers.length === 0) return []; const placed = await map.addMarkers( markers.map((marker) => ({ @@ -417,15 +511,16 @@ placedMarkerIds = placed; await map.enableClustering(); + return markers.map((marker) => marker.coordinate); } async function onMarkerClick(markerId: string) { const ids = markerIds.get(markerId); if (!ids?.length) return; - // Tapping a pin makes Google recentre the map by itself. That recentre is - // not the reader panning, so it must not offer to search the new area. - programmaticMove = true; + // Tapping a pin makes Google recentre the map by itself. That recentre is not + // the reader panning — the plugin reports it as a non-gesture move-started, so + // the idle it emits is already ignored and won't offer to search the new area. // The sheet opens straight away with its own spinner rather than raising the // app-wide overlay. Loading a pin's meetings is not an area search, and @@ -464,8 +559,9 @@ // Jumping somewhere new should search there, not wait to be asked — and the // move may not emit an idle, so run the search explicitly on the new view. + // Frame the results, since the reader has just jumped to a chosen place. searchedCentre = null; - await searchCurrentView(12); + await searchCurrentView(12, true); // A new session token per completed search is what keeps Places billing // on the per-session rate rather than per-keystroke. diff --git a/vite.config.ts b/vite.config.ts index e0cadfd..f08d1be 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,5 +1,6 @@ /// +import { execSync } from 'node:child_process'; import { sveltekit } from '@sveltejs/kit/vite'; import { SvelteKitPWA } from '@vite-pwa/sveltekit'; import { svelteTesting } from '@testing-library/svelte/vite'; @@ -9,7 +10,59 @@ import tailwindcss from '@tailwindcss/vite'; // Kit options (adapter, paths, runes) live in svelte.config.js — see the note // there for why they can't be inline here. +/** + * The commit this bundle was built from. + * + * CI is the case that matters: `GITHUB_SHA` is the only reliable answer there, + * because the checkout is detached and the worktree is thrown away afterwards. + * Locally it falls back to git, and to a placeholder when there is no git at + * all — a tarball, or a container without the binary. A build must never fail + * for want of a label. + * + * This exists because working out which commit produced TestFlight build 6 + * meant correlating an upload timestamp against workflow run times. That works + * until two builds go out twenty minutes apart, which has already happened. + */ +function git(command: string): string | undefined { + try { + return execSync(command, { stdio: ['ignore', 'pipe', 'ignore'] }) + .toString() + .trim(); + } catch { + return undefined; + } +} + +/** + * The version this build calls itself. + * + * The git tag, not `package.json`. The tag is what actually ships: CI sets + * `MARKETING_VERSION` from `GITHUB_REF_NAME` on a tagged run, and nothing reads + * the package version on the way to a store. They have already drifted apart — + * package.json says 0.1.0 while v1.0.0 is in TestFlight — so taking the tag is + * the difference between a number that matches the store listing and one that + * quietly does not. + * + * Falls back to the package version, then to `dev`, for a checkout with no tags + * at all. Paired with the commit below, an approximate version is still useful: + * the sha is the exact answer, the version is the readable one. + */ +function appVersion(): string { + const tag = process.env.GITHUB_REF_TYPE === 'tag' ? process.env.GITHUB_REF_NAME : git('git describe --tags --abbrev=0'); + return (tag ?? process.env.npm_package_version ?? 'dev').replace(/^v/, ''); +} + +function gitCommit(): string { + const fromCi = process.env.GITHUB_SHA; + if (fromCi) return fromCi.slice(0, 7); + return git('git rev-parse --short=7 HEAD') ?? 'unknown'; +} + export default defineConfig({ + define: { + __GIT_SHA__: JSON.stringify(gitCommit()), + __APP_VERSION__: JSON.stringify(appVersion()) + }, // Vite only exposes VITE_-prefixed variables on `import.meta.env`; adding // PUBLIC_ lets the Maps keys be read that way instead of through // `$env/static/public`.