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 000000000..9e23f3228 Binary files /dev/null and b/formulus/src/assets/map/cluster-100p.png differ 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 000000000..cc322d2be Binary files /dev/null and b/formulus/src/assets/map/cluster-100p@3x.png differ diff --git a/formulus/src/assets/map/cluster-10kp.png b/formulus/src/assets/map/cluster-10kp.png new file mode 100644 index 000000000..383ce6a94 Binary files /dev/null and b/formulus/src/assets/map/cluster-10kp.png differ diff --git a/formulus/src/assets/map/cluster-10kp@3x.png b/formulus/src/assets/map/cluster-10kp@3x.png new file mode 100644 index 000000000..76e2e8836 Binary files /dev/null and b/formulus/src/assets/map/cluster-10kp@3x.png differ diff --git a/formulus/src/assets/map/cluster-10p.png b/formulus/src/assets/map/cluster-10p.png new file mode 100644 index 000000000..bb1866c37 Binary files /dev/null and b/formulus/src/assets/map/cluster-10p.png differ 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 000000000..5de97e4b9 Binary files /dev/null and b/formulus/src/assets/map/cluster-10p@3x.png differ diff --git a/formulus/src/assets/map/cluster-1kp.png b/formulus/src/assets/map/cluster-1kp.png new file mode 100644 index 000000000..6e6d2493a Binary files /dev/null and b/formulus/src/assets/map/cluster-1kp.png differ diff --git a/formulus/src/assets/map/cluster-1kp@3x.png b/formulus/src/assets/map/cluster-1kp@3x.png new file mode 100644 index 000000000..2c80ab118 Binary files /dev/null and b/formulus/src/assets/map/cluster-1kp@3x.png differ diff --git a/formulus/src/assets/map/cluster-2.png b/formulus/src/assets/map/cluster-2.png new file mode 100644 index 000000000..64802cadb Binary files /dev/null and b/formulus/src/assets/map/cluster-2.png differ diff --git a/formulus/src/assets/map/cluster-200p.png b/formulus/src/assets/map/cluster-200p.png new file mode 100644 index 000000000..8fc97cd79 Binary files /dev/null and b/formulus/src/assets/map/cluster-200p.png differ 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 000000000..0801ba674 Binary files /dev/null and b/formulus/src/assets/map/cluster-200p@3x.png differ diff --git a/formulus/src/assets/map/cluster-20p.png b/formulus/src/assets/map/cluster-20p.png new file mode 100644 index 000000000..ee203fb32 Binary files /dev/null and b/formulus/src/assets/map/cluster-20p.png differ diff --git a/formulus/src/assets/map/cluster-20p@3x.png b/formulus/src/assets/map/cluster-20p@3x.png new file mode 100644 index 000000000..d637ac851 Binary files /dev/null and b/formulus/src/assets/map/cluster-20p@3x.png differ 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 000000000..b5aa6e363 Binary files /dev/null and b/formulus/src/assets/map/cluster-2@3x.png differ diff --git a/formulus/src/assets/map/cluster-2kp.png b/formulus/src/assets/map/cluster-2kp.png new file mode 100644 index 000000000..8d4ee2358 Binary files /dev/null and b/formulus/src/assets/map/cluster-2kp.png differ 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 000000000..629d8c370 Binary files /dev/null and b/formulus/src/assets/map/cluster-2kp@3x.png differ diff --git a/formulus/src/assets/map/cluster-3.png b/formulus/src/assets/map/cluster-3.png new file mode 100644 index 000000000..5df57affa Binary files /dev/null and b/formulus/src/assets/map/cluster-3.png differ diff --git a/formulus/src/assets/map/cluster-3@3x.png b/formulus/src/assets/map/cluster-3@3x.png new file mode 100644 index 000000000..29cfef517 Binary files /dev/null and b/formulus/src/assets/map/cluster-3@3x.png differ diff --git a/formulus/src/assets/map/cluster-4.png b/formulus/src/assets/map/cluster-4.png new file mode 100644 index 000000000..692741a5c Binary files /dev/null and b/formulus/src/assets/map/cluster-4.png differ diff --git a/formulus/src/assets/map/cluster-4@3x.png b/formulus/src/assets/map/cluster-4@3x.png new file mode 100644 index 000000000..616f72495 Binary files /dev/null and b/formulus/src/assets/map/cluster-4@3x.png differ diff --git a/formulus/src/assets/map/cluster-5.png b/formulus/src/assets/map/cluster-5.png new file mode 100644 index 000000000..1c17efc71 Binary files /dev/null and b/formulus/src/assets/map/cluster-5.png differ diff --git a/formulus/src/assets/map/cluster-500p.png b/formulus/src/assets/map/cluster-500p.png new file mode 100644 index 000000000..6d1c0f931 Binary files /dev/null and b/formulus/src/assets/map/cluster-500p.png differ diff --git a/formulus/src/assets/map/cluster-500p@3x.png b/formulus/src/assets/map/cluster-500p@3x.png new file mode 100644 index 000000000..f6b3c9e70 Binary files /dev/null and b/formulus/src/assets/map/cluster-500p@3x.png differ diff --git a/formulus/src/assets/map/cluster-50p.png b/formulus/src/assets/map/cluster-50p.png new file mode 100644 index 000000000..06cbb7972 Binary files /dev/null and b/formulus/src/assets/map/cluster-50p.png differ 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 000000000..434a53788 Binary files /dev/null and b/formulus/src/assets/map/cluster-50p@3x.png differ diff --git a/formulus/src/assets/map/cluster-5@3x.png b/formulus/src/assets/map/cluster-5@3x.png new file mode 100644 index 000000000..fcfc8a4dd Binary files /dev/null and b/formulus/src/assets/map/cluster-5@3x.png differ diff --git a/formulus/src/assets/map/cluster-5kp.png b/formulus/src/assets/map/cluster-5kp.png new file mode 100644 index 000000000..b5e55d12d Binary files /dev/null and b/formulus/src/assets/map/cluster-5kp.png differ 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 000000000..87a363b58 Binary files /dev/null and b/formulus/src/assets/map/cluster-5kp@3x.png differ diff --git a/formulus/src/assets/map/cluster-6.png b/formulus/src/assets/map/cluster-6.png new file mode 100644 index 000000000..d050bcac9 Binary files /dev/null and b/formulus/src/assets/map/cluster-6.png differ diff --git a/formulus/src/assets/map/cluster-6@3x.png b/formulus/src/assets/map/cluster-6@3x.png new file mode 100644 index 000000000..600505653 Binary files /dev/null and b/formulus/src/assets/map/cluster-6@3x.png differ diff --git a/formulus/src/assets/map/cluster-7.png b/formulus/src/assets/map/cluster-7.png new file mode 100644 index 000000000..17a1f985f Binary files /dev/null and b/formulus/src/assets/map/cluster-7.png differ diff --git a/formulus/src/assets/map/cluster-7@3x.png b/formulus/src/assets/map/cluster-7@3x.png new file mode 100644 index 000000000..613341680 Binary files /dev/null and b/formulus/src/assets/map/cluster-7@3x.png differ diff --git a/formulus/src/assets/map/cluster-8.png b/formulus/src/assets/map/cluster-8.png new file mode 100644 index 000000000..4a11da38c Binary files /dev/null and b/formulus/src/assets/map/cluster-8.png differ 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 000000000..75617ecb3 Binary files /dev/null and b/formulus/src/assets/map/cluster-8@3x.png differ diff --git a/formulus/src/assets/map/cluster-9.png b/formulus/src/assets/map/cluster-9.png new file mode 100644 index 000000000..a25071ef3 Binary files /dev/null and b/formulus/src/assets/map/cluster-9.png differ diff --git a/formulus/src/assets/map/cluster-9@3x.png b/formulus/src/assets/map/cluster-9@3x.png new file mode 100644 index 000000000..08c7a2ab3 Binary files /dev/null and b/formulus/src/assets/map/cluster-9@3x.png differ diff --git a/formulus/src/components/map/ObservationMap.tsx b/formulus/src/components/map/ObservationMap.tsx new file mode 100644 index 000000000..fae2795d4 --- /dev/null +++ b/formulus/src/components/map/ObservationMap.tsx @@ -0,0 +1,629 @@ +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import { + View, + Text, + StyleSheet, + Pressable, + ScrollView, + Modal, + type NativeSyntheticEvent, +} from 'react-native'; +import { + Camera, + GeoJSONSource, + Images, + Layer, + Map, + Marker, + type CameraRef, + type GeoJSONSourceRef, + type PressEventWithFeatures, +} from '@maplibre/maplibre-react-native'; +import Icon from '@react-native-vector-icons/material-design-icons'; +import { useTranslation } from 'react-i18next'; +import { useAppTheme } from '../../contexts/AppThemeContext'; +import colors from '../../theme/colors'; +import { + odeSpacing, + odeTypography, + odeRadius, + odeBorderWidth, +} from '../../theme/odeDesign'; +import { + boundsOfPoints, + framingPoints, + type ObservationMapPoint, +} from '../../database/observationMapQuery'; +import { + clusterBadgeExpression, + colorsByFormType, + toFeatureCollection, + CLUSTER_MAX_ZOOM, + CLUSTER_RADIUS_PX, + type PointProps, +} from '../../maps/observationClusters'; +import { CLUSTER_BADGE_IMAGES } from '../../maps/clusterBadgeImages'; +import type { Basemap } from '../../maps/basemap'; +import { formatDateTimeShort } from '../../utils/dateUtils'; + +type Bounds = [number, number, number, number]; + +type Props = { + points: ObservationMapPoint[]; + basemap: Basemap; + formNames: Record; + /** 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/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/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/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/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/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/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 ? ( { + 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, 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