Skip to content

Repository files navigation

Field Survey

Offline-first PWA for light location surveying in the field: GPS + compass readings, optional photo and voice note, saved as a GeoJSON observation. Data leaves the phone through the share sheet as a zip export and comes back through import — no server, no accounts, no network needed. Built for iOS Safari, installed to the home screen. Live at survey.field.works.

See CLAUDE.md for the constraints that bind the implementation and docs/styling.md for the design system.

Status

Field-usable offline. Capture (GPS/compass/photo/save, with in-place note editing and photo retake/delete/add on the open session's own rows — the history view stays read-only), a newest ↔ oldest order toggle for the live session's list, session history, zip export and import (plus Load session, which reopens a past or imported session to add to it, and session deletion — single, or a purge of the fully exported), the offline basemap (with four online basemaps beside it — Esri aerial imagery and three OpenFreeMap streetmaps), feature layers (with tap-to-inspect, an amber selection highlight, and "Record here" — which for a polygon records the polygon's centroid, not where you are standing), OS grid references and marking points you cannot reach are built. The map takes standard gestures — one finger pans, pinch zooms — and the interface itself can never be pinch-zoomed. navigator.storage.persist() is requested at startup, so the browser is asked not to evict IndexedDB under storage pressure — the probe page reports whether it stuck.

Voice notes ride on observations: record on the capture page (webm/opus at ~0.4 MB/min — about half a photo per minute, measured on the device), hear it back before saving, play it from the observations list afterwards. They store beside photos, travel in the export zip under audio/, and import back like everything else. iOS keyboard dictation into the note field remains the zero-code alternative for text.

There is no sync and there will be none. GitHub sync (and with it the encrypted personal access token, the passphrase prompt and the Git Data API commit flow) was planned as Phase 5 and deliberately dropped (2026-08-11): export to the device covers the need, with none of the token handling. Data leaves the phone through the share sheet and comes back through Import; the Exported badge on every observation says whether it has left yet, and a Changed since export badge takes over if a retake, delete, add or note edit touches an observation after its session was exported — the map marker, the history summary and the capture page's own Export hint all pick the same state up, so an amended survey never reads as safely off the device until it is exported again.

To see a map you must first produce basemap archives for your survey areas — see Offline basemap below. Without any, the app works exactly as before and the map panel simply offers to pick a region.

The app is in real field use on an iPhone, but the formal device pass lags the code: docs/ios-manual-checklist.md is the gate, and it is unticked from Phase 3 onward.

Trace modes record a shape by walking it: trace a path turns the walk into a line (a hedgerow, a track, a watercourse); trace a boundary walks a perimeter and closes it into a polygon. Vertices are captured automatically from the GPS watch (accuracy-gated and distance-thinned), the walk survives a force-quit, and a finished trace saves as an ordinary observation — note, photo, voice note and feature link all attach as usual. The app also has a night mode (a single-hue red scheme that preserves dark adaptation, map included) and draws the live fix as a station-mark locator whose compass beam widens with the heading's uncertainty.

Develop

npm install
npm run dev

Test

npm test           # domain logic (node) + UI components (happy-dom)
npm run test:browser  # real IndexedDB/Cache/WebCrypto, chromium + webkit
npm run test:e2e      # Playwright — builds, serves, and drives the real app
npm run lint
npm run format

npm test and npm run test:browser are separate because the browser tier spins up real browser instances and is slower — run it before pushing, not on every save.

Offline basemap

The map is MapLibre GL rendering PMTiles archives of your survey areas. The archives are not part of the build: you pre-bake one per region, commit them, and the app lists them so the surveyor can download the ones they need. Downloaded regions live in IndexedDB and work with no network at all — several can be held at once and switched between in the field.

Four online regions ride alongside the archives. "Aerial imagery (online)" streams Esri World Imagery tile by tile when there is signal (Bing was considered first, but its free tier was retired in June 2025). "Light", "Simple" and "Dark" are OpenFreeMap's hosted Positron, Liberty and Dark styles — labelled streetmaps, streamed the same way, used exactly as their quick start intends (never bulk-fetched; their ToS is hostile to bulk collection, not to per-tile streaming). Nothing is downloaded or cached for any of them — they go grey (or blank paper, for the style-based three) with no connection, which is why none is ever suggested, never a default, and all are disabled in the picker while offline. The map itself keeps working regardless: overlays, feature layers and point-marking are identical over them, and an unreachable provider can never take the app down — a streetmap style that cannot be fetched degrades to a blank local ground with every overlay intact. Do not bulk-fetch or cache any of their tiles for offline use — same rule as OSM. Offline imagery is a raster archive you produce (below).

Vector and raster archives both work. A vector archive gives you styled roads, water and labels; a raster archive (PNG or JPEG tiles — aerial imagery, a scanned map, a site survey) is drawn as-is, with no labels beyond whatever is baked into the pictures. The type is detected from each archive's header, along with the raster tile size, so there is nothing to declare. Note that a region is one or the other: raster imagery is not currently composited over a vector basemap. Vector data of your own goes on top as a feature layer rather than as a second archive — including over raster imagery, which is the case it exists for.

Produce a vector region with the pmtiles CLI against Protomaps' public daily planet build, into public/basemaps/, named for the area:

pmtiles extract https://build.protomaps.com/20260801.pmtiles public/basemaps/north-wiltshire.pmtiles \
  --bbox=-2.2,51.5,-1.6,51.8 \
  --maxzoom=15

npm run basemaps:manifest   # regenerates public/basemaps/manifest.json

A raster region comes from wherever your imagery does — rio pmtiles from a GeoTIFF, or pmtiles convert from an MBTiles pyramid — dropped into the same directory and followed by the same npm run basemaps:manifest.

Building a vector archive from your own data is a different exercise, and one with a sharp edge: it renders blank here, because the style expects Protomaps' schema. docs/making-pmtiles.md covers the tooling on Windows and says plainly what does and does not work.

--bbox is minLon,minLat,maxLon,maxLat. The filename becomes the region's name in the app (north-wiltshire.pmtiles → "North Wiltshire"), and the manifest records each archive's real bounds, zooms and size by reading its header — so the published list can't drift from the files. The manifest is also regenerated automatically by npm run build and npm run dev; commit it so the deployed list matches what you saw locally.

Once deployed, the app's map panel offers "Choose a region". The surveyor downloads what they need, and the app then suggests switching when their GPS fix falls inside a different downloaded region — it always asks, and never switches on its own.

Constraints worth knowing before you pick regions:

  • 100 MB is a hard ceiling per file — GitHub's limit. Git LFS is not a way around it: Pages serves LFS pointer files rather than the real bytes. Shrink the bbox or drop --maxzoom (each zoom level roughly quadruples the tile count) until it fits. A county at z15 is comfortably inside it; a country is not.
  • Every archive is permanent repo weight, checked out on every CI run, so prefer several tight regions over one sprawling one.
  • A newly deployed region appears only after the surveyor accepts the app's update prompt: the manifest is precached along with the rest of the build.
  • An archive the generator cannot read is warned about and skipped, not fatal — one bad file costs you that region, never the deploy. Watch the build log for SKIPPED.
  • Never bulk-fetch tiles from tile.openstreetmap.org or OpenFreeMap to build one. pmtiles extract against Protomaps is the documented, ODbL-licensed, no-key route (see CLAUDE.md).
  • The archive is deliberately excluded from the service-worker precache: Workbox would silently drop anything over 2 MiB, and precached responses can't serve the HTTP Range requests the pmtiles client reads archives with. IndexedDB is the store.

Map labels come from glyphs vendored into public/fonts/noto-sans-regular/ (Noto Sans, OFL 1.1, from protomaps/basemaps-assets) and precached, so they render offline. The five committed ranges cover Latin and the punctuation place names use. A region needing other scripts — Cyrillic, Greek, CJK — needs those ranges added to GLYPH_RANGES in src/map/glyphs.js, then:

node scripts/fetch-glyphs.mjs

Feature layers

Your own GIS data, drawn over whichever basemap is active and tappable. Parcels, designations, monitoring points, a hedgerow network — the things that make an aerial photograph legible. This is the answer for datasets up to a few thousand features; past that, see docs/making-pmtiles.md.

A layer is one GeoJSON file plus an optional style sidecar, in public/feature-layers/:

public/feature-layers/
  parcels.geojson        # a FeatureCollection, EPSG:4326
  parcels.style.json     # optional — everything that cannot be measured
npm run layers:manifest   # regenerates public/feature-layers/manifest.json

(npm run build and npm run dev also regenerate it automatically — dropping the files in and starting either is enough. Commit the regenerated manifest with the layer.)

Every key of the sidecar is optional:

Key Default What it does
name title-cased filename How the layer is listed in the app
colour #1c5f9e Fill, line, point and label colour
lineWidth 2 Line and polygon-outline width
fillOpacity 0.15 Polygon fill only — outlines are always solid
circleRadius 5 Point radius
labelProperty none Which property to draw as a label on the map
titleProperty none Which property titles the feature in the tap sheet
idProperty the feature's own id What gets recorded on an observation
fieldOrder alphabetical Which attributes show first in the tap sheet
minZoom 0 Hide the layer until this zoom — use it for dense data

Don't set colour to #c2611f: that is the live GPS fix and its accuracy ring, and a layer in it would scatter things across the map that read as "you are here".

In the app, Change map → Maps and layers lists every published layer. Switching one on fetches it once into IndexedDB; after that it works with no network, and switching it off keeps the data so it comes back offline too. Remove is what reclaims the space, and is offered only for a layer that is already switched off.

Tapping a feature highlights it on the map in amber, shows its attributes and offers Record here, which starts an observation linked to that feature — the highlight stays on the linked feature until Save or Unlink. For a polygon, Record here places the observation at the polygon's centroid rather than where you are standing (you are at the gate; the parcel is the record): it goes through the same marked-point path as the crosshair, position_source: "map", with an accuracy figure spanning the polygon, and "Use my position" before Save reverts it. The link travels into the exported GeoJSON as feature_layer, feature_id and feature_label, so a session can be joined back to the dataset it was surveyed against. Those three columns are present on every observation, null where there is no link.

Three things that will bite:

  • Coordinates must be EPSG:4326. British National Grid data exported without reprojecting is valid GeoJSON that lands in the Atlantic. The generator rejects out-of-range coordinates and names the fix — ogr2ogr -t_srs EPSG:4326 — rather than publishing a broken layer.
  • The manifest is precached; the GeoJSON is not. That is deliberate: the list has to be readable offline, and the data belongs in IndexedDB where there is no 2 MiB cliff.
  • A layer the generator cannot read is warned about and skipped, never fatal — same rule as the basemap archives. Watch the build log for SKIPPED.

Grid references

Every observation shows and exports an Ordnance Survey grid reference (SU 14082 39216) alongside its latitude and longitude — on the live readout, on each saved card, and as os_grid_ref in session.geojson. Computed offline, with no API key and no request budget.

It is derived from the coordinates at display and export time rather than stored, because it is a restatement of them and a stored copy could only drift. Outside Great Britain it is null, and the column is still present in the export: a GIS consumer takes its schema from the rows it sees, so a column that appears only for southern surveys is worse than a column of nulls.

The transformation is OSTN15, not a Helmert approximation. Projecting lat/lon onto the National Grid gives ETRS89 eastings and northings — right projection, wrong datum, about 100 m out. OSTN15 is the correction, and a single-parameter Helmert transform instead would leave 4–5 m of error on a reading whose GPS accuracy is 5–10 m.

The shift grid is vendored from OS's Lite developer pack:

node scripts/fetch-ostn15.mjs

That downloads the pack, reduces it to public/geodesy/ostn15-lite.json (34 kB, precached), and saves OS's 115 published test points to src/geo/fixtures/src/geo/osgb.test.js checks every one to within a millimetre. Lite rather than the full grid because the full transformation is a 13 MB pack or a 28 MB NTv2 file, and OS put Lite's error at 0.08 m RMS against it: around one percent of the GPS error being transformed.

Licence, honestly: OS publish OSTN15 free of charge "as raw data for developers", and the developer pack exists to be implemented in software. What is not stated anywhere I could find — neither the pack's user guide nor the resources page — is whether the grid may be redistributed inside a repository. Other open-source implementations embed it, and the app carries the OS OpenData attribution at the foot of the capture page. If you would rather carry no doubt at all, add public/geodesy/ to .gitignore and run the script as a setup step: the app degrades to showing no grid references rather than breaking.

Why not what3words

Asked and answered, so it does not get re-investigated. Two independent blockers:

  • No offline path. what3words converts via their web API or their offline SDKs, and those SDKs are native iOS/Android/C++/Java. There is no JavaScript or WASM build at any price, so in a PWA a 3-word address can only resolve when there is signal — which is not when a surveyor is standing in a field tapping Save.
  • The licence forbids the useful part. Clause 6.3(b) of the API licence: "you must not display, or otherwise share with any third party, any 3 Word Address alongside its corresponding coordinates." Storage is permitted (6.3(e)(ii), up to 100 million), but this app's only outputs are a GeoJSON carrying lat/lon, shared as a zip and committed to a data repo. The pairing is the entire product.

Plus Codes (Apache-2.0, offline, no key) would clear both bars if a global short code is ever wanted alongside the grid reference.

Marking a point you cannot reach

A surveyor can often see a thing they cannot get to — the far side of a river, a pylon in standing crop, a roof. Mark a distant point on the map panel puts a crosshair at the centre of the map; pan the ground under it, check the grid reference and distance in the readout, and confirm. The next Save records that point instead of the phone's position.

A crosshair rather than a tap because a gloved fingertip is a 44 px target that covers the thing being aimed at. Follow mode switches off while picking, so an incoming GPS fix cannot drag the map off the target.

The observation is otherwise completely ordinary. Two things differ:

  • gps_accuracy_m holds the map precision at the zoom it was picked at — a few metres zoomed right in, tens of metres zoomed out — rather than a fix accuracy. Zooming in genuinely is a more precise placement, and the number reflects that.
  • position_source is map rather than gps. Without it, a point eyeballed from 300 m away and a satellite fix would be indistinguishable, since the accuracy figure reads the same either way. Saved cards say "Marked on the map, not measured"; the column is on every observation.

fix_at and the heading still come from the surveyor's own fix — the sighting was made from somewhere, at a time, and that is worth keeping. Altitude is dropped rather than carried across: the far side of a valley is not at the height you are standing at.

Fonts and styling

Two font families are vendored into public/fonts/ and precached, because the app has to render with no network at all — a hosted webfont would leave the interface in a fallback face in exactly the situation the app exists for.

Family Where For Script
Atkinson Hyperlegible public/fonts/atkinson/ the UI node scripts/fetch-fonts.mjs
Noto Sans (glyph .pbf) public/fonts/noto-sans-regular/ map labels node scripts/fetch-glyphs.mjs

Both are OFL 1.1 and both are committed — the scripts are run by hand, not on install. Adding any asset means checking the precache count in the build output: something unprecached is invisible on a laptop and missing in a field.

docs/styling.md is the design record: the tokens, the type scale, every component's treatment, and the constraints any future change has to keep.

Manual verification

Playwright's WebKit is not Safari and not iOS. Before signing off any phase, run through docs/ios-manual-checklist.md on a real iPhone. Android has its own gate, docs/android-manual-checklist.md — see the Android section below for status.

Android

iOS Safari remains the design target and the sign-off gate, but Android is now a genuinely supported platform, not merely un-blocked. The app is deliberately web-standard and the automated browser tests already run Chromium — the engine Android Chrome ships — so almost everything worked there without changes: the manifest (standalone display, portrait, a maskable icon), storage (IndexedDB as ArrayBuffers, persist()), photo capture, voice notes (webm/opus is Chrome's native recording format), Web Share with a download fallback, and the whole map stack.

What the 2026-08-17 pass added, all feature-detected and iOS-neutral:

  • Compass. src/sensors/heading.js now subscribes to deviceorientationabsolute in addition to deviceorientation, never instead of it — some Chromium devices with no relative-orientation sensor feed absolute data into the plain event, and swapping the subscription (rather than adding to it) would have broken that device's working compass. Detected via 'ondeviceorientationabsolute' in target, not truthiness — Chromium exposes the property as an unassigned null. toHeadingReading's existing absolute + alpha branch converts the reading; only the subscription changed. iOS is unaffected — WebKit has never implemented the property, so the in check resolves to exactly the one listener it has always had, enforced by a fence test.
  • Standalone detection. isStandalone() moved from src/probe/capabilities.js to src/app/standalone.js (avoiding a directory-level import cycle with offlineStatus.js, which needs it too) and is now used by readOfflineStatus/subscribeOfflineStatus instead of a bare Boolean(navigator.standalone). An installed Android PWA now correctly reports standalone: true on the probe page via the standard (display-mode: standalone) media query.
  • Touch hardening. overscroll-behavior-y: contain (scoped to browser-tab mode only, so installed apps on both platforms keep their bounce), -webkit-tap-highlight-color: transparent, and user-select: none on buttons/photo-control labels (not the coordinate readouts). See docs/styling.md → "Touch hardening for Android" for the full reasoning.
  • mobile-chrome e2e coverage. playwright.config.js gained a devices['Pixel 7'] project. It shares browserName: 'chromium' with the desktop project, so every spec already gated on that runs at mobile viewport with touch automatically — no guard changes needed. Everything added since rides the same rule: the revisit-mode e2e (e2e/revisit.spec.js) runs on the mobile-chrome project with no Android-specific work.
  • A device pass. docs/android-manual-checklist.md — install from Chrome's menu (the maskable icon should render uncropped), compass shows a heading after Start with no permission prompt, share-sheet export flips the Exported badge, the app launches offline from the icon, voice notes record and play back, plus several items (share-sheet cancellation, the canShare fallback path, the sticky history header's safe-area) that could only be identified, not verified, by reading source. Nothing on it has been run yet — no Android device was available for this pass.
  • A known cosmetic difference to accept. Android reports no compass accuracy figure, so the locator deliberately draws its widest, faintest beam — the designed treatment for an unknown uncertainty, not a bug.

Landed since that pass, checklist updated but still unrun on a device (2026-08-24):

  • Revisit mode works on web-standard paths throughout — the reference zip picker (accept now includes application/octet-stream, because Android document providers commonly report a zip as bare binary and grey it out), the native camera via <input capture="environment">, crypto.subtle hashing (secure context only — test over HTTPS), and DecompressionStream('deflate-raw') (Chrome 111+). The checklist gained a Revisit section mirroring the iOS one.
  • Background GPS during traces: Chromium deliberately stops geolocation callbacks whenever the page is not foregrounded, so a backgrounded trace loses its fix stream on Android exactly as on iOS. The app now records the missed stretch honestly (dotted on the map, trace_gaps in the export, a one-line notice on return) and holds a screen wake lock while a trace records (Chrome 84+) so the screen never auto-locks mid-walk.

Deliberately out of scope: a custom beforeinstallprompt install affordance (Chrome's own "Install app" menu entry covers it) and screen.orientation.lock() (unnecessary — Android already honours the manifest's portrait lock, unlike iOS).

Deploy

Pushing to main builds and deploys to GitHub Pages via Actions (.github/workflows/ci.yml). First-time setup: in the repo's Settings → Pages, set the source to "GitHub Actions". The app is served at the root of the custom domain survey.field.works (a Cloudflare CNAME; the domain is set in the same Pages settings — an Actions deploy ignores CNAME files, so there is none), which is why vite.config.js's base is '/'. Changing or removing the domain means changing base with it: the two only work together.

Import a session

Session history → Import session reads a previously exported zip (or a bare session.geojson) back onto the device. It always creates a copy under fresh ids — importing never overwrites or merges, importing twice yields two visible copies, and a session exported mid-way arrives closed: it is an archive copy, not a continuation. A malformed file fails on the Import tap with a named reason and writes nothing (one transaction). Since the export format carries the session itself (survey_session, a GeoJSON foreign member), the copy keeps its name and times; older zips without it are reconstructed from the features.

Load session is the continuation import deliberately is not: from a past session's detail view (an imported copy included), it reopens that session in the capture interface so new observations land in it. It is refused while another session is open — end the live session first; nothing is ever closed for you. What was already exported keeps its Exported badge; anything captured after loading honestly reads Not exported. While a session is open, each saved observation's note can be edited in place from the list, and its photo retaken, deleted or added (the history view stays read-only throughout). Editing an observation that was already part of an export doesn't clear its Exported badge — it switches to Changed since export, so the surveyor knows to send it again rather than assuming the earlier export still matches.

Sessions can also be deleted — permanently and locally, observations, photos and voice notes together, behind a two-step confirm that states how many observations have never been exported before you commit. Delete exported sessions clears every session whose observations have all left the device (the same test the ✓ Exported badge uses), leaving anything unexported standing. There is no undo: export first if the data matters.

Data

Sessions live on the device and leave it only through export — there is no server, no token, and nothing to sync. The exported zip (GeoJSON + photos) is the canonical interchange format, and identical data always exports byte-identically, so exports are diffable and dedupable.

The lens per photo. Each entry in a feature's photos[] carries focal_length_35mm (the 35 mm equivalent, an integer), focal_length_mm (the physical focal length) and lens (the lens model string), all null when unknown. The exported JPEGs themselves carry no EXIF — every photo is re-encoded on the device at 1600px — so these fields are the only record of the lens. They are read from the original file at capture, and only a photo picked From library has them: on iOS, a shot taken through the in-app shutter is re-encoded by WebKit's camera UI without its camera tags, so the shutter's photos record null. Take the photo in the Camera app and pick it from the library when the lens matters (a revisit reference, say). The framing screen shows the reference's lens under its photo and, after a shot on a different lens, says so in a line.

About

No description, website, or topics provided.

Resources

Stars

0 stars

Watchers

0 watching

Forks

Releases

Packages

Contributors

Languages