From 1e0261ae579618c9aa3fceb3b93efedb59b05cd7 Mon Sep 17 00:00:00 2001 From: najuna-brian Date: Thu, 5 Nov 2026 02:34:07 +0300 Subject: [PATCH 1/3] feat(formulus): add located-observation query for the observations map Reads only the stored geolocation (never data) with the same filters as the observations table, caps the result, and reports how many matches have no usable location. Refs #553 --- .../__tests__/observationMapQuery.test.ts | 115 ++++++++++ formulus/src/database/observationListQuery.ts | 6 +- formulus/src/database/observationMapQuery.ts | 196 ++++++++++++++++++ .../repositories/LocalRepoInterface.ts | 12 ++ .../database/repositories/WatermelonDBRepo.ts | 57 +++++ .../repositories/__tests__/LocalRepo.test.ts | 21 ++ formulus/src/services/FormService.ts | 13 ++ .../services/__tests__/FormService.test.ts | 1 + 8 files changed, 418 insertions(+), 3 deletions(-) create mode 100644 formulus/src/database/__tests__/observationMapQuery.test.ts create mode 100644 formulus/src/database/observationMapQuery.ts diff --git a/formulus/src/database/__tests__/observationMapQuery.test.ts b/formulus/src/database/__tests__/observationMapQuery.test.ts new file mode 100644 index 000000000..75b711652 --- /dev/null +++ b/formulus/src/database/__tests__/observationMapQuery.test.ts @@ -0,0 +1,115 @@ +import { + boundsOfPoints, + buildObservationMapSql, + framingPoints, + mapObservationMapRow, + OBSERVATION_MAP_POINT_LIMIT, + parseObservationCoordinates, +} from '../observationMapQuery'; +import { buildObservationListSql } from '../observationListQuery'; + +describe('buildObservationMapSql', () => { + it('reads located rows only, never data, capped with one extra row', () => { + const built = buildObservationMapSql({}); + expect(built.pointsSql).toContain('geolocation IS NOT NULL'); + expect(built.pointsSql).not.toMatch(/\bdata\b/); + expect(built.pointsSql).toContain('LIMIT ?'); + expect(built.pointsParams.at(-1)).toBe(OBSERVATION_MAP_POINT_LIMIT + 1); + expect(built.countSql).toBe( + 'SELECT COUNT(*) AS cnt FROM observations WHERE deleted = 0', + ); + }); + + it('applies exactly the same filters as the list', () => { + const filters = { + formType: 'water_point', + syncStatus: 'pending' as const, + search: 'abc', + }; + const map = buildObservationMapSql(filters); + const list = buildObservationListSql({ ...filters, page: 1 }); + expect(map.countSql).toBe(list.countSql); + expect(map.countParams).toEqual(list.countParams); + expect(map.pointsParams.slice(0, -1)).toEqual(list.countParams); + }); +}); + +describe('parseObservationCoordinates', () => { + it('parses stored JSON', () => { + expect( + parseObservationCoordinates( + JSON.stringify({ latitude: 0.3476, longitude: 32.5825, accuracy: 7 }), + ), + ).toEqual({ latitude: 0.3476, longitude: 32.5825, accuracy: 7 }); + }); + + it.each([ + null, + '', + 'not json', + '{}', + JSON.stringify({ latitude: 0, longitude: 0 }), + JSON.stringify({ latitude: 95, longitude: 10 }), + JSON.stringify({ latitude: 'x', longitude: 10 }), + ])('rejects %p', raw => { + expect(parseObservationCoordinates(raw)).toBeNull(); + }); +}); + +describe('mapObservationMapRow', () => { + it('maps a synced row', () => { + const point = mapObservationMapRow({ + observation_id: 'obs-1', + form_type: 'survey', + created_at: 1_700_000_000_000, + updated_at: 1_700_000_000_000, + synced_at: 1_700_000_100_000, + geolocation: JSON.stringify({ latitude: 1, longitude: 2 }), + }); + expect(point).toMatchObject({ + observationId: 'obs-1', + formType: 'survey', + synced: true, + latitude: 1, + longitude: 2, + accuracy: null, + }); + }); + + it('marks edits after the last sync as pending', () => { + const point = mapObservationMapRow({ + observation_id: 'obs-2', + form_type: 'survey', + created_at: 1_700_000_000_000, + updated_at: 1_700_000_200_000, + synced_at: 1_700_000_100_000, + geolocation: JSON.stringify({ latitude: 1, longitude: 2 }), + }); + expect(point?.synced).toBe(false); + }); +}); + +describe('framing', () => { + const cluster = [ + { latitude: 0.31, longitude: 32.58 }, + { latitude: 0.32, longitude: 32.59 }, + { latitude: 0.33, longitude: 32.57 }, + { latitude: 0.315, longitude: 32.585 }, + ]; + + it('drops a far outlier from the framing', () => { + const outlier = { latitude: 51.5, longitude: -0.12 }; + const framed = framingPoints([...cluster, outlier]); + expect(framed).toHaveLength(4); + expect(framed).not.toContainEqual(outlier); + }); + + it('keeps everything when points are spread evenly', () => { + expect(framingPoints(cluster)).toHaveLength(4); + }); + + it('computes bounds as west, south, east, north', () => { + expect(boundsOfPoints(cluster)).toEqual([32.57, 0.31, 32.59, 0.33]); + expect(boundsOfPoints([])).toBeNull(); + }); +}); diff --git a/formulus/src/database/observationListQuery.ts b/formulus/src/database/observationListQuery.ts index cf2469d28..48f2a2956 100644 --- a/formulus/src/database/observationListQuery.ts +++ b/formulus/src/database/observationListQuery.ts @@ -37,10 +37,10 @@ function likeContains(term: string): string { .replace(/_/g, '\\_')}%`; } -function appendFilters( +export function appendObservationListFilters( where: string[], params: Array, - query: ObservationListQuery, + query: Pick, ): void { where.push('deleted = 0'); if (query.formType?.trim()) { @@ -82,7 +82,7 @@ export function buildObservationListSql(query: ObservationListQuery): { const offset = (page - 1) * pageSize; const where: string[] = []; const params: Array = []; - appendFilters(where, params, query); + appendObservationListFilters(where, params, query); const whereSql = `WHERE ${where.join(' AND ')}`; return { listSql: `SELECT ${LIST_COLUMNS} FROM observations ${whereSql} ORDER BY created_at DESC LIMIT ? OFFSET ?`, diff --git a/formulus/src/database/observationMapQuery.ts b/formulus/src/database/observationMapQuery.ts new file mode 100644 index 000000000..771f8278f --- /dev/null +++ b/formulus/src/database/observationMapQuery.ts @@ -0,0 +1,196 @@ +import { + appendObservationListFilters, + type ObservationListQuery, +} from './observationListQuery'; +import { isObservationFullySynced } from '../utils/observationSyncStatus'; + +/** + * Upper bound on points the map loads in one read. Far above what a device + * normally holds for one study; keeps a runaway dataset from stalling the UI. + */ +export const OBSERVATION_MAP_POINT_LIMIT = 5000; + +export type ObservationMapQuery = Omit< + ObservationListQuery, + 'page' | 'pageSize' +> & { + limit?: number; +}; + +export type ObservationMapPoint = { + observationId: string; + formType: string; + createdAt: Date; + synced: boolean; + latitude: number; + longitude: number; + accuracy: number | null; +}; + +export type ObservationMapResult = { + points: ObservationMapPoint[]; + /** Observations matching the filters, with or without a location. */ + total: number; + /** Observations matching the filters that have no usable location. */ + withoutLocation: number; + /** True when more located observations exist than `limit` returned. */ + truncated: boolean; +}; + +const MAP_COLUMNS = `observation_id, form_type, created_at, updated_at, synced_at, geolocation`; + +/** + * Same filters as the Observations table, so List and Map always show the + * same set. Only rows that stored a geolocation are read; `data` is never read. + */ +export function buildObservationMapSql(query: ObservationMapQuery): { + pointsSql: string; + pointsParams: Array; + countSql: string; + countParams: Array; + limit: number; +} { + const limit = Math.max(1, query.limit ?? OBSERVATION_MAP_POINT_LIMIT); + const where: string[] = []; + const params: Array = []; + appendObservationListFilters(where, params, query); + const whereSql = `WHERE ${where.join(' AND ')}`; + return { + // One extra row tells us whether the cap cut anything off. + pointsSql: `SELECT ${MAP_COLUMNS} FROM observations ${whereSql} AND geolocation IS NOT NULL AND geolocation != '' ORDER BY created_at DESC LIMIT ?`, + pointsParams: [...params, limit + 1], + countSql: `SELECT COUNT(*) AS cnt FROM observations ${whereSql}`, + countParams: [...params], + limit, + }; +} + +function finiteNumber(value: unknown): number | null { + const n = typeof value === 'string' ? Number(value) : value; + return typeof n === 'number' && Number.isFinite(n) ? n : null; +} + +/** + * Parse the stored geolocation JSON. Rejects out-of-range values and the 0,0 + * "null island" fix that some GPS stacks report before they have a position. + */ +export function parseObservationCoordinates( + raw: unknown, +): { latitude: number; longitude: number; accuracy: number | null } | null { + if (raw == null || raw === '') { + return null; + } + let value: unknown = raw; + if (typeof raw === 'string') { + try { + value = JSON.parse(raw); + } catch { + return null; + } + } + if (!value || typeof value !== 'object') { + return null; + } + const geo = value as Record; + const latitude = finiteNumber(geo.latitude); + const longitude = finiteNumber(geo.longitude); + if (latitude == null || longitude == null) { + return null; + } + if (Math.abs(latitude) > 90 || Math.abs(longitude) > 180) { + return null; + } + if (latitude === 0 && longitude === 0) { + return null; + } + return { latitude, longitude, accuracy: finiteNumber(geo.accuracy) }; +} + +export function mapObservationMapRow( + row: Record, +): ObservationMapPoint | null { + const coords = parseObservationCoordinates(row.geolocation); + if (!coords) { + return null; + } + const syncedRaw = row.synced_at; + const syncedAt = + syncedRaw == null || syncedRaw === '' ? null : new Date(Number(syncedRaw)); + const updatedAt = new Date(Number(row.updated_at ?? 0)); + return { + observationId: String(row.observation_id ?? row.id ?? ''), + formType: String(row.form_type ?? ''), + createdAt: new Date(Number(row.created_at ?? 0)), + synced: isObservationFullySynced({ updatedAt, syncedAt }), + ...coords, + }; +} + +export type LngLatBoundsTuple = [number, number, number, number]; + +/** + * [west, south, east, north] around the given points, or null when empty. + */ +export function boundsOfPoints( + points: Array<{ latitude: number; longitude: number }>, +): LngLatBoundsTuple | null { + if (points.length === 0) { + return null; + } + let west = Infinity; + let south = Infinity; + let east = -Infinity; + let north = -Infinity; + for (const p of points) { + west = Math.min(west, p.longitude); + east = Math.max(east, p.longitude); + south = Math.min(south, p.latitude); + north = Math.max(north, p.latitude); + } + return [west, south, east, north]; +} + +function median(values: number[]): number { + const sorted = [...values].sort((a, b) => a - b); + const mid = Math.floor(sorted.length / 2); + return sorted.length % 2 ? sorted[mid] : (sorted[mid - 1] + sorted[mid]) / 2; +} + +/** Great-circle distance in kilometres. */ +export function distanceKm( + a: { latitude: number; longitude: number }, + b: { latitude: number; longitude: number }, +): number { + const toRad = (d: number) => (d * Math.PI) / 180; + const dLat = toRad(b.latitude - a.latitude); + const dLon = toRad(b.longitude - a.longitude); + const h = + Math.sin(dLat / 2) ** 2 + + Math.cos(toRad(a.latitude)) * + Math.cos(toRad(b.latitude)) * + Math.sin(dLon / 2) ** 2; + return 6371 * 2 * Math.asin(Math.min(1, Math.sqrt(h))); +} + +/** + * Points to frame the initial view on. A single mistyped or stale fix (another + * country, a test device at the office) would otherwise zoom the map out until + * the real work area is a dot. Points further than `factor` times the median + * distance from the median centre (and at least `minKm`) are left out of the + * framing only; they are still drawn. + */ +export function framingPoints< + T extends { latitude: number; longitude: number }, +>(points: T[], factor = 5, minKm = 5): T[] { + if (points.length < 4) { + return points; + } + const centre = { + latitude: median(points.map(p => p.latitude)), + longitude: median(points.map(p => p.longitude)), + }; + const distances = points.map(p => distanceKm(centre, p)); + const limit = Math.max(minKm, median(distances) * factor); + const inliers = points.filter((_, i) => distances[i] <= limit); + return inliers.length > 0 ? inliers : points; +} diff --git a/formulus/src/database/repositories/LocalRepoInterface.ts b/formulus/src/database/repositories/LocalRepoInterface.ts index b3e840d78..3ea856140 100644 --- a/formulus/src/database/repositories/LocalRepoInterface.ts +++ b/formulus/src/database/repositories/LocalRepoInterface.ts @@ -8,6 +8,10 @@ import type { ObservationListPage, ObservationListQuery, } from '../observationListQuery'; +import type { + ObservationMapQuery, + ObservationMapResult, +} from '../observationMapQuery'; /** * Interface for local data repository operations * This allows us to abstract the storage implementation for testability @@ -46,6 +50,14 @@ export interface LocalRepoInterface { query: ObservationListQuery, ): Promise; + /** + * Located observations for the Observations map, using the table's filters. + * Reads the stored geolocation, never `data`. + */ + listObservationMapPoints( + query: ObservationMapQuery, + ): Promise; + /** * Query observations with structured filter AST (SQLite + local indexes). */ diff --git a/formulus/src/database/repositories/WatermelonDBRepo.ts b/formulus/src/database/repositories/WatermelonDBRepo.ts index 310f83107..8e0096512 100644 --- a/formulus/src/database/repositories/WatermelonDBRepo.ts +++ b/formulus/src/database/repositories/WatermelonDBRepo.ts @@ -26,6 +26,13 @@ import { type ObservationListPage, type ObservationListQuery, } from '../observationListQuery'; +import { + buildObservationMapSql, + mapObservationMapRow, + type ObservationMapPoint, + type ObservationMapQuery, + type ObservationMapResult, +} from '../observationMapQuery'; function parseTagsColumn(raw: string | undefined): string[] { if (!raw?.trim()) { @@ -442,6 +449,56 @@ export class WatermelonDBRepo implements LocalRepoInterface { }); } + async listObservationMapPoints( + query: ObservationMapQuery, + ): Promise { + return profileActivity.run( + 'Database: listObservationMapPoints', + async () => { + await this.ensureColumnIndexes(); + const built = buildObservationMapSql(query); + const started = Date.now(); + const results = await Promise.allSettled([ + this.observationsCollection + .query(Q.unsafeSqlQuery(built.pointsSql, built.pointsParams)) + .unsafeFetchRaw(), + this.observationsCollection + .query(Q.unsafeSqlQuery(built.countSql, built.countParams)) + .unsafeFetchRaw(), + ]); + // Drain both reads on failure before releasing the profile activity. + const [pointsResult, countResult] = results; + if (pointsResult.status === 'rejected') throw pointsResult.reason; + if (countResult.status === 'rejected') throw countResult.reason; + const rawRows = pointsResult.value; + const total = Number( + (countResult.value[0] as { cnt?: number } | undefined)?.cnt ?? 0, + ); + const truncated = rawRows.length > built.limit; + const points: ObservationMapPoint[] = []; + for (const row of rawRows.slice(0, built.limit)) { + const point = mapObservationMapRow(row); + if (point) points.push(point); + } + logger.info( + 'observations', + `mapPoints fetch=${Date.now() - started}ms`, + { + phase: 'query', + counts: points.length, + formType: query.formType ?? undefined, + }, + ); + return { + points, + total, + withoutLocation: truncated ? 0 : Math.max(0, total - points.length), + truncated, + }; + }, + ); + } + /** * All local observation rows (including soft-deleted), for backup/export. */ diff --git a/formulus/src/database/repositories/__tests__/LocalRepo.test.ts b/formulus/src/database/repositories/__tests__/LocalRepo.test.ts index 2147eb428..2ccabf174 100644 --- a/formulus/src/database/repositories/__tests__/LocalRepo.test.ts +++ b/formulus/src/database/repositories/__tests__/LocalRepo.test.ts @@ -64,6 +64,27 @@ class MockLocalRepo implements LocalRepoInterface { }; } + async listObservationMapPoints() { + const rows = await this.getActiveObservations(); + const points = rows + .filter(obs => obs.geolocation?.latitude != null) + .map(obs => ({ + observationId: obs.observationId, + formType: obs.formType, + createdAt: obs.createdAt, + synced: false, + latitude: obs.geolocation?.latitude ?? 0, + longitude: obs.geolocation?.longitude ?? 0, + accuracy: obs.geolocation?.accuracy ?? null, + })); + return { + points, + total: rows.length, + withoutLocation: rows.length - points.length, + truncated: false, + }; + } + async updateObservation( id: string, observation: Partial, diff --git a/formulus/src/services/FormService.ts b/formulus/src/services/FormService.ts index 80bf4a9ef..a245cc044 100644 --- a/formulus/src/services/FormService.ts +++ b/formulus/src/services/FormService.ts @@ -17,6 +17,10 @@ import type { ObservationListPage, ObservationListQuery, } from '../database/observationListQuery'; +import type { + ObservationMapQuery, + ObservationMapResult, +} from '../database/observationMapQuery'; /** * Interface representing a form type @@ -366,6 +370,15 @@ export class FormService { }); } + public async listObservationMapPoints( + query: ObservationMapQuery, + ): Promise { + return profileActivity.run('List observation map points', async () => { + const localRepo = databaseService.getLocalRepo(); + return localRepo.listObservationMapPoints(query); + }); + } + /** * Get observations with optional WHERE clause filtering (for dynamic choice lists). * Filters by data.field = 'value' conditions. age_from_dob() is handled in formplayer. diff --git a/formulus/src/services/__tests__/FormService.test.ts b/formulus/src/services/__tests__/FormService.test.ts index c3c327cc4..64cd3b6ac 100644 --- a/formulus/src/services/__tests__/FormService.test.ts +++ b/formulus/src/services/__tests__/FormService.test.ts @@ -108,6 +108,7 @@ jest.mock('../../database/DatabaseService', () => ({ getObservationsByFormType: mockGetObservationsByFormType, getObservation: jest.fn(), listObservationsPage: jest.fn(), + listObservationMapPoints: jest.fn(), queryObservations: mockQueryObservations, deleteObservation: mockDeleteObservation, saveObservation: mockSaveObservation, From ba30d8a52e6e0755d5e0f451770f556498c81036 Mon Sep 17 00:00:00 2001 From: najuna-brian Date: Thu, 5 Nov 2026 02:34:07 +0300 Subject: [PATCH 2/3] feat(formulus): add offline-safe MapLibre observations map Native MapLibre map with GPU clustering; cluster counts are bundled images so they render without map fonts or network. Basemap comes from app.config.json maps, bundled mbtiles/pmtiles or style in the app bundle, with OpenStreetMap as the online default. Everything shown is read from the device; the network is only probed in the background. Refs #553 --- formulus/package.json | 1 + formulus/pnpm-lock.yaml | 140 ++++ .../scripts/generate-map-cluster-badges.py | 61 ++ formulus/src/assets/map/cluster-100p.png | Bin 0 -> 1077 bytes formulus/src/assets/map/cluster-100p@3x.png | Bin 0 -> 2218 bytes formulus/src/assets/map/cluster-10kp.png | Bin 0 -> 1176 bytes formulus/src/assets/map/cluster-10kp@3x.png | Bin 0 -> 2862 bytes formulus/src/assets/map/cluster-10p.png | Bin 0 -> 873 bytes formulus/src/assets/map/cluster-10p@3x.png | Bin 0 -> 2132 bytes formulus/src/assets/map/cluster-1kp.png | Bin 0 -> 843 bytes formulus/src/assets/map/cluster-1kp@3x.png | Bin 0 -> 1606 bytes formulus/src/assets/map/cluster-2.png | Bin 0 -> 466 bytes formulus/src/assets/map/cluster-200p.png | Bin 0 -> 1167 bytes formulus/src/assets/map/cluster-200p@3x.png | Bin 0 -> 2866 bytes formulus/src/assets/map/cluster-20p.png | Bin 0 -> 952 bytes formulus/src/assets/map/cluster-20p@3x.png | Bin 0 -> 2758 bytes formulus/src/assets/map/cluster-2@3x.png | Bin 0 -> 1258 bytes formulus/src/assets/map/cluster-2kp.png | Bin 0 -> 963 bytes formulus/src/assets/map/cluster-2kp@3x.png | Bin 0 -> 2427 bytes formulus/src/assets/map/cluster-3.png | Bin 0 -> 480 bytes formulus/src/assets/map/cluster-3@3x.png | Bin 0 -> 1525 bytes formulus/src/assets/map/cluster-4.png | Bin 0 -> 442 bytes formulus/src/assets/map/cluster-4@3x.png | Bin 0 -> 824 bytes formulus/src/assets/map/cluster-5.png | Bin 0 -> 496 bytes formulus/src/assets/map/cluster-500p.png | Bin 0 -> 1234 bytes formulus/src/assets/map/cluster-500p@3x.png | Bin 0 -> 2859 bytes formulus/src/assets/map/cluster-50p.png | Bin 0 -> 942 bytes formulus/src/assets/map/cluster-50p@3x.png | Bin 0 -> 2741 bytes formulus/src/assets/map/cluster-5@3x.png | Bin 0 -> 1318 bytes formulus/src/assets/map/cluster-5kp.png | Bin 0 -> 1030 bytes formulus/src/assets/map/cluster-5kp@3x.png | Bin 0 -> 2454 bytes formulus/src/assets/map/cluster-6.png | Bin 0 -> 483 bytes formulus/src/assets/map/cluster-6@3x.png | Bin 0 -> 1594 bytes formulus/src/assets/map/cluster-7.png | Bin 0 -> 384 bytes formulus/src/assets/map/cluster-7@3x.png | Bin 0 -> 989 bytes formulus/src/assets/map/cluster-8.png | Bin 0 -> 475 bytes formulus/src/assets/map/cluster-8@3x.png | Bin 0 -> 1591 bytes formulus/src/assets/map/cluster-9.png | Bin 0 -> 491 bytes formulus/src/assets/map/cluster-9@3x.png | Bin 0 -> 1625 bytes .../src/components/map/ObservationMap.tsx | 629 ++++++++++++++++++ formulus/src/hooks/useObservationMap.ts | 121 ++++ formulus/src/maps/BasemapService.ts | 165 +++++ formulus/src/maps/OfflineMapsService.ts | 165 +++++ formulus/src/maps/__tests__/basemap.test.ts | 153 +++++ .../__tests__/observationClusters.test.ts | 72 ++ formulus/src/maps/basemap.ts | 239 +++++++ formulus/src/maps/clusterBadgeImages.ts | 40 ++ formulus/src/maps/observationClusters.ts | 104 +++ formulus/src/types/AppConfig.ts | 33 + 49 files changed, 1923 insertions(+) create mode 100644 formulus/scripts/generate-map-cluster-badges.py create mode 100644 formulus/src/assets/map/cluster-100p.png create mode 100644 formulus/src/assets/map/cluster-100p@3x.png create mode 100644 formulus/src/assets/map/cluster-10kp.png create mode 100644 formulus/src/assets/map/cluster-10kp@3x.png create mode 100644 formulus/src/assets/map/cluster-10p.png create mode 100644 formulus/src/assets/map/cluster-10p@3x.png create mode 100644 formulus/src/assets/map/cluster-1kp.png create mode 100644 formulus/src/assets/map/cluster-1kp@3x.png create mode 100644 formulus/src/assets/map/cluster-2.png create mode 100644 formulus/src/assets/map/cluster-200p.png create mode 100644 formulus/src/assets/map/cluster-200p@3x.png create mode 100644 formulus/src/assets/map/cluster-20p.png create mode 100644 formulus/src/assets/map/cluster-20p@3x.png create mode 100644 formulus/src/assets/map/cluster-2@3x.png create mode 100644 formulus/src/assets/map/cluster-2kp.png create mode 100644 formulus/src/assets/map/cluster-2kp@3x.png create mode 100644 formulus/src/assets/map/cluster-3.png create mode 100644 formulus/src/assets/map/cluster-3@3x.png create mode 100644 formulus/src/assets/map/cluster-4.png create mode 100644 formulus/src/assets/map/cluster-4@3x.png create mode 100644 formulus/src/assets/map/cluster-5.png create mode 100644 formulus/src/assets/map/cluster-500p.png create mode 100644 formulus/src/assets/map/cluster-500p@3x.png create mode 100644 formulus/src/assets/map/cluster-50p.png create mode 100644 formulus/src/assets/map/cluster-50p@3x.png create mode 100644 formulus/src/assets/map/cluster-5@3x.png create mode 100644 formulus/src/assets/map/cluster-5kp.png create mode 100644 formulus/src/assets/map/cluster-5kp@3x.png create mode 100644 formulus/src/assets/map/cluster-6.png create mode 100644 formulus/src/assets/map/cluster-6@3x.png create mode 100644 formulus/src/assets/map/cluster-7.png create mode 100644 formulus/src/assets/map/cluster-7@3x.png create mode 100644 formulus/src/assets/map/cluster-8.png create mode 100644 formulus/src/assets/map/cluster-8@3x.png create mode 100644 formulus/src/assets/map/cluster-9.png create mode 100644 formulus/src/assets/map/cluster-9@3x.png create mode 100644 formulus/src/components/map/ObservationMap.tsx create mode 100644 formulus/src/hooks/useObservationMap.ts create mode 100644 formulus/src/maps/BasemapService.ts create mode 100644 formulus/src/maps/OfflineMapsService.ts create mode 100644 formulus/src/maps/__tests__/basemap.test.ts create mode 100644 formulus/src/maps/__tests__/observationClusters.test.ts create mode 100644 formulus/src/maps/basemap.ts create mode 100644 formulus/src/maps/clusterBadgeImages.ts create mode 100644 formulus/src/maps/observationClusters.ts diff --git a/formulus/package.json b/formulus/package.json index b391ce30b..e70a6e5ef 100644 --- a/formulus/package.json +++ b/formulus/package.json @@ -27,6 +27,7 @@ "prebuild": "pnpm run sync:version && pnpm run generate" }, "dependencies": { + "@maplibre/maplibre-react-native": "11.5.1", "@notifee/react-native": "^9.1.8", "@nozbe/simdjson": "3.9.4", "@nozbe/sqlite": "3.46.0", diff --git a/formulus/pnpm-lock.yaml b/formulus/pnpm-lock.yaml index 94de7cce0..dd27ed450 100644 --- a/formulus/pnpm-lock.yaml +++ b/formulus/pnpm-lock.yaml @@ -30,6 +30,9 @@ importers: .: dependencies: + '@maplibre/maplibre-react-native': + specifier: 11.5.1 + version: 11.5.1(@types/geojson@7946.0.16)(@types/react@19.2.18)(react-native@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@20.2.0(supports-color@10.2.2)(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.4)(supports-color@10.2.2))(react@19.2.4) '@notifee/react-native': specifier: ^9.1.8 version: 9.1.8(react-native@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@20.2.0(supports-color@10.2.2)(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.4)(supports-color@10.2.2)) @@ -1374,6 +1377,33 @@ packages: resolution: {integrity: sha512-Z7C/xXCiGWsg0KuKsHTKJxbWhpI3Vs5GwLfOean7MGyVFGqdRgBbAjOCh6u4bbjPc/8MJ2pZmK/0DLdCbivLDA==} engines: {node: '>=8'} + '@mapbox/jsonlint-lines-primitives@2.0.3': + resolution: {integrity: sha512-0SElaV0uMxEnxzBhhX9WTuPyUeMsAN/SS0i16tjuba4/mio63MG9khjC1a0JAiPGXAwvwm4UfHJURCN7nyudQg==} + engines: {node: '>= 22'} + + '@mapbox/unitbezier@1.0.0': + resolution: {integrity: sha512-fqd515fjBmANKGGsQ286E2Wvj/XvDFpGzwJxq4CI6jMQue6Oy04uCKp+JWKF00xRTmk6cEu1jPJ9p3xqH8YWqQ==} + + '@maplibre/maplibre-gl-style-spec@26.4.4': + resolution: {integrity: sha512-hG4RZR4PBd/+6A3ODdZ3AnQRF106ZQk1y3bhwDg8bH/A4Oz2tnRUJVVWs9BZa6gXZ1d3UKe89C+Mm+olGBELvA==} + hasBin: true + + '@maplibre/maplibre-react-native@11.5.1': + resolution: {integrity: sha512-RtHdbPzVSgDvH8JBMfX8ND5Qmkx0mhmk6hpLEuv+I/NbP3DTp3W8KOGrNTpmx3CkZ/SMaJyNP11FulBCRIkKSQ==} + peerDependencies: + '@types/geojson': ^7946.0.0 + '@types/react': '>=19.1.0' + expo: '>=54.0.0' + react: '>=19.1.0' + react-native: '>=0.80.0' + peerDependenciesMeta: + '@types/geojson': + optional: true + '@types/react': + optional: true + expo: + optional: true + '@napi-rs/wasm-runtime@1.2.3': resolution: {integrity: sha512-UMduMbqO5s5zF2NkNacMT/yK5Y5QiKvWr2+50bzIIxFDwVJ2h49b+oyjaCGPhJxd2/gC2x39EHv/gHVuu36x2Q==} engines: {node: ^20.19.0 || ^22.13.0 || >=23.5.0} @@ -1775,6 +1805,24 @@ packages: '@tokenizer/token@0.3.0': resolution: {integrity: sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==} + '@turf/distance@7.4.0': + resolution: {integrity: sha512-ODkopQDG1m/U6Mx7OMmShncaCvneomwX3lZY1CDA7x40bhDdTTRYP/n/6LicZVyIO4/oK+aXkov4NOtWYthA2g==} + + '@turf/helpers@7.4.0': + resolution: {integrity: sha512-7PAwLZqOdRzTI5g9bHvUwlloAXPDH/mlajtryk0tw4ZwGMtmXsAyF4QundsAMfy4u48Fyd3AUqMXY0SMxqJGWg==} + + '@turf/invariant@7.4.0': + resolution: {integrity: sha512-OAsc3qdNx+tRqzWmMNFMnVlWWACIqnYqIejZKvb2oKkKPYJJrURPXZ6OdrGD58ljJMolLoqwIZGSx3VndaEjxg==} + + '@turf/length@7.4.0': + resolution: {integrity: sha512-vQsJLuL7uy8m6HAMuJJKoiOHYY1de1Hd6mY/fM90qfu2RyIrkoF8J4bRUb72XZNCVnvob/eHpUMdhZeoUhSyfQ==} + + '@turf/meta@7.4.0': + resolution: {integrity: sha512-3cLUvlEyDuSnMSzrjhaLAEiYR8xhbfWyTVQlrlEw40xL81d4KF4PqUWbjTXKpXZStdYbet2GCurl79KMypNw6g==} + + '@turf/nearest-point-on-line@7.4.0': + resolution: {integrity: sha512-eKTKf/qODIsPankYMZrNfI3LjLaVtQvYV/1hcj9kH/Zr3GfLUszevjhTRDZtMgTFrv8+awW3+MyhmN+PJGuLdQ==} + '@tybys/wasm-util@0.10.3': resolution: {integrity: sha512-F3fo1MYrRJYL3zER0OUOmkutjr1Vp23m7OsSgp7nq4SP6OqX6C/56XFIPAl5bt3zaBRjmW7SGz3u/6LwFpYcOg==} @@ -1796,6 +1844,9 @@ packages: '@types/fs-extra@11.0.4': resolution: {integrity: sha512-yTbItCNreRooED33qjunPthRcSjERP1r4MqCZc7wv0u2sUkzTFp45tgUfS5+r7FrZPdmCCNflLhVSP/o+SemsQ==} + '@types/geojson@7946.0.16': + resolution: {integrity: sha512-6C8nqWur3j98U6+lXDfTUWIfgvZU+EumvpHKcYjujKH7woYyLj2sUmff0tRhrqM7BohUw7Pz3ZB1jj2gW9Fvmg==} + '@types/hammerjs@2.0.46': resolution: {integrity: sha512-ynRvcq6wvqexJ9brDMS4BnBLzmr0e14d6ZJTEShTBWKymQiHwlAyGu0ZPEFI2Fh1U53F7tN9ufClWM5KvqkKOw==} @@ -3696,6 +3747,9 @@ packages: json-stable-stringify-without-jsonify@1.0.1: resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + json-stringify-pretty-compact@4.0.0: + resolution: {integrity: sha512-3CNZ2DnrpByG9Nqj6Xo8vqbjT4F6N+tb4Gb28ESAZjYZ5yqvmc56J+/kuIwkaAMOyblTQhUW7PxMkUb8Q36N3Q==} + json5@2.2.3: resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} engines: {node: '>=6'} @@ -3938,6 +3992,9 @@ packages: resolution: {integrity: sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg==} engines: {node: '>=16 || 14 >=14.17'} + minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + minipass@7.1.3: resolution: {integrity: sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A==} engines: {node: '>=16 || 14 >=14.17'} @@ -4286,6 +4343,9 @@ packages: quickjs-wasi@2.2.0: resolution: {integrity: sha512-zQxXmQMrEoD3S+jQdYsloq4qAuaxKFHZj6hHqOYGwB2iQZH+q9e/lf5zQPXCKOk0WJuAjzRFbO4KwHIp2D05Iw==} + quickselect@3.0.0: + resolution: {integrity: sha512-XdjUArbK4Bm5fLLvlm5KpTFOiOThgfWWI4axAZDWg4E/0mKdZyI9tNEfds27qCi1ze/vwTR16kvmmGhRra3c2g==} + range-parser@1.2.1: resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} engines: {node: '>= 0.6'} @@ -4858,6 +4918,9 @@ packages: resolution: {integrity: sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g==} engines: {node: '>=12.0.0'} + tinyqueue@3.0.0: + resolution: {integrity: sha512-gRa9gwYU3ECmQYv3lslts5hxuIa90veaEcxDYuu3QGOIAEM2mOZkVHp48ANJuu1CURtRdHKUBY5Lm1tHV+sD4g==} + tmpl@1.0.5: resolution: {integrity: sha512-3f0uOEAQwIqGuWW2MVzYg8fV/QNnc/IpuJNG837rLuczAaLVHslWHZQj4IGiEl5Hs3kkbhwL9Ab7Hrsmuj+Smw==} @@ -6560,6 +6623,32 @@ snapshots: '@lukeed/csprng@1.1.0': {} + '@mapbox/jsonlint-lines-primitives@2.0.3': {} + + '@mapbox/unitbezier@1.0.0': {} + + '@maplibre/maplibre-gl-style-spec@26.4.4': + dependencies: + '@mapbox/jsonlint-lines-primitives': 2.0.3 + '@mapbox/unitbezier': 1.0.0 + json-stringify-pretty-compact: 4.0.0 + minimist: 1.2.8 + quickselect: 3.0.0 + tinyqueue: 3.0.0 + + '@maplibre/maplibre-react-native@11.5.1(@types/geojson@7946.0.16)(@types/react@19.2.18)(react-native@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@20.2.0(supports-color@10.2.2)(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.4)(supports-color@10.2.2))(react@19.2.4)': + dependencies: + '@maplibre/maplibre-gl-style-spec': 26.4.4 + '@turf/distance': 7.4.0 + '@turf/helpers': 7.4.0 + '@turf/length': 7.4.0 + '@turf/nearest-point-on-line': 7.4.0 + react: 19.2.4 + react-native: 0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(@react-native-community/cli@20.2.0(supports-color@10.2.2)(typescript@5.9.3))(@react-native/metro-config@0.83.1(@babel/core@7.29.7(supports-color@10.2.2))(supports-color@10.2.2))(@types/react@19.2.18)(react@19.2.4)(supports-color@10.2.2) + optionalDependencies: + '@types/geojson': 7946.0.16 + '@types/react': 19.2.18 + '@napi-rs/wasm-runtime@1.2.3(@emnapi/core@1.10.0)(@emnapi/runtime@1.10.0)': dependencies: '@emnapi/core': 1.10.0 @@ -7209,6 +7298,47 @@ snapshots: '@tokenizer/token@0.3.0': {} + '@turf/distance@7.4.0': + dependencies: + '@turf/helpers': 7.4.0 + '@turf/invariant': 7.4.0 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/helpers@7.4.0': + dependencies: + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/invariant@7.4.0': + dependencies: + '@turf/helpers': 7.4.0 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/length@7.4.0': + dependencies: + '@turf/distance': 7.4.0 + '@turf/helpers': 7.4.0 + '@turf/meta': 7.4.0 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/meta@7.4.0': + dependencies: + '@turf/helpers': 7.4.0 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + + '@turf/nearest-point-on-line@7.4.0': + dependencies: + '@turf/distance': 7.4.0 + '@turf/helpers': 7.4.0 + '@turf/invariant': 7.4.0 + '@turf/meta': 7.4.0 + '@types/geojson': 7946.0.16 + tslib: 2.8.1 + '@tybys/wasm-util@0.10.3': dependencies: tslib: 2.8.1 @@ -7242,6 +7372,8 @@ snapshots: '@types/jsonfile': 6.1.4 '@types/node': 24.13.3 + '@types/geojson@7946.0.16': {} + '@types/hammerjs@2.0.46': {} '@types/istanbul-lib-coverage@2.0.6': {} @@ -9529,6 +9661,8 @@ snapshots: json-stable-stringify-without-jsonify@1.0.1: {} + json-stringify-pretty-compact@4.0.0: {} + json5@2.2.3: {} jsonfile@4.0.0: @@ -9864,6 +9998,8 @@ snapshots: dependencies: brace-expansion: 2.1.4 + minimist@1.2.8: {} + minipass@7.1.3: {} mkdirp@1.0.4: {} @@ -10201,6 +10337,8 @@ snapshots: quickjs-wasi@2.2.0: {} + quickselect@3.0.0: {} + range-parser@1.2.1: {} raw-body@3.0.2: @@ -10840,6 +10978,8 @@ snapshots: fdir: 6.5.0(picomatch@4.0.5) picomatch: 4.0.5 + tinyqueue@3.0.0: {} + tmpl@1.0.5: {} to-regex-range@5.0.1: diff --git a/formulus/scripts/generate-map-cluster-badges.py b/formulus/scripts/generate-map-cluster-badges.py new file mode 100644 index 000000000..55850f8e0 --- /dev/null +++ b/formulus/scripts/generate-map-cluster-badges.py @@ -0,0 +1,61 @@ +""" +Regenerates the cluster count badges used by the Observations map +(src/assets/map/cluster-*.png). Counts are images, not map text, so they +render offline without map fonts. Requires Pillow >= 10.1. + + python scripts/generate-map-cluster-badges.py +""" + +from pathlib import Path + +from PIL import Image, ImageDraw, ImageFont + +from_root = Path(__file__).resolve().parent.parent +OUT = from_root / "src" / "assets" / "map" +SCALE = 3 +FONT_PX = 13 * SCALE +STROKE_PX = 1 * SCALE + +LABELS = {str(n): str(n) for n in range(2, 10)} +LABELS.update( + { + "10p": "10+", + "20p": "20+", + "50p": "50+", + "100p": "100+", + "200p": "200+", + "500p": "500+", + "1kp": "1k+", + "2kp": "2k+", + "5kp": "5k+", + "10kp": "10k+", + } +) + + +def main(): + OUT.mkdir(parents=True, exist_ok=True) + font = ImageFont.load_default(size=FONT_PX) + for key, text in LABELS.items(): + left, top, right, bottom = font.getbbox(text, stroke_width=STROKE_PX) + pad = STROKE_PX + image = Image.new("RGBA", (right - left + pad * 2, bottom - top + pad * 2), (0, 0, 0, 0)) + ImageDraw.Draw(image).text( + (pad - left, pad - top), + text, + font=font, + fill=(255, 255, 255, 255), + stroke_width=STROKE_PX, + stroke_fill=(0, 0, 0, 110), + ) + image.save(OUT / f"cluster-{key}@3x.png", optimize=True) + base = image.resize( + (max(1, image.width // SCALE), max(1, image.height // SCALE)), + Image.LANCZOS, + ) + base.save(OUT / f"cluster-{key}.png", optimize=True) + print(f"Wrote {len(LABELS)} badges to {OUT}") + + +if __name__ == "__main__": + main() diff --git a/formulus/src/assets/map/cluster-100p.png b/formulus/src/assets/map/cluster-100p.png new file mode 100644 index 0000000000000000000000000000000000000000..9e23f3228b47e9c342736eba3d06d605499de32f GIT binary patch literal 1077 zcmV-51j_q~P)ax4wI^?}KXS~koW!xSAv@OvO&ck>3&iTIc zJKy*FD2{^w0HqX!5S#?1^r0gmpp=3!mN$>tg}$dR`Zwf*^8l&2$sW;3|Ns{5}B40A4C8D(bM=Y}Ww1a&-2@L&>9(BaATuzzLvjczF1FEEc=f z*Vh*SP-ie00N|q6>-{bq4&R!an@g^&tfV?SJHH0-qTB5*;(7kPsi~>O<>lqn($dn# z^z?MZ;c$E;2*UaN1Ic3$L1497<@);izC!^oSd8kP$|pu6D=(*pKF?? z_xJaYOOn*)^ZCBr+uPHdo0~rca5)eN+}z&YzT42y@S)r7em542{kE{M@H>Fl7-Jj& z4#3L*t^v4^x4Bb^04Ql{Y8n|C8MzLiV{>yeF)=YQ51<0T3xPmjIUElE0pMbuy-V?U zd^->b1OdFSs_I`|U0q)RumKP}9?xfrq8v6hHhxlFU2Wuf9(8qfJ$}FcXHgX2;(7iU z8yoD^o|CvqH@%TrqHa=GluWb(Ek2s;*wg;^|?dm9@YcS=i3 zZIzXk#kpM05RFDR9S(;=2vH-ENGg-bWIP^^BOZ@CbzQfuuCA(fyPZ=MMbmZtJOJxa zB86!k5JGMk3psmV*vDHf5aH$GMUU- zRh?B;H7m=qNe~1+&xn!ZIOE~rVRn6eJwpg#R;$%qUS1AD2t-jdGsZ|doz4(K&PkGF z6GhQVDK$!xR3b@IF{RXWR2uw=fQ;vPhKK)kwC=C3uivStsCY(I)hF|eJmqvc?a5^F z*R{2^`y9s|)YjIP&Cbq>0BCJ(ZJF6@=ELFey=XN0eS3TRHzt$m(#*_ES8HqQ7Xab_ zGyn&TF~a{tCo!|xY%m&)6aZvd-WnYpz2W!!KNuJocw3g`r+q$Oi^Jit_w@9931Bl6 z3jNaC+xzO`;$lQm6m5Kb{LN@Ix-maL{}ZKjl@Nles;YCTRO&z!MdSYd{+h1q58|C_ zWsc(j;7TwUj0_D8{RrT~f6Tv@QGqXc9EtxM>71~B6?l?Q00000NkvXXu0mjfjGp@w literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-100p@3x.png b/formulus/src/assets/map/cluster-100p@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..cc322d2beb0e16a99841825717b8d2fd47c4d11c GIT binary patch literal 2218 zcmV;b2vzrqP)GqgV1Oy`BL0vNh1*{B+;}*`6En| z76e)nCHaGegl1?JjY%VAQAS9j76zu!0b;?SE9E1F!dMLD%NUHkKX~`;`pq_m_!+n5 zNKbm_-t&I%y?gFG_uPBV1=eVd)@Y4>P{erYcLf8801yJe7k~wT3BVYD5ddZ{#r8Vo z1OP|^paifEfFFx%1z-Ts1mJ5IaC`tH1K7;sIHCmr{Qx=uJa_RMg~aws<%j{i!Cn$4 za#jGE=E@BDGo_ z8yg$DE;2GwYBHJ3!^6W99UUD*&z?QwD|rUM9RM#pjO}&Gk+MpOqYIOhlcP?aJeif1 zmGxRoOiY+qEEZ|C+K&AE{0dg&D**TH;B5wwO_|Zd<0<9L)czLA^_U}d;p-3Tw!5hzrK3)YW>2(g2l?C+qZAGQ?j{s zFl7La6B(6CRW>v<^qtj3s;#YkCY4H0Q}1}!L)ac|bXP{#I{;Md#0rLCn8Sw;r+0UE z|Mu9iV{eH>qLrA(40hf^Vq#+Cy?gh56&)QN0st0^#d`hvbzNp==EbO}sI&3$@#pvM z-Fu_Cxw)S_oRgFDdQDA@#@E+Z%oarhc+CUY9wV^}G78#mr>3SVv|6p^`0?YJK|w*j zq+GApk3>X71k-$Vb#)ud8UXx@!i*-E48t(BwY49_#>TD#0JGU_*|TR)MM+7?qk(~e zvFYjQxv{Y^V^>$#@Xebyn-vO$EHyP%0RVAvaqCSclexaWzK6w?0~i$$yOVWr9JQqi zz}o;a0c;150w4^)AnOUbedI)woBjLuZ)<63DN?CaN>Vm4F)>?IRP;$oO3ELW@!lkI zvv1$Nw5?mWZXkSdaq-{o-o4vt?;*3qepgmj_GEs3eqk9-2(4j+BBO@cr^x59aj``}%>b|umk4Y}{jku!C08$U=H+TrawEiSjg5`H6l-t$ zgez-kXyCSl7=~ezlar%pK>@MDy-7L!>_UuBy#W6FGRjFzOpK)Ggt4))8J3*`Fkx@- zlAE5Mp8wDlG?u{F9#f8w3o=FkHLQCoXD82gR%t6C&ip8qN^TRfzrWu=$vB$FxbnWf zK3X2cUG@NWkT)p@?4)_uP4ARY-jE-;r+PNo)3Qfg`S|#_&1jZNrT#Q8Aa)FZ#1$xM zKaZaVpa;(=?GVJSivXa} zZLYMtMytQSKc}d1O(V9M@VcpQ{u~-HFuJkjGo!q+4;c0Kwki$ z_OYCG8cxQ~&(G%DjYgwQZx<4qtYwz^QN3RO8;2`#hB>j+9JU%Ghchu#Q&T3YcS>C7 zG+YCchNrORc)VcP-sTVpqMV6|36oqdmjZxHCJW?+zMXn{GMUUqW^!_Jmga@T27uDi z(kG67u&Agg3jl81xY2Ad7-p7e4~sV`XNE*j`}+D`Y}~kUJ?r|0^MZPC5bT2;dGz-7 z+C(db#0G#*p;rg~FZ& zFw8b?1MgajzTnBXIoL*8Rpoq3_UfKLe?H#Y+B!fg4jee}CgoX0VKK58fW>IyJ0u|` zH#av)p-{-kb2T+J9elGR5cb#bvK#_;la<{x1pr*Sbm=pCZa97V^bSgP8-QQ3TOW}C zAdyJK=g*(dr6aMGm6e}U&wL{w_E$f2ShBnxgu}l^75`vPEMLAg@5b;Y&c2vdc#3o z4}iaZUq%kFnAOjv=r%Pq^(7@G{r>Xh%bz)0RqOTo5sgOkmz&dA7!?vBUjo@*!Vsi0dY2*iFfufyFP%E z#Q1Coa}q#EY_C_27>fyY3A_z3q$wt^AG)ILN} z0$OZkMO|p^K50v-SPNB`1Y@PF4ZZ{~jfz<@TVs=$oXzy*q_|$%KJgfb{BNL?0)PlWClBt_B^m(F0C*NaYGGlaLO%8q zfVTm>0wA(>HYy_f`qyZA0Qnk?<~@_iG*MPo)(0Si<2V4w)@rq#gM)+LPE1TJc64;y z1z^x=dvUFxg04eDS}d|tn2FP`r74k9Wut);NakQ z0G_U^s}quwlRwzl*l=5|)(6AG!}s>~_8!j6%q#=AQCeF1z9dPkzP`Q>fJQE*LVi>P zF#s|Es4u#o$PvajG&Fp^v$Jz(GMR?<_xFFdSS&xtCCM2dAOCrKd)p1*27rvBqN0!e zem|?Ks`@NBIl1%r_&8WwTWc;XEQ|*5yiTXPot>R+1W*Ei6Gic@`uh4YN@=ZJmcP@& z0YEq$1^{Vsad9UvFYlwCo}M2GAxe(pn7l_$>(RIs3O}VZoSK@d?(FP*Q4~diQW^#jKFxs$ z{XLtTn_mJrijI!1lq88KgfN*%v9YnS;_mM5q1Ws6GscKQq44;8zCYvR+9<*5D0jVj*fT$dH|0AtO8*CnZLQmaU6U; z-&qC`N+}l%2E(U<4g+93&vQJ_lTavhqSx!w8yg!dy1TpYn$2cQPEL+ftJQwk*Vk90 zQmI1e>FFtUyS>xr^YKkhO?RD6=Wm3NlP7{;jB%1AF*yUL;rDvICkY7&>cqrEg&+t_ zk|dE*8s+tRk0nWBlu}|e8V!L!;Mi<7ucf7>?HddR|H#P5-q_gKPogODEiEk-qobqu zgTWv>I5^k?@c5rddfnx6J+xRX-^;{&Zhn4#&1SQ$19(B!k#{^EPq3}6ZAcWwTcJ?s zvC(K8ot>S%zqPgH25=L=Qvd`2u>cxuHk$*$8vw2Vh<<{Y7l5hN>L@`FC;-I7#BBBV z_sw3MuN2ye1$Hm3nwpy+0t*x#10KCc=BLH#%yapgs zCi|tz88KW1@G^k%h`3BmO;G?jvZFU;sZNsjUjtADpj4ix%Jymi>Hk?VFX|y@4^c`Z qy#+kaM`W3tnIqEZ&dBzqz2h%D=$m^Njzup300004fNvEn^*t9ZLq2NN8#; zSW7ig2Z)bBLYCM@RE$Ky2P+Z%zeWM@B)^n209pXl08o#BVm#RZ7z1E50CTd4-2ff{Pypx| z;8_;{zXD*Pqg@Ul7eMcD1;um--eCp62Y?3vN3s|KvNM$cE&{k~;9PtG_>uSMqFw-X z04e~~YiWON06+l16~J@=HtUq zJYw3NX<_Hior`I?va(Ws?AWoZ3^%=S;>!a-7yyxh{VM?cTSwbW0CW276jM_Zu~_Ws z>FMd{;^Jc4-QC?Im&;o#Dk>Up-MYmGw*x>v(H8))g;OFL%_p>+o10q^8ylNVZhrt! z@S<>vEdgT^Tj}fT>-ye%?@1&Q$&?8bCfEywLKEI1jMDKwKNb*?NMx0oni?4&A0O!8 z;9#xofy&BCd3t*K+3f7>Qc`6NAR53I0NP#zE5%SyOo*78#Xe(iZ*RSO_wJ~T8#jiU zn3x#Lb#??`06<7ch(~U2Zi1Ve+vtHF^6~L;&C1I9bzEHB%$S&%thTncZlc!~0XY65 zfMQ~xh+7CCgaRTE2n6fatqZQIs`~w=O`GNn0uTTQCHev0-rg>!PoLgM0a2^fnyjp> z(z$cze&Xutn&RcQFWUa(-nD|vZ&3FhYJLQ>@hV9LwFDdX5b8xRoS zo|&2XMo37A2ir*|lPSEty$w~MZUDR}z(oi01*)puWa4Ab^h+%yNgIS@iSOMA%GwN-i(;;A{OU4V#*IWr?}C= z%9Sf;UA}xdnFXY^wY4ibIr+;OGiLnBh;e;r;jOpc3iS8)A4l&~Q&Yd<145~%r$?Qb zn0V~!)vNbu-L`Gp=8qXOhFWc+Cs@WW!J z@Am-y2H-G&9|1t6QuXfGv4gXmMvoqCxq9{L*-yCJUnQoyqZW%)0Kf^r5*?sV(*v~m z(*WgXR904YPb?OHyk^as!wQAMSc}JvC}z03yW0f@22P+2IXO9(J3Bj7RTJTZA& z02I=G1qB6_4Gj%H(fjD==owD{h&On6$ z3jmPI<*oDP&C9rP<32`H?!rc^3B9z1x^L2e%dXw}B7 zv~cIno&WTcL55p?Ai>T^`v1MXyF-vGc^ zT?6SVuD+wA<1yP{V`F2K>FMg~I=Z;H_%{VNI*5&}f#IOe1EL+Y$ ziL8o>ir%5tT2)omkKy6rpEfo&{>04hG&R4q`6BwpkjZ4f)9E=21WEe*muJB#oS15F zZ|`Pyx|so+g#(3x$-GN1D5FM=GA}47*f?|MOn0C!4G|?uPd}?`jO65G2>={Cc<_=^snlmatA`UP9n>VPt*vdDJbAJcF^?U1$u#Il zvO!CKFcy@ zX`R2n|F~19PQA_OG!goaD6u-LkwmIX09@f+`lyoE{~UQ&<8T6n+tG!Eg)+7wA|hfc z`seS#OCOI10231vL3nsLmv}2JEv@4@pFg8(*RD0j#>Qr=RI1+X+qa*}$;r7)+vd)l z>vi<#(KV)~rgS}=04(E)_g1o~CA>=?eb4KERYwTvnS#TmN1w~f%d5~gXmxdUGwYxxBqV%ZR8(|}-Y;Fc)Gs?b z`%QsBK=UbM+4vZSS&n|FNy9j0*tc)rx2#^-x^?S(rtd5OZHnaNCM<2b8{zs`IUItJA;3i`Mm9feF4$Oqidazy831194)p*wfkB+1A6u!yz^{*1x2rBq<>w zA(*~IDwS68(#IE!FgCh?zP!nzMT`E@($eyXIVTXAn3x#K^!Ep#E9Lx%_0|OfK@!g# ze)PP60`#SNd#Ps~6&1C=si~=zD)RN~*N2=xfBv@$h2n#nnwmcxKYn~|aB#2(3rKWy zbQTjcYXVSh1SqHI__uH0{&C5YC4XggPG)B28!J|<2tfY>5>8s)b!&ie?vUKja|6l) zFh^N0Uc6ZAs1{3l{vD9K@qJpTa?KYa!8bC@n3mjf;!> zdv9;Anr@7ooSc;l7cQK};}|}b5(|svh%RCZOYw|8g-9?U$6)kX;?U60N&K_K<>loK z2Am}}c0RAdu!9o-PzB&CJO|&W=*u-|(080vuIIpbI1D>=4_>O8f9ifP^AY2JISvev zHpn9!hNpeH?@6hVyhp_Ig6a1uXLTHc0Pm#Ab+YSiwS+meeV_6#0N-f|e+*_m^+e>@ zlY?}85alFTSr5G2UvfF4?@KN-vM^l)JUIsbl52!UXoN;+gq{uk7k5GQjE*cqK>z>% M07*qoM6N<$g3GRQm;e9( literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-10p.png b/formulus/src/assets/map/cluster-10p.png new file mode 100644 index 0000000000000000000000000000000000000000..bb1866c375927c639130ebcdfe8f9391f076abfc GIT binary patch literal 873 zcmV-v1D5=WP)ku`#kxL(D`&g3*n#L3ae`#4w4eVZ(}r>@^lhaEU@f z!ooNUh|w;zaiM0k3A&g7hChi6kZG9IhSp+J+6R5dO&KYJf4=18o|~Im7SiR zo{xZQN-6#=c}u(6+uJ*OdwW0F+1bf&Z*On8u3MX)p8gVW{C+AQHmzRG8o@=)Jy;7-E8yy{;1Fo8;Ni-V0xwN!&$8nsIi*q&s@jMTpluoB_ zrPJx0WmzwJp6AzEdw^kCmebMEadm5J>vlLC{-azjH(b{}C>D$5?(XgbqPn_o-6KZK+R0jFXcSPc^42A_|~d zt=4O`S|bz++2L?l`+PoWG#a*2%BWN-H4(Ae+S(qoEbG)T3@aE6K57_-)>_+VBDuIQ zp4M8LB+}dw&vo5=u~;m1b#*;iC=^0K0f>e|q4u@4wcpF-^0$dZ;tSyAY&QG;z`(#9 z@C#4_>LSvriu1+UwryFKWdIG^ws#j77k}#O>$^TUICvu*4i9HCnHxt(N0s^c`Jc7c zYo=-5bsWdHzrTOtIF8RW&EJ6Bc`;lDYhz>MU~g})3iywYkMAj^K95GDS0*MVM#jd* zT+=j3BocG=di@6xQA#P5&*$$94Gnz^{CZB>-ajoaKo{^9@CMKZ5Rb=wKsPWLi^bjr z-UOl-;%JWd|3hp2P>^95Eq-(V)FnU6Lu`KmB-Bd%=eVec00000NkvXXu0mjf`B|BM literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-10p@3x.png b/formulus/src/assets/map/cluster-10p@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..5de97e4b9af52f0f309f3cd7608454c472659306 GIT binary patch literal 2132 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 IM6N<$g4JNyxc~qF literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-200p.png b/formulus/src/assets/map/cluster-200p.png new file mode 100644 index 0000000000000000000000000000000000000000..8fc97cd79de4d23977f6023a4919cc2858cd3d61 GIT binary patch literal 1167 zcmV;A1aSL_P)lW3M`)yyBAR;2#O)g~r6`*P#bQlW#raNvE<`yS5A zbKd7Uz`sTaAz)edU?bB{$Apk{ac2IRXXRRg|6u82Er7RlI^9P~rSby+RscKzG&D4@ z07?Ly)9dx005}WaRlD8J$Y-+wRA@9Bn^voB25<_%(P!L44r`9co#z{k#&3s)hW_+; zJad6S;DOWW^Z@vFw z_kV>JPslRkOU6c?a1p0?0`>l5T519*_4CLSVDm)SaE3brTa4U(L?Wu3o!#?RNk% zg+ig)+S+<7Nm7DRN(dqGR4Nr`Syo+CRFp+2C669Gdc3^6OaUMojXnlIlu9K`CezuA z7cW{wQDh4W3bMPpx@^0C@j= zS6ANwu(iFtoygA4&fgmON=iNr27{qAYgm>A$8j*5%~zaG=e=hQYg!G9Y&P4kOG`@+JswZU>-ElzjEu~A zz1{_f!|?}z3ILW%moEMGygK4?xejzhMvwtu0niA*_%wafA|n8(l;gBcQ+crf?ovwE z2qCWlFzEIAW06Q?9YD}-x8HAXZ%@g}YSL&luPBvDc71()0YCu2##8DEnW={x!!RI( hJhA@gzvfVh{|D$Z*^msZ(&zvH002ovPDHLkV1m@SCprKC literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-200p@3x.png b/formulus/src/assets/map/cluster-200p@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..0801ba6748305f48acd2501b518a39b77d4954ac GIT binary patch literal 2866 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 M07*qoM6N<$g2{<7s{jB1 literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-2@3x.png b/formulus/src/assets/map/cluster-2@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..b5aa6e363a6b4db8010257335351e768868ad9d3 GIT binary patch literal 1258 zcmV-yo^Ch5|T?8n|4E+L7&X#*R0;DvkM z_ndp5b3e{KFFck1foN9Yn+9wK)&WLf1qc9hz&Nm!Y#2R|3*-UM$FW-m?gM?mgJi&% zfj5C?R0ePtXaj<41(UBlVM$F*)$iWD+q`4Pj!l`Fnd_F8mR2SwCw+Z=eIp)^XFL=N zg(UMwKm#zp+I_VXs1#3jcJ}8NE?oG|@An5oG34I8ds9b`9(8Fnnv;@m8K7GY$Y!8I zbXZbSQae38?F&7jG&D5aG#Cu0B%s38z`Q3qDk>_fURYRI6ax(p5C3Vi*)Heg zmX(#&M*{&E85xflNU{s4g_%+=KCRd5ovf;=a!0Xw z0LWSPHF0cBCe!-7yu2;qLr+i7-H5(bEz=y(6EPSO(REAui$H-;>~{NJtyUXenoE~1 z^+~p)YG(%EXJ7=_0vM$CKHzsi9PQ@-iwLZspkVun6DNvgq$(gAM3!!DZnkaRx^;sv zuv)E`y9j7F>%+pr!f^67{-D~XC8 zI3O-RtyX)|>2%(ZFUR`&`s;Gp0lblnnpp?z7lUXtnv->Pb-gl3V`Jl>Ua$XH0@=UH z?GSTG-+C(yP0iztyn&YwTuE`zkTw%#@xjh{$12NS55 zp9thF8RX2FGc7VmTU*;u1jr#EJsJCCuMA?h+nZ&O_V)Inw6ruCH>klqU7TRW~21wQ@+h50<#AQ7ga}&94T-(9Pf*AprfN> z*kZBN`ThQ&ir^Q4CSWYqQH>_ADlp+n@8ZRaABfAZv$NBi0Eieb6Y!{+nS&zDmzI|9 zymIBrA-!I&1z28QUU4`aKlpq;RUoHNpKel`D?p7Jm`c$I27_N13!i5C| zYr)c1nL4g?Tofu2v?frk52_=h10qOj(2S3aIEmkPyNT0iyXzk=_i*l?^SJ!a(f9`% zprn*&ng)PU3d1nC$>IN^uC7i3Zv!6!9{`y`2mo7ZYHFj^YHbI~l9FDXzo$0j_-i1py$XMvk;u!6ii)oj#KO6`xj(nJx3_@$p`oFlLZQ$(&}6gO?f{>A zz25Kg^YeRwcQs8TKR^G0&*z(wQr0BC{8b{MlmZZEXJ=85$1}39u&}P{`XTUUdV2aB z`}_OHz=aTkl#~>N5SEjZlj>kF*wx(Jd`}2*0ECrN!fv-~)z#H5!!Y8eX^Ldwq$KW( zqoboArl+SZqoboQT3T9)`}+FYoKEN6j*gCRf!)i?O9{kH(@Y5lgI+16EGQ`G27Yt7 zT(x<5dFeShId^qkkGb9MLen%0{eJ&OEEanVnAZfM>pDPjXJ_YlG#ZUJG&BqY?*NYe z{{Bbda5%EEvhrkkd3gh98Xg{g&0?`+Ha0f?7zhNOt*@^?jYJ~PmzI{E%+Ag}bvPVe zAT?PqD5V5Y=JWY}jz*)gwzjr!fcMhU(f}MiJw4-wVVJ9{tDF1#`v-2fy9dbecsyyq zC%~sgMMYoD&(Hq>d;xq06eo{>HZd_FfsebpyB{7N9zL(Cs_Fw8fO|kCP&6FMdq^78UNz;z|_%F4Jr6N~P!K<~BKjxvG*^MM|Oq^9D%(u|xowuuY|u l&@?SI!Hp%)WnyHm;cvauaYs@g#-soM002ovPDHLkV1l<;-$(!e literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-2kp@3x.png b/formulus/src/assets/map/cluster-2kp@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..629d8c3705fde3f2d0c18c1fcb6295cf5f2180cc GIT binary patch literal 2427 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 literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-3.png b/formulus/src/assets/map/cluster-3.png new file mode 100644 index 0000000000000000000000000000000000000000..5df57affa13779e1a9cbe30d0a4fbf32f80e4bd5 GIT binary patch literal 480 zcmV<60U!Q}P)eh*%aO59MAwblRhI(Sx2m1cA5` zi69|K5CsPBB!tkr2vM-OvLUz!MGv*UE>G8OPBX)wVdgh~xCkMHKuWo8Y~}!~N&qHs z%{0wh9*^hFY&HunmrE@W2uvoE$+@LLD2f8G6$*t8yWQ?U*Y%%zX7|} ze%I^u`X|FMPJr7|O0cjwnutM6mPsVr42fJk z+>(M30oV*cpCtH20Dl4K1rS~eFj)ZJBfAn2wOTDNFE7_>G#Z6Mp^$pL-r&T$Q$BrFFjYe}QH#hg2;^N}A z*4EZ5fk0rM)oW^My2OC_G}S16pANEb-##-tK|@2sS>B2xKZC*WozLeBQeD5_ALKah z5Tm1sHkmLEkZb^306-)XrCq#uu|6v+O9B8!qw(6|!-p-g4Da2$H!Bnh(<&<~b;MqH z=gys(fq{X$M8yMOoNt#F2h2*^*tTt3ky@>m)Be$;M}K4VZUZ<2;70&9`mm#;<4<-l zy%t#mb?z2m>&oNfk1HZ;K3@|zjf=@l*M8hW_>(aFdhIM z07^(25HPvsF#s)llvL8w(}l;6AKzI~QL!2T{C@xZ-o1Ozgu~$l>P`UM$$|lZSpZj} zJYPq^Jn=@cSjcglV(ZqeYxeKozeTUta{w?tKJMPVd-qS&-^fCGmbwmlixOB%OG{U@ zG;rd?iEf!p))<8pW3q4B^Qzh!8X7itb#;AKQc{x7dsPw3MwsGU9Dr5ih(s=z%Qrkc z?C^Lz0i{wYlSm{Y04OLZP}bGel{uZxN7t`kr%tOTQ8=4QJ(I!cg$dDV0n|iOmPjO` z+S=Ont*xy!9LFi>^If}knapN0B}FF*j)cLi2T)4t8CK>1`~jc{!0K4W`T6-d{r&x4 ztyr-l8vtA`mrtwJw#?1VQNsHLz*Hi~v<5&`433cy5WfPrOTyI2Y`Y8q+-`Tk=ko=t ztE<-nfK)0?9~~Wa-nemtCP+77@TWSkjy4nug;b?dWvW!F%*@P8F=O`{Ns_$)x(T?S z0d!H@&dyGj=&)(iCf1qdiAGao$4n;Ehfb&Sh|}pja_ZEn527Vfo{U8j*W~2?$W5V8 zNO?P)5SWLwF*Gz3*~$$YHmqcHRz+iAwrF3rY?+LGF*P+c$8_ft1rxdF^!D~fR;ttK zmT%m+aV3)+cLG=o;1#k{MF8H35DG~NR2Y9jw3HAln&bdJrd026IKIuv$&rx~;&xS4 z)%T%LXdxaSh{a-|&1O45chl?j204y9{xpQ?C zT-24wkwBC}eppO=-DMUvyp%#7D$GWB1*dUe!p zx4S~2&;rMC^2*9eU44DMAulg45-lfBp6sOsJ$ZDQTz!+0TAJXLO6Av9tM#8`?o2+P zFWA`Fc!8HFt5eNQ6NCWM{eJ)a>C>kN?%%&ZTUc1Aq|6#M6dykH8I8tkd-m*U=M`0x zgonbZ-nEyLYpIk^_obz!`5eb7_RhD>n?L=>O zGLjw&Q^@dsQc#U7_4k=P0B-_#^(nRS0*S^U;;5nL`Q=JLNG_L@7-HfF0YVFoL;<{% b7f${I6wjs8SkQzV00000NkvXXu0mjfL8sLd literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-4.png b/formulus/src/assets/map/cluster-4.png new file mode 100644 index 0000000000000000000000000000000000000000..692741a5c72f2eb553f3aa22418d444c254b83e3 GIT binary patch literal 442 zcmV;r0Y(0aP)Es#dFQ0Q*7+0ARmjlv0d{4(s*$mEZ682_ZCq*_@wSGtRjtqU~a_ zcoU6AJ)KVH$?Ns50?>*b0S0=SnFx k91b4ah(scymN4as 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 JPDHLkV1n8uX&V3l literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-50p.png b/formulus/src/assets/map/cluster-50p.png new file mode 100644 index 0000000000000000000000000000000000000000..06cbb797210066b9a9f5070105da3c9513b9c3d1 GIT binary patch literal 942 zcmV;f15x~mP)GUap zV*r`DKk7T<|0JVYXE(y(a6FMnq@vO2qgX7K+S}Xv&FOT809*m^j^FS9@#yF%9gD@1 z`}_Nk>~{O-0AA82C;vH>M)ZC%nS2}w1nv$G4}Uy9KJFPC8~dcYyZZwG14~OwcU4s_ zPEJmKX0ceNcXoDuPNh;$nwpwsW@l%004RVV0B&uiT4^j=Hzt#5HjzmD-qzOU0q`z> z8vw3x&MAO5j*pK|H#Rn+0ImTL0DP3okrLl^9*CGVH!pV z0q30c_xH<^B#~e+_;y!U*KM6n$2K=N9>5C#E&=2KphjzyWx2hhqr+S%6e??LYv0)I_CBxI zJAH9+p$3D&Ls1lIE|)80v)L-=oG``;oO3RUA|y$2_zsj(&!Mkz(LS~ZT2j&=_Y z4t7Q&k$a4>%W}C~DwRr?EiElN0A)o{p7izgUCCy%5`ZiKt0YM+iA3VDqA2&KrlvLl zydI0imL?`9?g01&KmkCJbB>znTAfa(pL5PvS64&r?d{WZb8~)0Q3{KTi(dhFb7f`a zTUAv{E|=?5S(b0rRA(?4+>}x<#$Yy^XTss|uTK+0y}TVBkLSCSlaov|8ciJ@9-b~Q zFMkhUWMN^!0N}kqAn-%YNAY+(S@RL+oM=+M4q!@K8|7Vt*XyMK`T^YR>FK!*;3j~p zn!M55+iL(Y0KjFl***j?3cy;IAoX^6HUvTV`ynBuA={pjb>u0L_Aj=-09G+sTjskO QGynhq07*qoM6N<$f{(1N#Q*>R literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-50p@3x.png b/formulus/src/assets/map/cluster-50p@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..434a5378863653aabe13fd31371e3b80fc10c937 GIT binary patch literal 2741 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 literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-5kp.png b/formulus/src/assets/map/cluster-5kp.png new file mode 100644 index 0000000000000000000000000000000000000000..b5e55d12de17187dee251aa78ce19b4bf22dbc17 GIT binary patch literal 1030 zcmV+h1o``kP)kuC!bMoZ~cj! z1}H)ZLrVGP$&)8PD=scRswj#kgpi7&i0jv{-?@7A>WI(hJ5^CpQIwyb{|yinLP+4C zLa8W<-0Q%>AEBx$ySuwd=gyrgaX1`eetv#g2w^h}LvuQvTL9_l>6WalEEn*|EJ!RC zvzY2*W<9527)O9LAo5iE%zHT%3WdU_PoMS!dFIRpGJrQaIy$bbuC8taHD0gR3Y^Hy z%xreKT#dl%fN;57UwFOVZlI*KwN(Ms{reX}NW(BhQc}`Uo6RP|;qXpfU0p?OZEZPl z%x<^e12$u^m_=1pX|vg^eSLk$FJ8QOW@u=rIVC0K1&_zGqpGUR$;nA>YHB)ex7#i4 z?d|bOAGZ#$q@|@f-EQ~c^XJc>+S=NZPN&n++}tc`Yiln}O-&71t=31HrpahDD*S%` zmsM3&1=ZEn7lOfHAMg_J>eABEP9ze!ABjZlKryfZg!YjJ9J#rDBQx?9)v`Ptdo`OVGE ze}ci_%;@OooXh2E0yH56nn_5)^z`(m$K&}HSWHMr7)eY_Ou2ULn%>aRZ~{2=;K74v za&q#KvuDqKw6L%+KQ=bD6bJ;?N=r)*&CJZ)>gnl;xZUo+8D*2M2$%SS+6a$4W{{Izpk)Qc+RS z55U_}N?|^SG82w?XNe0Q`&cUQ8juWZ1GlA=H-r!t;2j_VxFdwPA*DQKT9#&#b$1W0 zXF0=D%2ZX|r*SXdJBX%fPwb8Rjz73^z(WD}53N3Z4HmOEU;qFB07*qoM6N<$f)cCs A&j0`b literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-5kp@3x.png b/formulus/src/assets/map/cluster-5kp@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..87a363b58c759ca9441bd2c182b25fd32550d387 GIT binary patch literal 2454 zcmV;H32F9;P)2j@(V*Lfl;Hhc-;o;mlPbMNnd zkKgxw7Z{gu8J7=0c<*sN&t-jG~>X%F75#009fmpZv}83z~EQ~&eDW) zd;tUlaDD@zH2@RC@dtL{yNbA&whK|zX^XJdsO-M+PkYPOl>4&Ou&h$Vg zlWmQPiu%n+HaU9q=#M*g?D&oh?SBnX)xya9M0zlF>Qtw35@MRh@mQ+h>FGI%9*f1| zA2b@xkg1zoy?V8t6DWSwz}Zucba!{3C=dv&0HC9zqj%@dofqC<&-VB5oD%>n1Tc$H zhJFAI0ImU0jV5q9&Ghl(#~p9*06zn$0}wO9>-AA1N}PWrbcZX!0OoOi69Dihv?%|r z1NY>~6BfAVbqfO}&hs_NA^OG`_O z(9lrdpr9ZRS65fN-riodLZRp`D=T|iQBm&T)cJi01AjN4dHM#Q$@rX_W-B?@RPpMmW4Um zW@BT+&&bF~0D%1b{QEI6F|}golS8d;0Y0FAQkkW4KyujH|r~4KmAA9rPvl zsi~>oOC*w;`WEc$?CeWONRZsWe?OHv@|2X6&&6W#552v;FWFk0UgzfMXj4>Fv?U-Q zKnMU29z1A|jEu}^Zf>T;c7TBP)DTxQBsDcPd-W#iJl=Nhnx<-;4YLTZyu4gqUS2NG z%gd`0i^W%%^Qr-S1K?`_7imOmX=&-o&dx4nwI?hr%p?=Fv$Nw%rP7Ua=g#$@#m(^W z@M9d%@;IQ4nIvBu(sOchN^^2@N_Cv`goJ-~d;>rf^?`f#?D>NAcZG$8HHL0RAP`vQ z<>e)ZgoJq0-!^R6@NHXL+Y7eXLULYRq}u`j($mwg=-Xr0u3ZrTaN@*?pE^1^dQC_b z&8h%<09a*YQHc&}VGH1o0O0KGY^zi%)9A$Ug(&DEn2kb2m@XZ zfO90ZV`a4|<#s(Omp*9&kZX>q2AiO*Ryuy`rw3hKUHz{hYHQ0k)eP?3xudOcOrJj8 zwXm>ov!kP<4dZuKl9$BFwNcKG)nrYxOx1-R?A^O}F`v)31OTa2T7B!*Et-2y1MrIu z{x=+592^Af1mSWwPv$?`SRJ;*4CCx>oA;XrxS+zpxR2w ziWMv7unQIx6jU3#nYy|<1<@z~KvGiD>7t^dS~^~~Y}ssyM6#C0gp7XHk;;T z)at?b_;|_f+qbn*R3s8D5R1htnbW4RWvBNITs;F^6~oqX=+L29t~R|=2lDoI$sPK^`H8>3ztGRm&wcUY#WRl_IkKv%s%np)pPxGbXf&Fk z#Kgptm6esvWZjnlZkc#*9n@jBwzhV~#Ke59R4RMufGecX_$L!P`4xmu zPM~>jNlD3&+S}k4Dn0G2~QR0hH0f$&)Al z7Zeoqw=-wX+$J?P-D=X&(VLc*_TPYjfHV$hWh1E0wvam85CGh~dGqm_HEX^Z930dD zfQ5wx@6@SNqUh*oKaS>cWDvl2L{ss$JKUy-0>FJjB_@()uAzr+B?*6t9`@Z#_IBjF zj3E*ft^km8u8|Y-a?-Is-SeuB>&Dag8%>k07*qoM6N<$f?fQ!R{#J2 literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-6.png b/formulus/src/assets/map/cluster-6.png new file mode 100644 index 0000000000000000000000000000000000000000..d050bcac96fae8a6e8a87d4f70ddd9fc458960b0 GIT binary patch literal 483 zcmV<90UZ8`P)k`Fm54;h!6^e?nk51LciZXu`KI!I2=w(rP3jQ6f@rpBoQG1PX~j+*Iuu86bJ-z zg+k$-h)6D%s{sfxGyKdfL=-elGZTx&)O0%C(ljkttJO}z;qWH_cfIwl*rQOB$>gKO zVzFp8o6ox4?kmS}-b|;{WvkWtY#7GHX0rhlMFD_^jYi|ka=H9fEEem!uD>Xk%k|Z2 zCE0BDEdZ670l(i50D7m>IUbM4-vMMiw#;ZW`qpl@j{!VT6a_3I0C4R%&JWjhHvq6+ zueVadb=}_pL_`20LL!m46OYG>v)SyjTCILmRn@FiDj(+a`Oj1;^*R!X1d0CQw@2=q zrdhXb``ofD$F}YBd_Mmkz>}@cv}25j$R2>lx~@kilgR~uArYNF7=CU6DWytV+KL#kL>xn;5SOsbD3Qh}K{Rv=ksoGA!m^kV7nA*11m=E-anXbj zi5VG=OfZXVYN8-eGQ&p@gk@iR&lLng3bnMfg|_zN?$15VSyw7W{jtS2x#@e(J@2_s z&pq$+KIZ~&>_1@4OU8=<$N)qF2nFB(una)Q{l0cE;Q*8XQUGiUFwOwrcL1XR+^+*n z5`atq5kbV50km?f^~%6x0!XJj1OmZ^%*@Q~@$vCusZ<(iGMQ}SRN-ru*|N(rl+TkN~Q7w6<-Ws!^>pLCuFj`yu5E*E|;5SS=Qt6c&=T$)}NJ?bvZUR zwp=ckm#S2%>c+;#Uui%(oz5H=7xx7PXzzN#93_*arKNpkx7(d8%d$?V)2&vkYv~oE zuhXYb-=YECym|9C>i$Onc>W&73jjn0Kmh;@!!TE_T*(g)4-W-^^78WL+qZB3v07kd zWo1WOTiY0EtJP{{LPA0`*NgxV7c7`m;^gP&r|;RbCjkI-I-R+)va*93_X~h;0elBw zl5}3Wbm_h~Wf_La%galpV&wiF7#bkux06OuQPI1^udAzj=x{h()YwE0I0GPrbar%f zJhED?4xvyO4ggA}GM0*o4hD>j5SvUU6COBlAc@>)Z*TYI3coDua=AR&+1X!j+_*6u z0Q7pj^+o))8W<^YR4Ubu(9lpG0I)2}_Vx8WUhD4f>FJqR<<`u)z$lfK+=6#uYzvj{+D9CZ*LR z74O`+Ge}bO!Gi~r08Uj@RA>m_tY#*YX{Ds3C1zk?;IoK`h%f+H zTwGjHC=_2>EEWehPV0hB!eU}$J)&RgB0qa$Bbljwel zL?R&m!oq^pAK;+C^!N8q5kEaWeJiO`oC=itDX?My;PH42y-x-Q2cP;|`L%$VB~Ej5 z^RV4+cak(ea^%P^YN@li%n)!w>@ax+IXO8yqoSg`d!x0rb@WArGSJExNH)=`uA-tM zm(S-D9ufigfD_tB$t&XX`C;eJpU?G9W-u%_G&IneHx;bX`;$0jWo6AoP$efPZ@zKk z#t9-peN7+`Y-nt3Jdv838cWAhS^3N#`vxkMWOY&igc*%So5f;rWM^k513*$zlKj}Q zW4moOn{#Pt$qoPtg+fwLP_X;@_3IyIWMp_5wzjsm_w3oTE!4a}(di$I?V^j|nKNhZ zy4`NiTFy)!kH>TF+_^TNVtQyj5`reS&D`EVcX#*1y?ggY5)%`p@$vCu9*_4gzHqzU z9*ssbcKrD9`uh6%0qWK~fF`=stmmv91|Zk>cp;TaBlqpww|&c&Et^ClQN+^Hl6_`o z#?;l-rS+rmrntku<5e8|c>vx6usg_)1upI=^DxkgtoM_Mhy%2P=$%!}0^kt`WaV{# sxnelBm2iX?!o30;m%2v0vHz?64Wc~pL2YsExBvhE07*qoM6N<$g6SXk{r~^~ literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-7.png b/formulus/src/assets/map/cluster-7.png new file mode 100644 index 0000000000000000000000000000000000000000..17a1f985f054f1cf9a3aae48f81fca860b854b99 GIT binary patch literal 384 zcmV-`0e}99P)73 z05bRZ}Ri|7oABJH#VT_U1`op&EgGQs#ab5SxFpT-SeE?TN zh?__xQV~K_N~O|+QtB<2%hdpE5)qa|j4=Sn`@Y}vJWm2R;+)U!|84@nIOi};Gg>Sb z@0C*HbUN)O5{a4mi>9e?E~R{FHk&?x<7IWe-ZafP5TVe)c@C3*dB>x;S}e+jb4Wbu1R!nb!Y4 ecOhbxHp>_CsbKj|^CB<+0000`@oge)S*del8&ku&F`O%_l5NyK8(L{YI^zWBw z91h2eZEbDuN#F1H-%*JCnT|-dZCVFD1suS0IjnzDtq`Bjcim>QSpZg6R<_2*#^#jR zxlEJTvMdk*e#kKyAIL#p^ z#l`i>$w~ErzRNa|5oGFQCwDrXue)5X^AhlSy|%`mo&`uIlPRy)dsB(~35c3W<_+LwfWpE;o388EBrq{C zac^m9NtW+F!1qU)*m4Y+x}TMfj*iO}6&23|#N+WjUDt0bu|EPaGs(OKlmHYJ6%~3s zo~sfV9UYwuhr_abcLW*CB4Yve-^5*AT~|s=ON#(@c6Q=^zyFRBdrxeWX=Kg=X8=k{ zN=|imcVCgf(9qD=8yg!D;YP*5#Vj&5;F5Ga9?#X{;$k~MBoc`QgTWakHk9rHvL zQbd=Rm!IkE?7S?2{{H^i?d|Qqh5J}EIm{wc0Msh_OKi}QmO(-J!w|rIFVPsyDgWlfW8&Xa#FE4M7kB=|pDkt;(3nA?zq2{vL zL*O$b$Q+iL`-k9h@r`AOSr5V2MX46cic-|uI`9SX*R$VT|10|!B@ho(E}LTs00000 LNkvXXu0mjf(6HEy literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-8.png b/formulus/src/assets/map/cluster-8.png new file mode 100644 index 0000000000000000000000000000000000000000..4a11da38c8c036fa0f9202915c9cd1cab49c5a2a GIT binary patch literal 475 zcmV<10VMv3P)}c|6|d3l}8IGLj@+^4CcSApjD9p=sI+ zzy$VnU5{Nl+@(Dzl}d+`$>h{=oY{0bJ!>=?AAxKXMFJ2AA<%U_mP{sJEtkt5-EQ|t zRn_O!YV~ap1iy>L;yXZh54L_zL)882;UP*D2R- zw{6e!&K$>?xvqO!uh)-(r%@D1fcQ^lM`|B9&@}BOkO%IYrg=T{tG{s7{{jE1pga4( R>~{bF002ovPDHLkV1hJ++PeS% literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-8@3x.png b/formulus/src/assets/map/cluster-8@3x.png new file mode 100644 index 0000000000000000000000000000000000000000..75617ecb346655acd3a5d573bb49b894b9e51fda GIT binary patch literal 1591 zcmV-72FUq|P)F7=GGP7_=zRmQm0^0r@n13`SK33)F;Y#um3FP9R8vM!?KO6O4=g*cO6W%w|}P znG(|o66?%NBr^sQmVKD%bTngEQHqSvPQGairIbS3yFd4^W2MD{{@CK1G=0y#?>YB5 z=f3B8-g|&o_8&0b8v2F;hy)MuV|-I^%M=+^iRyo}RX=)#{(+a`_3m zFMv1K0w#wZtf8Ty!xsooGnq`b($doN)EFNFkgf(u2!L7|2>qa`sVUp>sVf1p4-~mTEXM-cmOasI5?G>n%d}aIGkLFhNFOIa>AR%5vQ)M zu7CgmfPMS+UG@e90NUExe%EL;zmc{=p^%i6l%!B03U3MO&BBROTwJ_~_y&W)+}YXr zh~kq@0OtX;0(eN`&Ye5=D?M0YVPPWGT<0&C5aL8fN3SRT{rmSvsF)`J9&yZT25`$u z9HOG4Xp$893ubLP!+Sa%?RGoO!cKp|*of2D*Edf5l$4a$sTetcSO7w9wn}m%H8nMg z9!#gx{Y^Ek{(>1J&b4dTdWat%A1}$z&yVMt0s!xE6i^KygTz%;Rpk+XW@ctytJUf} zUawzZ`iaxj)bx|lXta=ab93`YQmItrtz&6v>E@c6ntT9oyWQ-80|%~{&E`4ow#6Hm zm2|6|kX2GrQp51@@H8PagTY{~t*!l5p-_A#6bcWE#o~IUQu*cCvuA&CI-M?-Wm%ie z=GeV^x5h&f5>_je8)(LiiHTW%^5n_Vii(O{o@a)7SVu?4qrH3gUK$%4qw~WKpxKuY z`U09wUyw?r;^5$5q08lBS6bGRl9CjSMpG6W8yi7g4g?@s&C28e$R~}XM~|u+8ynvd z2n2kR3p$<7$S};v%*@PuXlQ6qdV0D%H#c`9i2;D|@o{TmVd2-*j(h^(JAd-!HCiN( z9Xr-ei)2Sf$HT03J+6pQOiA4;6o(ih)8O+ z+A5+=U0q%J9Xob>Gc`43Tdt$UVzFzrTHUs7+Y|{22~q%%$z)=q(P*Z)#tC5Pd2=R~ zI7f~gDI&|xvMgIyS9jIra=E$N4gh@s{-9(5Znv8~c<|s=mSvZ6YtNoNIn?;k-b%{l zOhSod67d;^8PV(Y;}noK0J9!?RtX@mzrTNiVVIGOjErai$jr=)S*=h85hpS-QcQf0 zvSHK%2ms7+*Pn?Oi^V}JErK^N4w}lBrX3OzxRUbHYEQ>cPEO8x@Xd3BnWJWBUfSz9 zIU5540!SYzfVTjIas084E4ZZqz~}RM+1c4kUlflYKb~A}{O18PL7ba6Z}!rkC)L%} zTdBA=xE!egPz7KsU3x}DL@cdRdwV-W^&9aA%wLo!-ll7j)2C1G+P-~zvM-X#%gc*Z ztJNPY$^Y1x^}>YLPfSdddF-sGY+ztu^3n|14hO$(0a*48VG> p+z8+fgysR5xO{WJIvD(4+CL=I%u5C~S%Uxo002ovPDHLkV1n*>=tKYj literal 0 HcmV?d00001 diff --git a/formulus/src/assets/map/cluster-9.png b/formulus/src/assets/map/cluster-9.png new file mode 100644 index 0000000000000000000000000000000000000000..a25071ef345514b41c9c4c19dd30385259ef5c10 GIT binary patch literal 491 zcmVjBR{%7%^FO|G_RO>4`${8d*GadhyVaY1jZQt;-g7KgfI*< z0FE-5%+Y$iHl&n-hflqtWOCW9)m3F@}D>e_-48 zqjWlb$283sx~}uZVu9sy`O0DJdzRQ!=X6a>5%W0>}Y?sHmv$vuDqKBoc{20bqQ5{K4+syE|+) z+p;%Bj^kV!jb?n$o;_J|xm+9)5)u>}8ynHx-F=f65dj$YrW@n~jJtAbYHCz+xm--f zt*x!ScDtPbF$3rUa00-O0G=#-U}y&4e_5ylVY0AzuHNpxp$aq)J- zKY8-RcK-bNNq)G(m+L(MRsi<_^buK&Ml;5;>=FPl3?nQpE#2YGzCU0h2%}Ug69_*x zHuk{fa*<{J5r72`y*U6T60XtF(Lc$AN~PLN$!!P(3`3Zhm>4%OlgVVIWEOqsJ|&F7 zU~tQ4WMm{zGV(yc{JlgA1nyOuBr#8+YZZk^Byw*Zsi~>7H^hN}u@PorVZrTz%G2q?IZx@ z#JzgGzAZXBS_A+#n{8R8QXQF?n0V+b&+_u}l65+rIy5vim`;9~OxDT|9RR-a*E9F| zZVeWT#nIBza?V|53?saB=~6>UNlCi5WZ%1Yud2Vl{}YqRWW9Rz>NJ_NSS$`objjOG zLsluIYXC|B;MlQaLrSG`V`F1u0RY6u$46m+hlRkLpgHI`vC!ZEbE*QPKAF^z?YSTrP6CTq}CL-gNo$<+~jn9fOmTlMg*h zo32Wd0{$kj62RN*>B<;}5wa|Mm@Jx=m6a#Ey1GXB>3#sW{q@Wy!sO=WZXsKO*=)8C z3=I6?519P?{1gIYd3o7+<;s=csp*A4!n8uK7d16CDga0al78X)lTboP9 zot>T6D4i)^-t-0L5#5*26V1)d?@+^30JQ)<1n@d}c;LW+x8vgCA^~7+9FA z&r(z407jn2Sz7_%T{_uCB2j35etxn-p@@@6B;iJ*(W=+$O#=f1Q=|!}=q3Q&^Sp(K z2T-)eHHf!s5gmZvUc~)OFo5lR?B%P-nE_1l9&!8|H&>+6#C)PV`BwEYy; + /** Shown over the map when its background cannot load from the network. */ + offlineNotice: string | null; + onOpenObservation: (observationId: string) => void; + onOpenOfflineMaps: (viewBounds: Bounds | null) => void; +}; + +const FIT_PADDING = { top: 72, right: 48, bottom: 72, left: 48 }; +const LEAF_LIST_LIMIT = 200; +const PENDING_OUTLINE = '#B45309'; +const POINT_FILTER = ['!', ['has', 'point_count']]; +const CLUSTER_FILTER = ['has', 'point_count']; + +const ObservationMap: React.FC = ({ + points, + basemap, + formNames, + offlineNotice, + onOpenObservation, + onOpenOfflineMaps, +}) => { + const { t } = useTranslation(); + const { themeColors, resolvedMode } = useAppTheme(); + const isDark = resolvedMode === 'dark'; + const cameraRef = useRef(null); + const sourceRef = useRef(null); + const viewBounds = useRef(null); + const [selectedId, setSelectedId] = useState(null); + const [leafList, setLeafList] = useState(null); + const [legendOpen, setLegendOpen] = useState(false); + + const formColors = useMemo( + () => colorsByFormType(points.map(p => p.formType)), + [points], + ); + const featureCollection = useMemo( + () => toFeatureCollection(points, formColors), + [points, formColors], + ); + const pointsById = useMemo(() => { + const byId = new globalThis.Map(); + for (const p of points) byId.set(p.observationId, p); + return byId; + }, [points]); + const fitBounds = useMemo( + () => boundsOfPoints(framingPoints(points)), + [points], + ); + + useEffect(() => { + if (fitBounds) { + cameraRef.current?.fitBounds(fitBounds, { + padding: FIT_PADDING, + duration: 0, + }); + } + }, [fitBounds]); + + const onSourcePress = useCallback( + async (event: NativeSyntheticEvent) => { + const features = event.nativeEvent.features ?? []; + const cluster = features.find(f => f.properties?.cluster); + if (cluster && cluster.geometry.type === 'Point') { + const clusterId = Number(cluster.properties?.cluster_id); + const [longitude, latitude] = cluster.geometry.coordinates; + try { + const zoom = + await sourceRef.current?.getClusterExpansionZoom(clusterId); + if (zoom == null || zoom > CLUSTER_MAX_ZOOM) { + const leaves = + (await sourceRef.current?.getClusterLeaves( + clusterId, + LEAF_LIST_LIMIT, + 0, + )) ?? []; + setLeafList(leaves.map(leaf => leaf.properties as PointProps)); + return; + } + cameraRef.current?.easeTo({ + center: [longitude, latitude], + zoom, + duration: 350, + }); + } catch { + cameraRef.current?.easeTo({ + center: [longitude, latitude], + zoom: CLUSTER_MAX_ZOOM, + duration: 350, + }); + } + return; + } + const id = features[0]?.properties?.observationId; + if (typeof id === 'string') { + setSelectedId(id); + } + }, + [], + ); + + const onRecenter = useCallback(() => { + if (fitBounds) { + cameraRef.current?.fitBounds(fitBounds, { + padding: FIT_PADDING, + duration: 400, + }); + } + }, [fitBounds]); + + const selected = selectedId ? pointsById.get(selectedId) : undefined; + const surface = themeColors.surface as string; + const onSurface = themeColors.onSurface as string; + const strongText = isDark + ? (colors.neutral.white as string) + : (colors.neutral[900] as string); + const primary = themeColors.primary as string; + const onPrimary = themeColors.onPrimary as string; + const legendEntries = Object.entries(formColors); + + const initialViewState = fitBounds + ? { bounds: fitBounds, padding: FIT_PADDING } + : { center: [0, 15] as [number, number], zoom: 1.5 }; + + return ( + + { + viewBounds.current = event.nativeEvent.bounds as Bounds; + }} + onPress={() => setSelectedId(null)}> + + + + + + + + + {selected ? ( + + + + ) : null} + + + + + setLegendOpen(open => !open)} + style={[styles.chip, { backgroundColor: surface }]} + accessibilityRole="button" + accessibilityLabel={t('observations.map.legend')}> + + + {t('observations.map.legend')} + + + {offlineNotice ? ( + + + + {offlineNotice} + + + ) : null} + + + + + + onOpenOfflineMaps(viewBounds.current)} + style={[styles.iconButton, { backgroundColor: surface }]} + accessibilityRole="button" + accessibilityLabel={t('offlineMaps.title')}> + + + + + + {legendOpen ? ( + + {legendEntries.map(([formType, color]) => ( + + + + {formNames[formType] ?? formType} + + + ))} + + + + {t('observations.map.pendingOutline')} + + + + ) : null} + + {selected ? ( + + + + + {formNames[selected.formType] ?? selected.formType} + + setSelectedId(null)} + hitSlop={10} + accessibilityRole="button" + accessibilityLabel={t('common.close')}> + + + + + {formatDateTimeShort(selected.createdAt)} + {' · '} + + {selected.synced ? t('filters.synced') : t('filters.pending')} + + {selected.accuracy != null + ? ` · ±${Math.round(selected.accuracy)} m` + : ''} + + onOpenObservation(selected.observationId)} + style={[styles.openButton, { backgroundColor: primary }]} + accessibilityRole="button"> + + {t('observations.map.open')} + + + + + ) : null} + + setLeafList(null)}> + setLeafList(null)}> + undefined}> + + {t('observations.map.samePlace', { + count: leafList?.length ?? 0, + })} + + + {(leafList ?? []).map(leaf => { + const point = pointsById.get(leaf.observationId); + return ( + { + setLeafList(null); + onOpenObservation(leaf.observationId); + }}> + + + + {formNames[leaf.formType] ?? leaf.formType} + + {point ? ( + + {formatDateTimeShort(point.createdAt)} + {' · '} + {leaf.synced + ? t('filters.synced') + : t('filters.pending')} + + ) : null} + + + + ); + })} + + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + map: { + flex: 1, + }, + selectedRing: { + width: 30, + height: 30, + borderRadius: 15, + borderWidth: 3, + }, + topControls: { + position: 'absolute', + top: odeSpacing.sm, + left: odeSpacing.sm, + right: odeSpacing.sm, + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'flex-start', + gap: odeSpacing.xs, + }, + topLeft: { + flex: 1, + alignItems: 'flex-start', + gap: odeSpacing.xs, + }, + topRight: { + gap: odeSpacing.xs, + }, + chip: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.xxs, + paddingHorizontal: odeSpacing.sm, + paddingVertical: odeSpacing.xs, + borderRadius: 20, + elevation: 3, + }, + chipText: { + fontSize: odeTypography.caption, + fontWeight: '600', + }, + offline: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.xs, + paddingHorizontal: odeSpacing.sm, + paddingVertical: odeSpacing.xs, + borderRadius: odeRadius.inner, + elevation: 3, + maxWidth: '100%', + }, + offlineText: { + flexShrink: 1, + fontSize: odeTypography.caption, + }, + iconButton: { + width: 44, + height: 44, + borderRadius: 22, + alignItems: 'center', + justifyContent: 'center', + elevation: 3, + }, + legend: { + position: 'absolute', + top: 56, + left: odeSpacing.sm, + maxWidth: '70%', + padding: odeSpacing.sm, + borderRadius: odeRadius.inner, + borderWidth: odeBorderWidth.hairline, + gap: odeSpacing.xxs, + elevation: 4, + }, + legendRow: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.xs, + }, + legendDot: { + width: 14, + height: 14, + borderRadius: 7, + }, + legendPending: { + backgroundColor: 'transparent', + borderWidth: 2.5, + borderColor: PENDING_OUTLINE, + }, + legendText: { + fontSize: odeTypography.bodySm, + flexShrink: 1, + }, + card: { + position: 'absolute', + left: odeSpacing.sm, + right: odeSpacing.sm, + bottom: odeSpacing.xl, + padding: odeSpacing.md, + borderRadius: odeRadius.card, + borderWidth: odeBorderWidth.hairline, + gap: odeSpacing.xs, + elevation: 6, + }, + cardHeader: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.xs, + }, + cardTitle: { + flex: 1, + fontSize: odeTypography.body, + fontWeight: '700', + }, + cardMeta: { + fontSize: odeTypography.caption, + }, + openButton: { + marginTop: odeSpacing.xxs, + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + gap: odeSpacing.xxs, + paddingVertical: odeSpacing.sm, + borderRadius: odeRadius.inner, + }, + openButtonText: { + fontSize: odeTypography.bodySm, + fontWeight: '700', + }, + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sheet: { + maxHeight: '60%', + borderTopLeftRadius: odeRadius.card, + borderTopRightRadius: odeRadius.card, + paddingTop: odeSpacing.md, + paddingBottom: odeSpacing.lg, + }, + sheetTitle: { + fontSize: odeTypography.body, + fontWeight: '700', + paddingHorizontal: odeSpacing.md, + paddingBottom: odeSpacing.sm, + }, + sheetList: { + flexGrow: 0, + }, + sheetRow: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.sm, + paddingHorizontal: odeSpacing.md, + paddingVertical: odeSpacing.sm, + borderBottomWidth: odeBorderWidth.hairline, + }, + sheetRowText: { + flex: 1, + }, +}); + +export default ObservationMap; diff --git a/formulus/src/hooks/useObservationMap.ts b/formulus/src/hooks/useObservationMap.ts new file mode 100644 index 000000000..5ebe25716 --- /dev/null +++ b/formulus/src/hooks/useObservationMap.ts @@ -0,0 +1,121 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; +import { FormService } from '../services/FormService'; +import { logger } from '../diagnostics/logger'; +import type { SyncStatus } from '../components/common/SyncStatusButtons'; +import type { ObservationMapResult } from '../database/observationMapQuery'; +import type { Basemap } from '../maps/basemap'; +import { checkBasemapOnline, loadBasemap } from '../maps/BasemapService'; + +const EMPTY: ObservationMapResult = { + points: [], + total: 0, + withoutLocation: 0, + truncated: false, +}; + +/** Filter changes arrive per keystroke from search; coalesce them. */ +const FILTER_DEBOUNCE_MS = 250; + +type Filters = { + formType: string | null; + syncStatus: SyncStatus; + search: string; +}; + +/** + * Located observations + basemap for the Observations map. Everything shown + * comes from the device; the network is only checked in the background to + * tell the user whether the map background can load. Does nothing while + * `enabled` is false, so the List view never pays for the map. + */ +export function useObservationMap(enabled: boolean, filters: Filters) { + const [result, setResult] = useState(EMPTY); + const [basemap, setBasemap] = useState(null); + /** null until the background check answers. */ + const [online, setOnline] = useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const requestId = useRef(0); + const { formType, syncStatus, search } = filters; + + const loadPoints = useCallback(async () => { + const id = ++requestId.current; + const started = Date.now(); + setLoading(true); + setError(null); + try { + const formService = await FormService.getInstance(); + const points = await formService.listObservationMapPoints({ + formType, + syncStatus, + search, + }); + if (id !== requestId.current) return; + setResult(points); + logger.info( + 'observations', + `map load done in ${Date.now() - started}ms`, + { + screen: 'Observations', + phase: 'done', + counts: points.points.length, + success: true, + }, + ); + } catch (err) { + if (id !== requestId.current) return; + const message = + err instanceof Error ? err.message : 'Failed to load the map'; + logger.error('observations', message, { + screen: 'Observations', + phase: 'done', + success: false, + }); + setError(message); + } finally { + if (id === requestId.current) setLoading(false); + } + }, [formType, syncStatus, search]); + + const loadBackground = useCallback(async () => { + try { + setBasemap(await loadBasemap()); + } catch (err) { + logger.warn( + 'map', + err instanceof Error ? err.message : 'Basemap unavailable', + ); + } + try { + const check = await checkBasemapOnline(); + setOnline(check.online); + if (check.styleUpdated) { + setBasemap(await loadBasemap()); + } + } catch { + setOnline(false); + } + }, []); + + useEffect(() => { + if (!enabled) return; + const timer = setTimeout(() => { + void loadPoints(); + }, FILTER_DEBOUNCE_MS); + return () => clearTimeout(timer); + }, [enabled, loadPoints]); + + useEffect(() => { + if (!enabled) return; + const timer = setTimeout(() => { + void loadBackground(); + }, 0); + return () => clearTimeout(timer); + }, [enabled, loadBackground]); + + const refresh = useCallback(async () => { + await Promise.all([loadPoints(), loadBackground()]); + }, [loadPoints, loadBackground]); + + return { ...result, basemap, online, loading, error, refresh }; +} diff --git a/formulus/src/maps/BasemapService.ts b/formulus/src/maps/BasemapService.ts new file mode 100644 index 000000000..b7a741cfa --- /dev/null +++ b/formulus/src/maps/BasemapService.ts @@ -0,0 +1,165 @@ +import RNFS from 'react-native-fs'; +import type { StyleSpecification } from '@maplibre/maplibre-react-native'; +import AppConfigService from '../services/AppConfigService'; +import { profileCachePath, profilePath } from '../profiles/ProfilePaths'; +import { logger } from '../diagnostics/logger'; +import { + BUNDLED_TILE_EXTENSIONS, + basemapProbeUrl, + resolveBasemap, + resolveBundledStyle, + type Basemap, + type BundledTileFile, +} from './basemap'; + +/** Folder inside the app bundle that holds offline map files. */ +export const BUNDLE_MAPS_DIR = 'app/maps'; + +async function listBundledTileFiles( + mapsDir: string, +): Promise { + try { + if (!(await RNFS.exists(mapsDir))) { + return []; + } + const entries = await RNFS.readDir(mapsDir); + return entries + .filter( + entry => + entry.isFile() && + BUNDLED_TILE_EXTENSIONS.some(ext => + entry.name.toLowerCase().endsWith(ext), + ), + ) + .sort((a, b) => a.name.localeCompare(b.name)) + .map(entry => ({ name: entry.name, path: entry.path })); + } catch (error) { + logger.warn( + 'map', + error instanceof Error ? error.message : 'Could not list bundled maps', + ); + return []; + } +} + +async function readBundledStyle(mapsDir: string) { + const stylePath = `${mapsDir}/style.json`; + try { + if (!(await RNFS.exists(stylePath))) { + return null; + } + const raw = await RNFS.readFile(stylePath, 'utf8'); + const style = resolveBundledStyle(raw, mapsDir); + if (!style) { + logger.warn('map', 'maps/style.json is not a MapLibre v8 style'); + } + return style; + } catch (error) { + logger.warn( + 'map', + error instanceof Error ? error.message : 'Could not read maps/style.json', + ); + return null; + } +} + +/** Never let a dead or captive network hold up the map. */ +const NETWORK_TIMEOUT_MS = 4000; +const STYLE_CACHE_FILE = 'maps/remote-style.json'; + +async function fetchWithTimeout(url: string): Promise { + const controller = new AbortController(); + const timer = setTimeout(() => controller.abort(), NETWORK_TIMEOUT_MS); + try { + return await fetch(url, { signal: controller.signal }); + } catch { + return null; + } finally { + clearTimeout(timer); + } +} + +async function readCachedStyle( + styleUrl: string, +): Promise { + try { + const path = profileCachePath(STYLE_CACHE_FILE); + if (!(await RNFS.exists(path))) return null; + const cached = JSON.parse(await RNFS.readFile(path, 'utf8')); + return cached?.url === styleUrl && cached.style?.version === 8 + ? (cached.style as StyleSpecification) + : null; + } catch { + return null; + } +} + +async function cacheStyle(styleUrl: string, style: StyleSpecification) { + try { + const path = profileCachePath(STYLE_CACHE_FILE); + const dir = path.slice(0, path.lastIndexOf('/')); + if (!(await RNFS.exists(dir))) await RNFS.mkdir(dir); + await RNFS.writeFile( + path, + JSON.stringify({ url: styleUrl, style }), + 'utf8', + ); + } catch (error) { + logger.warn( + 'map', + error instanceof Error ? error.message : 'Could not cache map style', + ); + } +} + +async function mapsConfig() { + const appConfig = AppConfigService.getInstance(); + await appConfig.loadConfig(); + return appConfig.getConfig()?.maps; +} + +/** + * Resolve the basemap from what is on the device only: app.config.json `maps`, + * offline map files in the bundle's `maps/` folder, and a configured style URL + * as cached by an earlier online visit. Never touches the network. + */ +export async function loadBasemap(): Promise { + const config = await mapsConfig(); + const mapsDir = profilePath(BUNDLE_MAPS_DIR); + const [bundledFiles, bundledStyle, remoteStyle] = await Promise.all([ + listBundledTileFiles(mapsDir), + readBundledStyle(mapsDir), + config?.styleUrl ? readCachedStyle(config.styleUrl) : null, + ]); + return resolveBasemap({ config, bundledFiles, bundledStyle, remoteStyle }); +} + +/** + * Check, in the background, whether the online basemap is reachable. A + * configured style URL is fetched and cached so it is available offline later; + * `styleUpdated` says the caller should reload the basemap to use it. + */ +export async function checkBasemapOnline(): Promise<{ + online: boolean; + styleUpdated: boolean; +}> { + const config = await mapsConfig(); + if (config?.styleUrl) { + const response = await fetchWithTimeout(config.styleUrl); + if (!response?.ok) return { online: false, styleUpdated: false }; + try { + const style = (await response.json()) as StyleSpecification; + if (style?.version !== 8) return { online: true, styleUpdated: false }; + const previous = await readCachedStyle(config.styleUrl); + await cacheStyle(config.styleUrl, style); + return { + online: true, + styleUpdated: JSON.stringify(previous) !== JSON.stringify(style), + }; + } catch { + return { online: true, styleUpdated: false }; + } + } + const response = await fetchWithTimeout(basemapProbeUrl(config)); + return { online: Boolean(response?.ok), styleUpdated: false }; +} diff --git a/formulus/src/maps/OfflineMapsService.ts b/formulus/src/maps/OfflineMapsService.ts new file mode 100644 index 000000000..fcba29d1c --- /dev/null +++ b/formulus/src/maps/OfflineMapsService.ts @@ -0,0 +1,165 @@ +import { + OfflineManager, + type OfflinePack, + type OfflinePackStatus, +} from '@maplibre/maplibre-react-native'; +import { getActiveProfile } from '../profiles/ProfileRuntime'; +import { logger } from '../diagnostics/logger'; +import { estimateTileCount } from './basemap'; + +/** + * Hard cap on tiles in one area download. Keeps a mis-sized area (a whole + * country at street zoom) from filling the device; the UI asks the user to zoom + * in instead. + */ +export const OFFLINE_PACK_TILE_LIMIT = 20000; +export const OFFLINE_PACK_MIN_ZOOM = 8; + +export type OfflineAreaMetadata = { + name: string; + profileId: string; + createdAt: number; + minZoom: number; + maxZoom: number; +}; + +export type OfflineArea = { + id: string; + bounds: [number, number, number, number]; + metadata: OfflineAreaMetadata; + status: OfflinePackStatus | null; +}; + +let configured = false; + +function configureOnce() { + if (configured) return; + configured = true; + OfflineManager.setTileCountLimit(OFFLINE_PACK_TILE_LIMIT); + OfflineManager.setProgressEventThrottle(750); +} + +function readMetadata(pack: OfflinePack): OfflineAreaMetadata | null { + const m = pack.metadata as Partial | undefined; + if (!m || typeof m.name !== 'string' || typeof m.profileId !== 'string') { + return null; + } + return { + name: m.name, + profileId: m.profileId, + createdAt: Number(m.createdAt ?? 0), + minZoom: Number(m.minZoom ?? OFFLINE_PACK_MIN_ZOOM), + maxZoom: Number(m.maxZoom ?? 0), + }; +} + +/** + * MapLibre keeps one offline database per app; packs are tagged with the + * profile that created them so each profile only sees (and deletes) its own. + */ +export async function listOfflineAreas(): Promise { + configureOnce(); + const profileId = getActiveProfile().id; + const packs = await OfflineManager.getPacks(); + const areas: OfflineArea[] = []; + for (const pack of packs) { + const metadata = readMetadata(pack); + if (!metadata || metadata.profileId !== profileId) continue; + let status: OfflinePackStatus | null = null; + try { + status = await pack.status(); + } catch (error) { + logger.warn( + 'map', + error instanceof Error ? error.message : 'Offline pack status failed', + ); + } + areas.push({ + id: pack.id, + bounds: pack.bounds as [number, number, number, number], + metadata, + status, + }); + } + return areas.sort((a, b) => b.metadata.createdAt - a.metadata.createdAt); +} + +export function estimateAreaTiles( + bounds: [number, number, number, number], + maxZoom: number, +): number { + return estimateTileCount(bounds, OFFLINE_PACK_MIN_ZOOM, maxZoom); +} + +export async function downloadOfflineArea(options: { + name: string; + bounds: [number, number, number, number]; + mapStyle: string; + maxZoom: number; + onProgress: (status: OfflinePackStatus) => void; + onError: (message: string) => void; +}): Promise { + configureOnce(); + const metadata: OfflineAreaMetadata = { + name: options.name, + profileId: getActiveProfile().id, + createdAt: Date.now(), + minZoom: OFFLINE_PACK_MIN_ZOOM, + maxZoom: options.maxZoom, + }; + logger.info('map', 'offline area download start', { + phase: 'start', + counts: estimateAreaTiles(options.bounds, options.maxZoom), + }); + const pack = await OfflineManager.createPack( + { + mapStyle: options.mapStyle, + bounds: options.bounds, + minZoom: OFFLINE_PACK_MIN_ZOOM, + maxZoom: options.maxZoom, + metadata, + }, + (_pack, status) => { + options.onProgress(status); + if (status.state === 'complete') { + logger.info('map', 'offline area download complete', { + phase: 'done', + counts: status.completedTileCount, + success: true, + }); + } + }, + (_pack, error) => { + logger.warn('map', `offline area download error: ${error.message}`); + options.onError(error.message); + }, + ); + return pack.id; +} + +export async function watchOfflineArea( + id: string, + onProgress: (status: OfflinePackStatus) => void, + onError: (message: string) => void, +): Promise { + configureOnce(); + await OfflineManager.addListener( + id, + (_pack, status) => onProgress(status), + (_pack, error) => onError(error.message), + ); +} + +export function unwatchOfflineArea(id: string): void { + OfflineManager.removeListener(id); +} + +export async function resumeOfflineArea(id: string): Promise { + const pack = await OfflineManager.getPack(id); + await pack.resume(); +} + +export async function deleteOfflineArea(id: string): Promise { + OfflineManager.removeListener(id); + await OfflineManager.deletePack(id); +} diff --git a/formulus/src/maps/__tests__/basemap.test.ts b/formulus/src/maps/__tests__/basemap.test.ts new file mode 100644 index 000000000..14f21caaf --- /dev/null +++ b/formulus/src/maps/__tests__/basemap.test.ts @@ -0,0 +1,153 @@ +import { + basemapProbeUrl, + buildRasterStyle, + estimateTileCount, + OSM_TILE_URL, + padBounds, + resolveBasemap, + resolveBundledStyle, +} from '../basemap'; + +describe('resolveBasemap', () => { + it('defaults to OpenStreetMap tiles and blocks area downloads', () => { + const basemap = resolveBasemap({ + config: undefined, + bundledFiles: [], + bundledStyle: null, + }); + const style = basemap.mapStyle as ReturnType; + expect(style.sources.online).toMatchObject({ tiles: [OSM_TILE_URL] }); + expect(basemap.downloadStyle).toBeNull(); + expect(basemap.downloadBlockedReason).toBe('osm-policy'); + }); + + it('never allows downloads from OSM even when the config asks', () => { + const basemap = resolveBasemap({ + config: { tiles: [OSM_TILE_URL], allowOfflineDownload: true }, + bundledFiles: [], + bundledStyle: null, + }); + expect(basemap.downloadStyle).toBeNull(); + expect(basemap.downloadBlockedReason).toBe('osm-policy'); + }); + + it('allows downloads from a provider that opts in', () => { + const basemap = resolveBasemap({ + config: { + tiles: ['https://tiles.example.org/{z}/{x}/{y}.png'], + allowOfflineDownload: true, + }, + bundledFiles: [{ name: 'area.mbtiles', path: '/data/maps/area.mbtiles' }], + bundledStyle: null, + }); + expect(basemap.downloadBlockedReason).toBeNull(); + // The pack style excludes bundled files: they are already on the device. + expect(basemap.downloadStyle).not.toContain('mbtiles://'); + expect(basemap.downloadStyle).toContain('tiles.example.org'); + }); + + it('uses the fetched or cached style for a configured style URL', () => { + const remoteStyle = { version: 8 as const, sources: {}, layers: [] }; + const basemap = resolveBasemap({ + config: { + styleUrl: 'https://tiles.example.org/styles/liberty', + allowOfflineDownload: true, + }, + bundledFiles: [], + bundledStyle: null, + remoteStyle, + }); + expect(basemap.mapStyle).toBe(remoteStyle); + expect(basemap.downloadStyle).toBe( + 'https://tiles.example.org/styles/liberty', + ); + expect(basemap.offlineMaxZoom).toBe(14); + }); + + it('falls back to the built-in style when a style URL was never reachable', () => { + const basemap = resolveBasemap({ + config: { styleUrl: 'https://tiles.example.org/styles/liberty' }, + bundledFiles: [], + bundledStyle: null, + remoteStyle: null, + }); + expect(typeof basemap.mapStyle).toBe('object'); + expect(JSON.stringify(basemap.mapStyle)).toContain(OSM_TILE_URL); + }); + + it('probes the first tile of a raster source or the style URL', () => { + expect(basemapProbeUrl(undefined)).toBe( + 'https://tile.openstreetmap.org/0/0/0.png', + ); + expect( + basemapProbeUrl({ tiles: ['https://{s}.t.example/{z}/{x}/{y}.png'] }), + ).toBe('https://a.t.example/0/0/0.png'); + expect(basemapProbeUrl({ styleUrl: 'https://s.example/style.json' })).toBe( + 'https://s.example/style.json', + ); + }); + + it('prefers a bundled style over everything', () => { + const bundledStyle = { version: 8 as const, sources: {}, layers: [] }; + const basemap = resolveBasemap({ + config: { styleUrl: 'https://x', allowOfflineDownload: true }, + bundledFiles: [], + bundledStyle, + }); + expect(basemap.mapStyle).toBe(bundledStyle); + expect(basemap.usesBundledStyle).toBe(true); + expect(basemap.downloadBlockedReason).toBe('bundled'); + }); +}); + +describe('buildRasterStyle', () => { + it('stacks bundled raster files above the online layer', () => { + const style = buildRasterStyle(undefined, [ + { name: 'district a.mbtiles', path: '/data/maps/district a.mbtiles' }, + { name: 'b.pmtiles', path: '/data/maps/b.pmtiles' }, + ]); + expect(style.layers.map(l => l.id)).toEqual([ + 'background', + 'online', + 'bundle_district_a_mbtiles', + 'bundle_b_pmtiles', + ]); + expect(style.sources.bundle_district_a_mbtiles).toMatchObject({ + url: 'mbtiles:///data/maps/district a.mbtiles', + }); + expect(style.sources.bundle_b_pmtiles).toMatchObject({ + url: 'pmtiles://file:///data/maps/b.pmtiles', + }); + }); +}); + +describe('resolveBundledStyle', () => { + it('substitutes {mapsDir} and requires a v8 style', () => { + const raw = JSON.stringify({ + version: 8, + sources: { v: { type: 'vector', url: 'mbtiles://{mapsDir}/r.mbtiles' } }, + layers: [], + }); + const style = resolveBundledStyle(raw, '/data/app/maps/'); + expect(style?.sources.v).toMatchObject({ + url: 'mbtiles:///data/app/maps/r.mbtiles', + }); + expect(resolveBundledStyle('{"version":7}', '/x')).toBeNull(); + expect(resolveBundledStyle('nope', '/x')).toBeNull(); + }); +}); + +describe('tile estimates', () => { + it('counts one tile for the world at zoom 0 and four at zoom 1', () => { + expect(estimateTileCount([-180, -85, 180, 85], 0, 0)).toBe(1); + expect(estimateTileCount([-180, -85, 180, 85], 1, 1)).toBe(4); + }); + + it('pads bounds by roughly the given distance', () => { + const [west, south, east, north] = padBounds([32, 0, 33, 1], 1); + expect(south).toBeCloseTo(-0.009, 3); + expect(north).toBeCloseTo(1.009, 3); + expect(west).toBeLessThan(32); + expect(east).toBeGreaterThan(33); + }); +}); diff --git a/formulus/src/maps/__tests__/observationClusters.test.ts b/formulus/src/maps/__tests__/observationClusters.test.ts new file mode 100644 index 000000000..ef76a2740 --- /dev/null +++ b/formulus/src/maps/__tests__/observationClusters.test.ts @@ -0,0 +1,72 @@ +import { + CLUSTER_BADGES, + clusterBadgeExpression, + clusterBadgeFor, + colorsByFormType, + FORM_TYPE_PALETTE, + toFeatureCollection, +} from '../observationClusters'; +import { CLUSTER_BADGE_IMAGES } from '../clusterBadgeImages'; + +describe('colorsByFormType', () => { + it('assigns stable colours independent of input order', () => { + expect(colorsByFormType(['b', 'a', 'b'])).toEqual( + colorsByFormType(['a', 'b']), + ); + expect(colorsByFormType(['a']).a).toBe(FORM_TYPE_PALETTE[0]); + }); +}); + +describe('toFeatureCollection', () => { + it('builds lon/lat point features with display properties', () => { + const fc = toFeatureCollection( + [ + { + observationId: 'o1', + formType: 'survey', + createdAt: new Date(0), + synced: false, + latitude: 0.35, + longitude: 32.58, + accuracy: null, + }, + ], + { survey: '#123456' }, + ); + expect(fc.features[0].geometry.coordinates).toEqual([32.58, 0.35]); + expect(fc.features[0].properties).toEqual({ + observationId: 'o1', + formType: 'survey', + color: '#123456', + synced: false, + }); + }); +}); + +describe('cluster badges', () => { + it('has a bundled image for every badge', () => { + for (const [, image] of CLUSTER_BADGES) { + expect(CLUSTER_BADGE_IMAGES[image]).toBeDefined(); + } + }); + + it('picks exact badges below ten and ranges above', () => { + expect(clusterBadgeFor(2)).toBe('cluster-2'); + expect(clusterBadgeFor(9)).toBe('cluster-9'); + expect(clusterBadgeFor(10)).toBe('cluster-10p'); + expect(clusterBadgeFor(49)).toBe('cluster-20p'); + expect(clusterBadgeFor(1500)).toBe('cluster-1kp'); + expect(clusterBadgeFor(250000)).toBe('cluster-10kp'); + }); + + it('builds an ascending step expression matching clusterBadgeFor', () => { + const expr = clusterBadgeExpression(); + expect(expr.slice(0, 3)).toEqual([ + 'step', + ['get', 'point_count'], + 'cluster-2', + ]); + const thresholds = expr.slice(3).filter((_, i) => i % 2 === 0); + expect(thresholds).toEqual([...thresholds].sort((a, b) => +a - +b)); + }); +}); diff --git a/formulus/src/maps/basemap.ts b/formulus/src/maps/basemap.ts new file mode 100644 index 000000000..50c1587d9 --- /dev/null +++ b/formulus/src/maps/basemap.ts @@ -0,0 +1,239 @@ +import type { StyleSpecification } from '@maplibre/maplibre-react-native'; +import type { MapsConfig } from '../types/AppConfig'; + +export const OSM_TILE_URL = 'https://tile.openstreetmap.org/{z}/{x}/{y}.png'; +export const OSM_ATTRIBUTION = '© OpenStreetMap contributors'; + +/** Hosts whose usage policy forbids bulk (offline) tile download. */ +const NO_BULK_DOWNLOAD_HOSTS = ['tile.openstreetmap.org']; + +export const BUNDLED_TILE_EXTENSIONS = ['.mbtiles', '.pmtiles'] as const; + +export type BundledTileFile = { + /** File name inside the bundle's maps/ folder. */ + name: string; + /** Absolute path on the device. */ + path: string; +}; + +export type Basemap = { + /** What MapLibre renders: an inline style object or a style URL. */ + mapStyle: StyleSpecification | string; + /** + * Style used to create offline packs, or null when downloads are not + * allowed for this tile source. + */ + downloadStyle: string | null; + /** Why downloads are unavailable, when they are. */ + downloadBlockedReason: 'osm-policy' | 'not-enabled' | 'bundled' | null; + /** Highest zoom an area download should fetch. */ + offlineMaxZoom: number; + /** Offline map files from the app bundle that are part of this style. */ + bundledFiles: BundledTileFile[]; + /** True when a bundled vector style replaces the online basemap. */ + usesBundledStyle: boolean; +}; + +function sourceUrlFor(file: BundledTileFile): string { + const lower = file.name.toLowerCase(); + const absolute = file.path.startsWith('/') ? file.path : `/${file.path}`; + return lower.endsWith('.pmtiles') + ? `pmtiles://file://${absolute}` + : `mbtiles://${absolute}`; +} + +function safeLayerId(name: string): string { + return name.replace(/[^A-Za-z0-9_-]/g, '_'); +} + +function isNoBulkDownloadHost(url: string): boolean { + return NO_BULK_DOWNLOAD_HOSTS.some(host => url.includes(host)); +} + +/** + * Raster style with the online tile source at the bottom and every bundled + * raster file stacked above it. Offline, the online layer simply has no tiles + * and the bundled files (and MapLibre's cache) show through. + */ +export function buildRasterStyle( + config: MapsConfig | undefined, + bundledFiles: BundledTileFile[], +): StyleSpecification { + const tiles = config?.tiles?.length ? config.tiles : [OSM_TILE_URL]; + const attribution = + config?.attribution ?? + (tiles.some(isNoBulkDownloadHost) ? OSM_ATTRIBUTION : undefined); + const style: StyleSpecification = { + version: 8, + sources: { + online: { + type: 'raster', + tiles, + tileSize: config?.tileSize ?? 256, + maxzoom: config?.maxZoom ?? 19, + ...(attribution ? { attribution } : {}), + }, + }, + layers: [ + { + id: 'background', + type: 'background', + paint: { 'background-color': '#e9e5dc' }, + }, + { id: 'online', type: 'raster', source: 'online' }, + ], + }; + for (const file of bundledFiles) { + const id = `bundle_${safeLayerId(file.name)}`; + style.sources[id] = { + type: 'raster', + url: sourceUrlFor(file), + tileSize: 256, + }; + style.layers.push({ id, type: 'raster', source: id }); + } + return style; +} + +/** Same style without bundled files: what an offline pack downloads. */ +function onlineOnlyRasterStyle(config: MapsConfig | undefined): string { + return JSON.stringify(buildRasterStyle(config, [])); +} + +/** + * Bundled `maps/style.json` may reference files next to it with the + * `{mapsDir}` placeholder, e.g. `"url": "mbtiles://{mapsDir}/region.mbtiles"`. + */ +export function resolveBundledStyle( + rawStyle: string, + mapsDir: string, +): StyleSpecification | null { + try { + const dir = mapsDir.replace(/\/+$/, ''); + const parsed = JSON.parse(rawStyle.split('{mapsDir}').join(dir)); + if (parsed && typeof parsed === 'object' && parsed.version === 8) { + return parsed as StyleSpecification; + } + } catch { + // Fall through to the online basemap. + } + return null; +} + +/** One URL whose reachability tells whether the online basemap can load. */ +export function basemapProbeUrl(config: MapsConfig | undefined): string { + if (config?.styleUrl) { + return config.styleUrl; + } + const template = config?.tiles?.[0] ?? OSM_TILE_URL; + return template + .replace('{s}', 'a') + .replace('{z}', '0') + .replace('{x}', '0') + .replace('{y}', '0'); +} + +export function resolveBasemap(input: { + config: MapsConfig | undefined; + bundledFiles: BundledTileFile[]; + bundledStyle: StyleSpecification | null; + /** + * The configured `styleUrl` as a style object, fetched now or cached from an + * earlier online visit. Null when it has never been reachable. + */ + remoteStyle?: StyleSpecification | null; +}): Basemap { + const { config, bundledFiles, bundledStyle, remoteStyle } = input; + + if (bundledStyle) { + return { + mapStyle: bundledStyle, + downloadStyle: null, + downloadBlockedReason: 'bundled', + offlineMaxZoom: 14, + bundledFiles, + usesBundledStyle: true, + }; + } + + if (config?.styleUrl) { + const allowed = + config.allowOfflineDownload === true && + !isNoBulkDownloadHost(config.styleUrl); + return { + // A style URL that cannot be fetched would leave the map blank; the + // built-in raster style still draws the observations. + mapStyle: remoteStyle ?? buildRasterStyle(config, bundledFiles), + downloadStyle: allowed ? config.styleUrl : null, + downloadBlockedReason: allowed + ? null + : isNoBulkDownloadHost(config.styleUrl) + ? 'osm-policy' + : 'not-enabled', + offlineMaxZoom: config.offlineMaxZoom ?? 14, + bundledFiles, + usesBundledStyle: false, + }; + } + + const tiles = config?.tiles?.length ? config.tiles : [OSM_TILE_URL]; + const osm = tiles.some(isNoBulkDownloadHost); + const allowed = !osm && config?.allowOfflineDownload === true; + return { + mapStyle: buildRasterStyle(config, bundledFiles), + downloadStyle: allowed ? onlineOnlyRasterStyle(config) : null, + downloadBlockedReason: allowed ? null : osm ? 'osm-policy' : 'not-enabled', + offlineMaxZoom: config?.offlineMaxZoom ?? 16, + bundledFiles, + usesBundledStyle: false, + }; +} + +/** Slippy-map tile count for a bounding box over a zoom range. */ +export function estimateTileCount( + bounds: [number, number, number, number], + minZoom: number, + maxZoom: number, +): number { + const [west, south, east, north] = bounds; + const clampLat = (lat: number) => Math.max(-85.0511, Math.min(85.0511, lat)); + const tileX = (lon: number, z: number) => + Math.floor(((lon + 180) / 360) * 2 ** z); + const tileY = (lat: number, z: number) => { + const rad = (clampLat(lat) * Math.PI) / 180; + return Math.floor( + ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * + 2 ** z, + ); + }; + let total = 0; + for (let z = Math.max(0, minZoom); z <= maxZoom; z += 1) { + const max = 2 ** z - 1; + const x0 = Math.max(0, Math.min(max, tileX(west, z))); + const x1 = Math.max(0, Math.min(max, tileX(east, z))); + const y0 = Math.max(0, Math.min(max, tileY(north, z))); + const y1 = Math.max(0, Math.min(max, tileY(south, z))); + total += (Math.abs(x1 - x0) + 1) * (Math.abs(y1 - y0) + 1); + } + return total; +} + +/** + * Grow a bounding box by `km` on every side so the downloaded area stays + * usable at the edge of the work area. + */ +export function padBounds( + bounds: [number, number, number, number], + km: number, +): [number, number, number, number] { + const [west, south, east, north] = bounds; + const dLat = km / 111; + const midLat = ((south + north) / 2) * (Math.PI / 180); + const dLon = km / (111 * Math.max(0.1, Math.cos(midLat))); + return [ + Math.max(-180, west - dLon), + Math.max(-85, south - dLat), + Math.min(180, east + dLon), + Math.min(85, north + dLat), + ]; +} diff --git a/formulus/src/maps/clusterBadgeImages.ts b/formulus/src/maps/clusterBadgeImages.ts new file mode 100644 index 000000000..79cb51d00 --- /dev/null +++ b/formulus/src/maps/clusterBadgeImages.ts @@ -0,0 +1,40 @@ +import type { ImageEntry } from '@maplibre/maplibre-react-native'; +import badge2 from '../assets/map/cluster-2.png'; +import badge3 from '../assets/map/cluster-3.png'; +import badge4 from '../assets/map/cluster-4.png'; +import badge5 from '../assets/map/cluster-5.png'; +import badge6 from '../assets/map/cluster-6.png'; +import badge7 from '../assets/map/cluster-7.png'; +import badge8 from '../assets/map/cluster-8.png'; +import badge9 from '../assets/map/cluster-9.png'; +import badge10Plus from '../assets/map/cluster-10p.png'; +import badge20Plus from '../assets/map/cluster-20p.png'; +import badge50Plus from '../assets/map/cluster-50p.png'; +import badge100Plus from '../assets/map/cluster-100p.png'; +import badge200Plus from '../assets/map/cluster-200p.png'; +import badge500Plus from '../assets/map/cluster-500p.png'; +import badge1kPlus from '../assets/map/cluster-1kp.png'; +import badge2kPlus from '../assets/map/cluster-2kp.png'; +import badge5kPlus from '../assets/map/cluster-5kp.png'; +import badge10kPlus from '../assets/map/cluster-10kp.png'; + +export const CLUSTER_BADGE_IMAGES: Record = { + 'cluster-2': { source: badge2 }, + 'cluster-3': { source: badge3 }, + 'cluster-4': { source: badge4 }, + 'cluster-5': { source: badge5 }, + 'cluster-6': { source: badge6 }, + 'cluster-7': { source: badge7 }, + 'cluster-8': { source: badge8 }, + 'cluster-9': { source: badge9 }, + 'cluster-10p': { source: badge10Plus }, + 'cluster-20p': { source: badge20Plus }, + 'cluster-50p': { source: badge50Plus }, + 'cluster-100p': { source: badge100Plus }, + 'cluster-200p': { source: badge200Plus }, + 'cluster-500p': { source: badge500Plus }, + 'cluster-1kp': { source: badge1kPlus }, + 'cluster-2kp': { source: badge2kPlus }, + 'cluster-5kp': { source: badge5kPlus }, + 'cluster-10kp': { source: badge10kPlus }, +}; diff --git a/formulus/src/maps/observationClusters.ts b/formulus/src/maps/observationClusters.ts new file mode 100644 index 000000000..c2dbaf900 --- /dev/null +++ b/formulus/src/maps/observationClusters.ts @@ -0,0 +1,104 @@ +import type { ObservationMapPoint } from '../database/observationMapQuery'; + +/** + * Colour-blind-safe categorical palette (Okabe–Ito, plus extras), one per form + * type in a stable order. Sync state is shown by the outline, not by colour. + */ +export const FORM_TYPE_PALETTE = [ + '#0072B2', + '#E69F00', + '#009E73', + '#CC79A7', + '#D55E00', + '#56B4E9', + '#8C6D31', + '#6A3D9A', + '#F0E442', + '#4D4D4D', +] as const; + +export function colorsByFormType(formTypes: string[]): Record { + const sorted = [...new Set(formTypes)].sort((a, b) => a.localeCompare(b)); + const out: Record = {}; + sorted.forEach((formType, i) => { + out[formType] = FORM_TYPE_PALETTE[i % FORM_TYPE_PALETTE.length]; + }); + return out; +} + +/** Above this zoom nothing is clustered except points that share a spot. */ +export const CLUSTER_MAX_ZOOM = 16; +export const CLUSTER_RADIUS_PX = 48; + +export type PointProps = { + observationId: string; + formType: string; + color: string; + synced: boolean; +}; + +export function toFeatureCollection( + points: ObservationMapPoint[], + colors: Record, +): GeoJSON.FeatureCollection { + return { + type: 'FeatureCollection', + features: points.map(p => ({ + type: 'Feature', + id: p.observationId, + geometry: { type: 'Point', coordinates: [p.longitude, p.latitude] }, + properties: { + observationId: p.observationId, + formType: p.formType, + color: colors[p.formType] ?? FORM_TYPE_PALETTE[0], + synced: p.synced, + }, + })), + }; +} + +/** + * Count badges are bundled images (see scripts/generate-map-cluster-badges.py) + * so they render offline: map text would need downloaded font glyphs. + * Thresholds are ascending; each badge covers counts from its threshold up. + */ +export const CLUSTER_BADGES: Array<[threshold: number, image: string]> = [ + [2, 'cluster-2'], + [3, 'cluster-3'], + [4, 'cluster-4'], + [5, 'cluster-5'], + [6, 'cluster-6'], + [7, 'cluster-7'], + [8, 'cluster-8'], + [9, 'cluster-9'], + [10, 'cluster-10p'], + [20, 'cluster-20p'], + [50, 'cluster-50p'], + [100, 'cluster-100p'], + [200, 'cluster-200p'], + [500, 'cluster-500p'], + [1000, 'cluster-1kp'], + [2000, 'cluster-2kp'], + [5000, 'cluster-5kp'], + [10000, 'cluster-10kp'], +]; + +/** MapLibre `step` expression choosing the badge for a cluster's point_count. */ +export function clusterBadgeExpression(): unknown[] { + const [first, ...rest] = CLUSTER_BADGES; + return [ + 'step', + ['get', 'point_count'], + first[1], + ...rest.flatMap(([threshold, image]) => [threshold, image]), + ]; +} + +/** Badge image name for a count, mirroring {@link clusterBadgeExpression}. */ +export function clusterBadgeFor(count: number): string { + let image = CLUSTER_BADGES[0][1]; + for (const [threshold, name] of CLUSTER_BADGES) { + if (count >= threshold) image = name; + } + return image; +} diff --git a/formulus/src/types/AppConfig.ts b/formulus/src/types/AppConfig.ts index 7647ec8b0..c5601c859 100644 --- a/formulus/src/types/AppConfig.ts +++ b/formulus/src/types/AppConfig.ts @@ -98,6 +98,37 @@ export interface NavigationConfig { tabs: string[]; } +/** + * Basemap for the Observations map (`maps` in app.config.json). + * + * Offline map files shipped in the app bundle under `maps/` are picked up + * without configuration: raster `*.mbtiles` / `*.pmtiles` are layered over the + * online basemap, and `maps/style.json` (vector) replaces it. + */ +export interface MapsConfig { + /** + * MapLibre style URL for the online basemap (e.g. a vector style from a + * hosted tile provider). Overrides `tiles`. + */ + styleUrl?: string; + /** Raster XYZ tile URL templates for the online basemap. */ + tiles?: string[]; + /** Attribution text required by the tile provider. */ + attribution?: string; + /** Raster tile size in pixels (default 256). */ + tileSize?: number; + /** Highest zoom the online tile source serves (default 19). */ + maxZoom?: number; + /** + * Whether enumerators may download areas of the online basemap for offline + * use. Only enable this when the provider's terms allow bulk download. It is + * never enabled for the public OpenStreetMap tile servers. + */ + allowOfflineDownload?: boolean; + /** Highest zoom an area download fetches (default 16 raster, 14 vector). */ + offlineMaxZoom?: number; +} + /** * Root shape of app.config.json. */ @@ -122,6 +153,8 @@ export interface AppConfig { * Used when Formulus Settings language is Auto. */ defaultLocale?: string; + /** Basemap and offline map settings for the Observations map. */ + maps?: MapsConfig; } // Keep this alias exported so app-config consumers can strongly type From f707702e002ee94f54a197f36a577d58ba2fc98a Mon Sep 17 00:00:00 2001 From: najuna-brian Date: Thu, 5 Nov 2026 02:34:07 +0300 Subject: [PATCH 3/3] feat(formulus): add list/map toggle and offline maps sheet to observations Map view shares the table's filters, loads lazily, unmounts when the screen is not focused, and explains when the background is unavailable offline. Area downloads are only offered for tile providers that allow them. Strings in en/pt/fr/sw. Refs #553 --- .../src/components/map/OfflineMapsSheet.tsx | 563 ++++++++++++++++++ formulus/src/locales/en.json | 43 +- formulus/src/locales/fr.json | 43 +- formulus/src/locales/pt.json | 43 +- formulus/src/locales/sw.json | 42 ++ formulus/src/screens/ObservationsScreen.tsx | 222 ++++++- 6 files changed, 950 insertions(+), 6 deletions(-) create mode 100644 formulus/src/components/map/OfflineMapsSheet.tsx diff --git a/formulus/src/components/map/OfflineMapsSheet.tsx b/formulus/src/components/map/OfflineMapsSheet.tsx new file mode 100644 index 000000000..4ffbddff7 --- /dev/null +++ b/formulus/src/components/map/OfflineMapsSheet.tsx @@ -0,0 +1,563 @@ +import React, { useCallback, useEffect, useRef, useState } from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Modal, + ActivityIndicator, +} from 'react-native'; +import Icon from '@react-native-vector-icons/material-design-icons'; +import { useTranslation } from 'react-i18next'; +import type { OfflinePackStatus } from '@maplibre/maplibre-react-native'; +import { useAppTheme } from '../../contexts/AppThemeContext'; +import colors from '../../theme/colors'; +import { + odeSpacing, + odeTypography, + odeRadius, + odeBorderWidth, +} from '../../theme/odeDesign'; +import { padBounds, type Basemap } from '../../maps/basemap'; +import { + deleteOfflineArea, + downloadOfflineArea, + estimateAreaTiles, + listOfflineAreas, + resumeOfflineArea, + unwatchOfflineArea, + watchOfflineArea, + OFFLINE_PACK_TILE_LIMIT, + type OfflineArea, +} from '../../maps/OfflineMapsService'; +import { formatDateTimeShort } from '../../utils/dateUtils'; +import { logger } from '../../diagnostics/logger'; + +type Bounds = [number, number, number, number]; + +type Props = { + visible: boolean; + onClose: () => void; + basemap: Basemap; + /** False when the map's tile source cannot be reached right now. */ + online: boolean; + /** Bounds framing the observations currently on the map. */ + observationsBounds: Bounds | null; + /** Bounds of what the map currently shows. */ + viewBounds: Bounds | null; +}; + +/** Margin around the observations so the edge of the work area stays usable. */ +const OBSERVATION_AREA_PADDING_KM = 1; +/** Rough average tile size, for the estimate shown before a download. */ +const ESTIMATED_BYTES_PER_TILE = 18 * 1024; + +function formatBytes(bytes: number): string { + if (bytes < 1024 * 1024) return `${Math.max(1, Math.round(bytes / 1024))} KB`; + return `${(bytes / (1024 * 1024)).toFixed(1)} MB`; +} + +function isComplete(status: OfflinePackStatus | null): boolean { + return status?.state === 'complete' || (status?.percentage ?? 0) >= 100; +} + +const OfflineMapsSheet: React.FC = ({ + visible, + onClose, + basemap, + online, + observationsBounds, + viewBounds, +}) => { + const { t } = useTranslation(); + const { themeColors, resolvedMode } = useAppTheme(); + const isDark = resolvedMode === 'dark'; + const strongText = isDark + ? (colors.neutral.white as string) + : (colors.neutral[900] as string); + const onSurface = themeColors.onSurface as string; + const primary = themeColors.primary as string; + const onPrimary = themeColors.onPrimary as string; + + const [areas, setAreas] = useState([]); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [busyId, setBusyId] = useState(null); + const watched = useRef(new Set()); + + const updateStatus = useCallback((status: OfflinePackStatus) => { + setAreas(current => + current.map(area => (area.id === status.id ? { ...area, status } : area)), + ); + }, []); + + const reportError = useCallback((message: string) => { + setError(message); + }, []); + + const refresh = useCallback(async () => { + setLoading(true); + try { + const list = await listOfflineAreas(); + setAreas(list); + for (const area of list) { + if (!isComplete(area.status) && !watched.current.has(area.id)) { + watched.current.add(area.id); + await watchOfflineArea(area.id, updateStatus, reportError); + } + } + } catch (err) { + const message = + err instanceof Error ? err.message : t('offlineMaps.loadFailed'); + logger.warn('map', message); + setError(message); + } finally { + setLoading(false); + } + }, [reportError, t, updateStatus]); + + useEffect(() => { + if (!visible) return; + const timer = setTimeout(() => { + setError(null); + void refresh(); + }, 0); + return () => clearTimeout(timer); + }, [visible, refresh]); + + useEffect(() => { + const ids = watched.current; + return () => { + ids.forEach(id => unwatchOfflineArea(id)); + ids.clear(); + }; + }, []); + + const startDownload = useCallback( + async (kind: 'observations' | 'view', bounds: Bounds) => { + if (!basemap.downloadStyle) return; + setError(null); + setBusyId(kind); + try { + const stamp = formatDateTimeShort(new Date()); + const name = + kind === 'observations' + ? t('offlineMaps.observationsAreaName', { date: stamp }) + : t('offlineMaps.viewAreaName', { date: stamp }); + const id = await downloadOfflineArea({ + name, + bounds, + mapStyle: basemap.downloadStyle, + maxZoom: basemap.offlineMaxZoom, + onProgress: updateStatus, + onError: reportError, + }); + watched.current.add(id); + await refresh(); + } catch (err) { + setError( + err instanceof Error ? err.message : t('offlineMaps.downloadFailed'), + ); + } finally { + setBusyId(null); + } + }, + [basemap, refresh, reportError, t, updateStatus], + ); + + const removeArea = useCallback( + async (id: string) => { + setBusyId(id); + try { + watched.current.delete(id); + await deleteOfflineArea(id); + setAreas(current => current.filter(area => area.id !== id)); + } catch (err) { + setError( + err instanceof Error ? err.message : t('offlineMaps.deleteFailed'), + ); + } finally { + setBusyId(null); + } + }, + [t], + ); + + const resumeArea = useCallback( + async (id: string) => { + try { + if (!watched.current.has(id)) { + watched.current.add(id); + await watchOfflineArea(id, updateStatus, reportError); + } + await resumeOfflineArea(id); + } catch (err) { + setError( + err instanceof Error ? err.message : t('offlineMaps.downloadFailed'), + ); + } + }, + [reportError, t, updateStatus], + ); + + const renderDownloadOption = ( + kind: 'observations' | 'view', + icon: React.ComponentProps['name'], + label: string, + rawBounds: Bounds | null, + ) => { + if (!rawBounds) return null; + const bounds = + kind === 'observations' + ? padBounds(rawBounds, OBSERVATION_AREA_PADDING_KM) + : rawBounds; + const tiles = estimateAreaTiles(bounds, basemap.offlineMaxZoom); + const tooLarge = tiles > OFFLINE_PACK_TILE_LIMIT; + const disabled = tooLarge || busyId != null; + return ( + void startDownload(kind, bounds)} + style={[ + styles.option, + { + borderColor: themeColors.divider, + opacity: disabled ? 0.55 : 1, + }, + ]} + accessibilityRole="button"> + + + {label} + + {tooLarge + ? t('offlineMaps.tooLarge') + : t('offlineMaps.estimate', { + size: formatBytes(tiles * ESTIMATED_BYTES_PER_TILE), + })} + + + {busyId === kind ? ( + + ) : ( + + )} + + ); + }; + + const blockedMessage = + basemap.downloadBlockedReason === 'osm-policy' + ? t('offlineMaps.blockedOsm') + : basemap.downloadBlockedReason === 'bundled' + ? t('offlineMaps.blockedBundled') + : basemap.downloadBlockedReason === 'not-enabled' + ? t('offlineMaps.blockedNotEnabled') + : !online + ? t('offlineMaps.offlineNoDownload') + : null; + + return ( + + + undefined}> + + + {t('offlineMaps.title')} + + + + + + + + {t('offlineMaps.intro')} + + + {basemap.bundledFiles.length > 0 ? ( + + + {t('offlineMaps.bundledTitle')} + + {basemap.bundledFiles.map(file => ( + + + + + {file.name} + + + {t('offlineMaps.bundledHint')} + + + + ))} + + ) : null} + + + + {t('offlineMaps.downloadTitle')} + + {blockedMessage ? ( + + + + {blockedMessage} + + + ) : ( + <> + {renderDownloadOption( + 'observations', + 'map-marker-radius-outline', + t('offlineMaps.observationsArea'), + observationsBounds, + )} + {renderDownloadOption( + 'view', + 'crop-free', + t('offlineMaps.viewArea'), + viewBounds, + )} + + )} + + + + + {t('offlineMaps.savedTitle')} + + {loading && areas.length === 0 ? ( + + ) : areas.length === 0 ? ( + + {t('offlineMaps.none')} + + ) : ( + areas.map(area => { + const status = area.status; + const done = isComplete(status); + const pct = Math.floor(status?.percentage ?? 0); + const active = status?.state === 'active'; + return ( + + + + + {area.metadata.name} + + + {done + ? t('offlineMaps.ready', { + size: formatBytes( + status?.completedResourceSize ?? 0, + ), + }) + : active + ? t('offlineMaps.downloading', { percent: pct }) + : t('offlineMaps.paused', { percent: pct })} + + {!done ? ( + + + + ) : null} + + {!done && !active && online ? ( + void resumeArea(area.id)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('offlineMaps.resume')}> + + + ) : null} + {busyId === area.id ? ( + + ) : ( + void removeArea(area.id)} + hitSlop={8} + accessibilityRole="button" + accessibilityLabel={t('common.delete')}> + + + )} + + ); + }) + )} + + + {error ? ( + + {error} + + ) : null} + + + + {t('common.close')} + + + + + + ); +}; + +const styles = StyleSheet.create({ + backdrop: { + flex: 1, + backgroundColor: 'rgba(0,0,0,0.4)', + justifyContent: 'flex-end', + }, + sheet: { + maxHeight: '85%', + borderTopLeftRadius: odeRadius.card, + borderTopRightRadius: odeRadius.card, + paddingBottom: odeSpacing.md, + }, + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: odeSpacing.md, + paddingTop: odeSpacing.md, + paddingBottom: odeSpacing.xs, + }, + title: { + fontSize: odeTypography.sectionTitle, + fontWeight: '700', + }, + body: { + paddingHorizontal: odeSpacing.md, + paddingBottom: odeSpacing.md, + gap: odeSpacing.md, + }, + section: { + gap: odeSpacing.xs, + }, + sectionTitle: { + fontSize: odeTypography.bodySm, + fontWeight: '700', + textTransform: 'uppercase', + letterSpacing: 0.5, + }, + option: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.sm, + padding: odeSpacing.sm, + borderRadius: odeRadius.inner, + borderWidth: odeBorderWidth.hairline, + }, + optionText: { + flex: 1, + gap: 2, + }, + row: { + flexDirection: 'row', + alignItems: 'center', + gap: odeSpacing.sm, + paddingVertical: odeSpacing.sm, + borderBottomWidth: odeBorderWidth.hairline, + }, + rowTitle: { + fontSize: odeTypography.bodySm, + fontWeight: '600', + }, + meta: { + fontSize: odeTypography.caption, + }, + flex: { + flex: 1, + }, + notice: { + flexDirection: 'row', + gap: odeSpacing.xs, + padding: odeSpacing.sm, + borderRadius: odeRadius.inner, + borderWidth: odeBorderWidth.hairline, + }, + progressTrack: { + height: 4, + borderRadius: 2, + overflow: 'hidden', + marginTop: 4, + }, + progressFill: { + height: 4, + }, + doneButton: { + marginHorizontal: odeSpacing.md, + paddingVertical: odeSpacing.sm, + borderRadius: odeRadius.inner, + alignItems: 'center', + }, + doneText: { + fontSize: odeTypography.body, + fontWeight: '700', + }, +}); + +export default OfflineMapsSheet; diff --git a/formulus/src/locales/en.json b/formulus/src/locales/en.json index d12013c7c..5a482b9e6 100644 --- a/formulus/src/locales/en.json +++ b/formulus/src/locales/en.json @@ -51,7 +51,6 @@ "profiles.qrNewMessage": "This QR code uses a different server from the locked profile. Enter a name for the new profile and review the scanned connection before adding it. The current profile and its data will be kept. Only connect to trusted Synkronus servers.", "profiles.qrCredentialsFailed": "The profile was added, but its credentials could not be saved securely. Select it in Profiles and re-enter your password.", "profiles.qrSwitchFailed": "The QR profile was added, but switching is blocked. Close any open form and wait for sync to finish, then select the new profile. You do not need to scan again.", - "profiles.showPassword": "Show password", "profiles.hidePassword": "Hide password", "profiles.working": "Working…", @@ -282,6 +281,48 @@ "observations.loadFailed": "Failed to load observation", "observations.updateSuccess": "Observation updated successfully", "observations.deleteSuccess": "Observation deleted successfully", + "observations.viewList": "Show as list", + "observations.viewMap": "Show as map", + "observations.map.loading": "Loading map...", + "observations.map.errorTitle": "Could not load the map", + "observations.map.emptyTitle": "Nothing to show on the map", + "observations.map.emptyNoLocation_one": "{{count}} observation matches, but it has no location.", + "observations.map.emptyNoLocation_other": "{{count}} observations match, but none has a location.", + "observations.map.withoutLocation_one": "{{count}} of {{total}} has no location and is not shown.", + "observations.map.withoutLocation_other": "{{count}} of {{total}} have no location and are not shown.", + "observations.map.truncated": "Showing the newest {{count}}. Filter by form to see others.", + "observations.map.legend": "Legend", + "observations.map.fitAll": "Show all observations", + "observations.map.pendingOutline": "Orange outline: not synced yet", + "observations.map.open": "Open observation", + "observations.map.samePlace_one": "{{count}} observation at this spot", + "observations.map.samePlace_other": "{{count}} observations at this spot", + "observations.map.offlineNotice": "Offline. Places not saved on this phone show a plain background.", + "observations.map.offlineWithStudyMaps": "Offline. Showing the maps included with your study.", + "offlineMaps.title": "Offline maps", + "offlineMaps.intro": "Areas saved here show on the map without internet. Areas you have already viewed are also kept for a while.", + "offlineMaps.bundledTitle": "Included with your study", + "offlineMaps.bundledHint": "Always available offline", + "offlineMaps.downloadTitle": "Download an area", + "offlineMaps.observationsArea": "Area around these observations", + "offlineMaps.viewArea": "What the map shows now", + "offlineMaps.observationsAreaName": "Observations area · {{date}}", + "offlineMaps.viewAreaName": "Map area · {{date}}", + "offlineMaps.estimate": "About {{size}}", + "offlineMaps.tooLarge": "Too large. Zoom in on the map and try again.", + "offlineMaps.savedTitle": "Saved areas", + "offlineMaps.none": "No areas saved yet.", + "offlineMaps.ready": "Ready offline · {{size}}", + "offlineMaps.downloading": "Downloading… {{percent}}%", + "offlineMaps.paused": "Paused at {{percent}}%", + "offlineMaps.resume": "Resume download", + "offlineMaps.blockedOsm": "The public OpenStreetMap servers do not allow downloading areas. Ask your study administrator to add offline map files to the app bundle or set up a map provider that allows downloads.", + "offlineMaps.blockedBundled": "This study ships its own offline maps, so no download is needed.", + "offlineMaps.blockedNotEnabled": "Downloading areas is not enabled for this study's map provider.", + "offlineMaps.loadFailed": "Could not read saved areas.", + "offlineMaps.downloadFailed": "The download could not start.", + "offlineMaps.deleteFailed": "Could not delete the area.", + "offlineMaps.offlineNoDownload": "You are offline. Connect to the internet to download an area.", "help.exportFailed": "Export failed", "help.exportAttachmentsFailed": "Could not export attachments.", "help.exportObservationsFailed": "Could not export observations.", diff --git a/formulus/src/locales/fr.json b/formulus/src/locales/fr.json index 6a3e1f37b..9f5dbd740 100644 --- a/formulus/src/locales/fr.json +++ b/formulus/src/locales/fr.json @@ -51,7 +51,6 @@ "profiles.qrNewMessage": "Ce code QR utilise un autre serveur que le profil verrouillé. Donnez un nom au nouveau profil et vérifiez les paramètres scannés avant de l’ajouter. Le profil actuel et ses données seront conservés. Connectez-vous uniquement à des serveurs Synkronus de confiance.", "profiles.qrCredentialsFailed": "Le profil a été ajouté, mais ses identifiants n’ont pas pu être enregistrés de manière sécurisée. Sélectionnez-le dans Profils et saisissez à nouveau votre mot de passe.", "profiles.qrSwitchFailed": "Le profil QR a été ajouté, mais le changement est bloqué. Fermez les formulaires ouverts et attendez la fin de la synchronisation, puis sélectionnez le nouveau profil. Inutile de scanner à nouveau le code.", - "profiles.showPassword": "Afficher le mot de passe", "profiles.hidePassword": "Masquer le mot de passe", "profiles.working": "Traitement…", @@ -282,6 +281,48 @@ "observations.loadFailed": "Impossible de charger l'observation", "observations.updateSuccess": "Observation mise à jour avec succès", "observations.deleteSuccess": "Observation supprimée avec succès", + "observations.viewList": "Afficher en liste", + "observations.viewMap": "Afficher sur la carte", + "observations.map.loading": "Chargement de la carte...", + "observations.map.errorTitle": "Impossible de charger la carte", + "observations.map.emptyTitle": "Rien à afficher sur la carte", + "observations.map.emptyNoLocation_one": "{{count}} observation correspond, mais elle n'a pas de position.", + "observations.map.emptyNoLocation_other": "{{count}} observations correspondent, mais aucune n'a de position.", + "observations.map.withoutLocation_one": "{{count}} sur {{total}} n'a pas de position et n'est pas affichée.", + "observations.map.withoutLocation_other": "{{count}} sur {{total}} n'ont pas de position et ne sont pas affichées.", + "observations.map.truncated": "Affichage des {{count}} plus récentes. Filtrez par formulaire pour voir les autres.", + "observations.map.legend": "Légende", + "observations.map.fitAll": "Afficher toutes les observations", + "observations.map.pendingOutline": "Contour orange : pas encore synchronisée", + "observations.map.open": "Ouvrir l'observation", + "observations.map.samePlace_one": "{{count}} observation à cet endroit", + "observations.map.samePlace_other": "{{count}} observations à cet endroit", + "observations.map.offlineNotice": "Hors ligne. Les lieux non enregistrés sur ce téléphone s'affichent sur un fond uni.", + "observations.map.offlineWithStudyMaps": "Hors ligne. Affichage des cartes fournies avec votre étude.", + "offlineMaps.title": "Cartes hors ligne", + "offlineMaps.intro": "Les zones enregistrées ici s'affichent sur la carte sans internet. Les zones déjà consultées sont aussi conservées un certain temps.", + "offlineMaps.bundledTitle": "Fournies avec votre étude", + "offlineMaps.bundledHint": "Toujours disponible hors ligne", + "offlineMaps.downloadTitle": "Télécharger une zone", + "offlineMaps.observationsArea": "Zone autour de ces observations", + "offlineMaps.viewArea": "Ce que la carte affiche maintenant", + "offlineMaps.observationsAreaName": "Zone des observations · {{date}}", + "offlineMaps.viewAreaName": "Zone de la carte · {{date}}", + "offlineMaps.estimate": "Environ {{size}}", + "offlineMaps.tooLarge": "Trop grande. Zoomez sur la carte et réessayez.", + "offlineMaps.savedTitle": "Zones enregistrées", + "offlineMaps.none": "Aucune zone enregistrée.", + "offlineMaps.ready": "Prête hors ligne · {{size}}", + "offlineMaps.downloading": "Téléchargement… {{percent}} %", + "offlineMaps.paused": "En pause à {{percent}} %", + "offlineMaps.resume": "Reprendre le téléchargement", + "offlineMaps.blockedOsm": "Les serveurs publics d'OpenStreetMap n'autorisent pas le téléchargement de zones. Demandez à l'administrateur de l'étude d'ajouter des cartes hors ligne au paquet de l'application ou de configurer un fournisseur de cartes qui l'autorise.", + "offlineMaps.blockedBundled": "Cette étude fournit ses propres cartes hors ligne, aucun téléchargement n'est nécessaire.", + "offlineMaps.blockedNotEnabled": "Le téléchargement de zones n'est pas activé pour le fournisseur de cartes de cette étude.", + "offlineMaps.loadFailed": "Impossible de lire les zones enregistrées.", + "offlineMaps.downloadFailed": "Le téléchargement n'a pas pu démarrer.", + "offlineMaps.deleteFailed": "Impossible de supprimer la zone.", + "offlineMaps.offlineNoDownload": "Vous êtes hors ligne. Connectez-vous à internet pour télécharger une zone.", "help.exportFailed": "Échec de l'export", "help.exportAttachmentsFailed": "Impossible d'exporter les pièces jointes.", "help.exportObservationsFailed": "Impossible d'exporter les observations.", diff --git a/formulus/src/locales/pt.json b/formulus/src/locales/pt.json index a64ca54cb..e5b699b91 100644 --- a/formulus/src/locales/pt.json +++ b/formulus/src/locales/pt.json @@ -51,7 +51,6 @@ "profiles.qrNewMessage": "Este código QR utiliza um servidor diferente do perfil bloqueado. Dê um nome ao novo perfil e verifique a ligação lida antes de o adicionar. O perfil atual e os seus dados serão mantidos. Ligue-se apenas a servidores Synkronus de confiança.", "profiles.qrCredentialsFailed": "O perfil foi adicionado, mas não foi possível guardar as credenciais em segurança. Selecione-o em Perfis e introduza novamente a palavra-passe.", "profiles.qrSwitchFailed": "O perfil QR foi adicionado, mas a mudança está bloqueada. Feche os formulários abertos e aguarde o fim da sincronização. Depois, selecione o novo perfil. Não precisa de ler o código novamente.", - "profiles.showPassword": "Mostrar palavra-passe", "profiles.hidePassword": "Ocultar palavra-passe", "profiles.working": "A processar…", @@ -282,6 +281,48 @@ "observations.loadFailed": "Não foi possível carregar a observação", "observations.updateSuccess": "Observação atualizada com sucesso", "observations.deleteSuccess": "Observação eliminada com sucesso", + "observations.viewList": "Ver como lista", + "observations.viewMap": "Ver no mapa", + "observations.map.loading": "A carregar o mapa...", + "observations.map.errorTitle": "Não foi possível carregar o mapa", + "observations.map.emptyTitle": "Nada para mostrar no mapa", + "observations.map.emptyNoLocation_one": "{{count}} observação corresponde, mas não tem localização.", + "observations.map.emptyNoLocation_other": "{{count}} observações correspondem, mas nenhuma tem localização.", + "observations.map.withoutLocation_one": "{{count}} de {{total}} não tem localização e não é mostrada.", + "observations.map.withoutLocation_other": "{{count}} de {{total}} não têm localização e não são mostradas.", + "observations.map.truncated": "A mostrar as {{count}} mais recentes. Filtre por formulário para ver outras.", + "observations.map.legend": "Legenda", + "observations.map.fitAll": "Mostrar todas as observações", + "observations.map.pendingOutline": "Contorno laranja: ainda não sincronizada", + "observations.map.open": "Abrir observação", + "observations.map.samePlace_one": "{{count}} observação neste local", + "observations.map.samePlace_other": "{{count}} observações neste local", + "observations.map.offlineNotice": "Sem internet. Os locais não guardados neste telefone aparecem com fundo simples.", + "observations.map.offlineWithStudyMaps": "Sem internet. A mostrar os mapas incluídos no seu estudo.", + "offlineMaps.title": "Mapas offline", + "offlineMaps.intro": "As áreas guardadas aqui aparecem no mapa sem internet. As áreas que já viu também ficam guardadas durante algum tempo.", + "offlineMaps.bundledTitle": "Incluídos no seu estudo", + "offlineMaps.bundledHint": "Sempre disponível offline", + "offlineMaps.downloadTitle": "Descarregar uma área", + "offlineMaps.observationsArea": "Área à volta destas observações", + "offlineMaps.viewArea": "O que o mapa mostra agora", + "offlineMaps.observationsAreaName": "Área das observações · {{date}}", + "offlineMaps.viewAreaName": "Área do mapa · {{date}}", + "offlineMaps.estimate": "Cerca de {{size}}", + "offlineMaps.tooLarge": "Demasiado grande. Aproxime o mapa e tente de novo.", + "offlineMaps.savedTitle": "Áreas guardadas", + "offlineMaps.none": "Ainda não há áreas guardadas.", + "offlineMaps.ready": "Pronto offline · {{size}}", + "offlineMaps.downloading": "A descarregar… {{percent}}%", + "offlineMaps.paused": "Em pausa a {{percent}}%", + "offlineMaps.resume": "Retomar descarga", + "offlineMaps.blockedOsm": "Os servidores públicos do OpenStreetMap não permitem descarregar áreas. Peça ao administrador do estudo para incluir mapas offline no pacote da aplicação ou configurar um fornecedor de mapas que permita descargas.", + "offlineMaps.blockedBundled": "Este estudo inclui os seus próprios mapas offline, por isso não é preciso descarregar.", + "offlineMaps.blockedNotEnabled": "A descarga de áreas não está ativada para o fornecedor de mapas deste estudo.", + "offlineMaps.loadFailed": "Não foi possível ler as áreas guardadas.", + "offlineMaps.downloadFailed": "Não foi possível iniciar a descarga.", + "offlineMaps.deleteFailed": "Não foi possível eliminar a área.", + "offlineMaps.offlineNoDownload": "Está sem internet. Ligue-se à internet para descarregar uma área.", "help.exportFailed": "Exportação falhou", "help.exportAttachmentsFailed": "Não foi possível exportar anexos.", "help.exportObservationsFailed": "Não foi possível exportar observações.", diff --git a/formulus/src/locales/sw.json b/formulus/src/locales/sw.json index 7fd2228dd..bd4119f43 100644 --- a/formulus/src/locales/sw.json +++ b/formulus/src/locales/sw.json @@ -281,6 +281,48 @@ "observations.loadFailed": "Imeshindwa kupakia rekodi", "observations.updateSuccess": "Rekodi imesasishwa", "observations.deleteSuccess": "Rekodi imefutwa", + "observations.viewList": "Onyesha kama orodha", + "observations.viewMap": "Onyesha kwenye ramani", + "observations.map.loading": "Inapakia ramani...", + "observations.map.errorTitle": "Imeshindwa kupakia ramani", + "observations.map.emptyTitle": "Hakuna cha kuonyesha kwenye ramani", + "observations.map.emptyNoLocation_one": "Uchunguzi {{count}} unalingana, lakini hauna mahali.", + "observations.map.emptyNoLocation_other": "Uchunguzi {{count}} unalingana, lakini hakuna wenye mahali.", + "observations.map.withoutLocation_one": "{{count}} kati ya {{total}} hauna mahali na haujaonyeshwa.", + "observations.map.withoutLocation_other": "{{count}} kati ya {{total}} haina mahali na haijaonyeshwa.", + "observations.map.truncated": "Inaonyesha {{count}} vya karibuni zaidi. Chuja kwa fomu kuona vingine.", + "observations.map.legend": "Ufunguo", + "observations.map.fitAll": "Onyesha uchunguzi wote", + "observations.map.pendingOutline": "Mzunguko wa machungwa: bado haujasawazishwa", + "observations.map.open": "Fungua uchunguzi", + "observations.map.samePlace_one": "Uchunguzi {{count}} mahali hapa", + "observations.map.samePlace_other": "Uchunguzi {{count}} mahali hapa", + "observations.map.offlineNotice": "Nje ya mtandao. Maeneo ambayo hayajahifadhiwa kwenye simu hii yanaonyesha mandharinyuma tupu.", + "observations.map.offlineWithStudyMaps": "Nje ya mtandao. Inaonyesha ramani zilizojumuishwa na utafiti wako.", + "offlineMaps.title": "Ramani nje ya mtandao", + "offlineMaps.intro": "Maeneo yaliyohifadhiwa hapa yanaonekana kwenye ramani bila intaneti. Maeneo uliyokwisha kuyatazama pia huhifadhiwa kwa muda.", + "offlineMaps.bundledTitle": "Yamejumuishwa na utafiti wako", + "offlineMaps.bundledHint": "Yanapatikana nje ya mtandao kila wakati", + "offlineMaps.downloadTitle": "Pakua eneo", + "offlineMaps.observationsArea": "Eneo linalozunguka uchunguzi huu", + "offlineMaps.viewArea": "Kinachoonyeshwa na ramani sasa", + "offlineMaps.observationsAreaName": "Eneo la uchunguzi · {{date}}", + "offlineMaps.viewAreaName": "Eneo la ramani · {{date}}", + "offlineMaps.estimate": "Takriban {{size}}", + "offlineMaps.tooLarge": "Ni kubwa mno. Kuza ramani kisha ujaribu tena.", + "offlineMaps.savedTitle": "Maeneo yaliyohifadhiwa", + "offlineMaps.none": "Bado hakuna maeneo yaliyohifadhiwa.", + "offlineMaps.ready": "Tayari nje ya mtandao · {{size}}", + "offlineMaps.downloading": "Inapakua… {{percent}}%", + "offlineMaps.paused": "Imesitishwa kwa {{percent}}%", + "offlineMaps.resume": "Endelea kupakua", + "offlineMaps.blockedOsm": "Seva za umma za OpenStreetMap haziruhusu kupakua maeneo. Mwombe msimamizi wa utafiti aongeze ramani za nje ya mtandao kwenye kifurushi cha programu au aweke mtoa ramani anayeruhusu kupakua.", + "offlineMaps.blockedBundled": "Utafiti huu una ramani zake za nje ya mtandao, kwa hivyo hakuna haja ya kupakua.", + "offlineMaps.blockedNotEnabled": "Kupakua maeneo hakujawezeshwa kwa mtoa ramani wa utafiti huu.", + "offlineMaps.loadFailed": "Imeshindwa kusoma maeneo yaliyohifadhiwa.", + "offlineMaps.downloadFailed": "Upakuaji haukuweza kuanza.", + "offlineMaps.deleteFailed": "Imeshindwa kufuta eneo.", + "offlineMaps.offlineNoDownload": "Uko nje ya mtandao. Unganisha kwenye intaneti ili kupakua eneo.", "help.exportFailed": "Uhamishaji umeshindikana", "help.exportAttachmentsFailed": "Haikuweza kuhamisha viambatisho.", "help.exportObservationsFailed": "Haikuweza kuhamisha rekodi.", diff --git a/formulus/src/screens/ObservationsScreen.tsx b/formulus/src/screens/ObservationsScreen.tsx index 661232c87..a82a03d8a 100644 --- a/formulus/src/screens/ObservationsScreen.tsx +++ b/formulus/src/screens/ObservationsScreen.tsx @@ -1,4 +1,12 @@ -import React, { useCallback, useEffect, useRef, useState } from 'react'; +import React, { + Suspense, + lazy, + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; import { View, Text, @@ -10,6 +18,8 @@ import { Input as ODEInput } from '../components/common'; import { SafeAreaView } from 'react-native-safe-area-context'; import Icon from '@react-native-vector-icons/material-design-icons'; import { useObservations } from '../hooks/useObservations'; +import { useObservationMap } from '../hooks/useObservationMap'; +import { boundsOfPoints, framingPoints } from '../database/observationMapQuery'; import { EmptyState, FormTypeSelector, @@ -18,7 +28,11 @@ import { ObservationPager, } from '../components/common'; import { FormService } from '../services/FormService'; -import { useFocusEffect, useNavigation } from '@react-navigation/native'; +import { + useFocusEffect, + useIsFocused, + useNavigation, +} from '@react-navigation/native'; import { StackNavigationProp } from '@react-navigation/stack'; import { MainAppStackParamList } from '../types/NavigationTypes'; import colors from '../theme/colors'; @@ -35,6 +49,14 @@ import { useTranslation } from 'react-i18next'; import { logger } from '../diagnostics/logger'; import type { ObservationListRow } from '../database/observationListQuery'; +type ViewMode = 'list' | 'map'; + +// The map engine loads only when someone opens the Map view. +const ObservationMap = lazy(() => import('../components/map/ObservationMap')); +const OfflineMapsSheet = lazy( + () => import('../components/map/OfflineMapsSheet'), +); + type ObservationsScreenNavigationProp = StackNavigationProp< MainAppStackParamList, 'ObservationDetail' @@ -88,6 +110,29 @@ const ObservationsScreen: React.FC = () => { [], ); const [showSearch, setShowSearch] = useState(false); + const [viewMode, setViewMode] = useState('list'); + const [offlineSheet, setOfflineSheet] = useState<{ + viewBounds: [number, number, number, number] | null; + } | null>(null); + // The map holds GPU and tile memory: keep it mounted only while this screen + // is visible (not under ObservationDetail or a form). + const isFocused = useIsFocused(); + const mapActive = viewMode === 'map' && isFocused; + const map = useObservationMap(mapActive, { + formType: selectedFormType, + syncStatus, + search: searchQuery, + }); + const observationsBounds = useMemo( + () => boundsOfPoints(framingPoints(map.points)), + [map.points], + ); + const mapOfflineNotice = + map.online !== false || !map.basemap + ? null + : map.basemap.usesBundledStyle || map.basemap.bundledFiles.length > 0 + ? t('observations.map.offlineWithStudyMaps') + : t('observations.map.offlineNotice'); const refreshRef = useRef(refresh); const skipFocusRefresh = useRef(true); @@ -129,6 +174,13 @@ const ObservationsScreen: React.FC = () => { const showSubtitle = total > 0; + const openObservation = useCallback( + (observationId: string) => { + navigation.navigate('ObservationDetail', { observationId }); + }, + [navigation], + ); + const handleRowPress = useCallback( (row: ObservationListRow) => { navigation.navigate('ObservationDetail', { @@ -232,10 +284,149 @@ const ObservationsScreen: React.FC = () => { onStatusChange={setSyncStatus} containerStyle={{ alignSelf: 'stretch', maxWidth: undefined }} /> + + {(['list', 'map'] as const).map(mode => { + const active = viewMode === mode; + return ( + setViewMode(mode)} + accessibilityRole="tab" + accessibilityState={{ selected: active }} + accessibilityLabel={ + mode === 'list' + ? t('observations.viewList') + : t('observations.viewMap') + } + style={[ + styles.viewToggleButton, + active && { + backgroundColor: themeColors.primary as string, + }, + ]}> + + + ); + })} + - {loading ? ( + {viewMode === 'map' ? ( + + {map.points.length > 0 && + (map.withoutLocation > 0 || map.truncated) ? ( + + + + {map.truncated + ? t('observations.map.truncated', { + count: map.points.length, + }) + : t('observations.map.withoutLocation', { + count: map.withoutLocation, + total: map.total, + })} + + + ) : null} + {map.error && map.points.length === 0 ? ( + + ) : !map.basemap || (map.loading && map.points.length === 0) ? ( + + + + {t('observations.map.loading')} + + + ) : map.points.length === 0 ? ( + 0 + ? t('observations.map.emptyNoLocation', { + count: map.total, + }) + : t('observations.emptyFoundMessage') + } + /> + ) : mapActive ? ( + + + + }> + + setOfflineSheet({ viewBounds }) + } + /> + + ) : null} + {map.basemap && offlineSheet != null ? ( + + setOfflineSheet(null)} + basemap={map.basemap} + online={map.online !== false} + observationsBounds={observationsBounds} + viewBounds={offlineSheet.viewBounds} + /> + + ) : null} + + ) : loading ? (