Geospatial data and privacy
How to build map features without storing location: the H3 hex grid
In HuntLine IQ a trail-cam observation is written to the database as six H3 cells, not a coordinate. The feed, the statistics and the weather pairing all run on those cells.


SHORT ANSWER
Use the coordinate only at write time, then discard it. HuntLine IQ computes six cells per observation with h3-js, res3 through res8, and stores only those in content.signals; the finest cell (res8) averages 0.74 km². Feed distances are measured from that cell’s center and never shown exactly; local-area intelligence stays unpublished below a contributor floor of 6 to 25, depending on area size.
Where is the exact point kept, and where is it never kept?
HuntLine IQ lets US hunters share trail-camera observations and harvests; web/PWA, iOS and Android share one Supabase project. A hunter wants to share what the camera saw, not where it is. So the first rule sits in the data model: an observation row has no latitude or longitude column.
The exact point lives in only two places, both the member’s own records. A property’s center is in app.zones.center_point, and row-level security lets only its owner read it. Saved upload locations sit in app.upload_locations, which has RLS enabled and no policies at all, so no client can reach it; the Edge Function is the only reader and writer.
The observation itself is written like this:
- The photo’s EXIF is stripped on upload, and if stripping fails the upload is refused. Any GPS inside the file never reaches storage.
- The pin the app sends, or the property center when there is no pin, is used only to derive H3 cells and a county FIPS code, then dropped.
- Pins attempted in states the product does not cover yet are counted, but as state and day only, never as a point.
The mobile permission text says the same: location is read once, to place the property you stand on, and background location is off on both platforms.
Which H3 resolution, and why store six of them?
Instead of picking one resolution, every observation is written with six cells, res3 to res8: h3_index_res3 through h3_index_res8. The product computes statistics for areas of very different sizes, and each area tier lives at its own resolution. The mapping is one import-free file, lib/constants/zone-h3.ts, shared verbatim by Next.js and the Deno functions:
- micro → res8 (0.74 km² on average)
- tight → res7 (5.16 km²)
- neighborhood and local_plus → res6 (36.1 km²)
- local → res5 (252.9 km²)
- county, regional, statewide → not H3 at all; the county FIPS polygon in PostGIS, the state’s own region definition, and the state code
Stopping at res8 is deliberate. A res8 cell has an average edge of about 530 metres: too coarse to describe a camera, fine enough to tie consecutive frames from one camera to the same place. Nothing finer than res8 is stored, so no sharper location can ever be pulled out of the database.
There is also a practical constraint: Supabase Cloud has no H3 Postgres extension. Cells are therefore computed at write time in the Edge Function with npm:h3-js@4.1.0 and stored as denormalized columns. SQL never recomputes a cell; it only compares strings. Each of res5 through res8 has a partial index on (h3_index_resN, observed_at desc) that covers approved rows only.
In what order does an observation get written?
The order of ingest-signal is written at the top of the file: auth → idempotency → validation → tier gate → size and MIME → EXIF strip (hard fail) → image analysis (moderation, crop, non-deer rejection, fail-closed) → perceptual hash → selected property → location → H3 and county → season and weight → storage → a single transactional insert → idempotency cache.
The location step has its own rules. If the app sends coordinates, both must be present, numeric and on the globe; anything else is refused with a 422 rather than repaired, because the app can only send a pin it already validated. The pin is then checked against the same rule the map screen applies: if it does not fall inside a known county it is outside the launched states and is refused. With no coordinates, the selected property’s center is used.
The raw point has one more job before it is dropped: for a Missing Buck alert, a PostGIS ST_DWithin query in the same request finds property owners inside the broadcast radius. They are notified; the point is still never written.
This design has a cost, and the project notes record it plainly: because cells are stamped at upload, when a hunter later moves a property pin, older observations cannot be recomputed against the new location. With no raw coordinate to recompute from, a fix is only possible as a deliberate one-off. It is a trade-off accepted for privacy.
How do feed queries work on cells?
In the feed, an “area” is a viewing ring measured from the hunter’s selected property. The rings live in the feed.bands config row, in miles, mutually exclusive: micro 0–2, tight 2–3, neighborhood 3–5, local+ 5–7, local 7–10, county 10–35, regional 35–75, statewide 75+. Every observation lands in exactly one ring.
Distance is a haversine from the property center to the center of the observation’s res8 cell (haversineMiles(anchorPoint, cellCenter(c.h3_index_res8))). Device GPS and raw coordinates never enter the calculation.
The rule that matters most sits in the presentation layer: public content never displays an exact distance. The code states why: a few cooperating users at known property centers could triangulate an observation from exact radii. So:
- a standard sighting card carries no distance at all; the ring is the context;
- a harvest carries only the ring’s range (“35–75 mi”);
- a Missing Buck alert reads “Reported within N miles of your hunting property”.
The response keeps a distanceMiles field for payload stability, and it is always null.
For members with several properties there is a second guard: an observation goes only to the containing property whose center is closest, so two feeds cannot be compared to narrow the location down. Properties of the same member must also be at least 3 miles apart, enforced at creation. On the scanning side a request looks at up to 3,000 observations and goes back 365 days.
Why are statistics computed on shared cells instead of personal rings?
Feed rings differ per member; intelligence is computed on fixed areas that are the same for everyone: H3 cells, the true county boundary, the state’s regions and the state. An area’s observations are selected in one SQL function by plain string equality on the cell (intelligence.fn_zone_window_aggregate):
or (z.zone_type = 'tight' and s.h3_index_res7 = z.h3_index)
No spatial query is needed; it is an indexed text comparison.
Before a report is published it passes two gates. The first is a contributor floor: micro 6, tight 9, neighborhood 12, local+ 15, local 20, county 25 contributors. There is a separate observation floor, from 7 for micro to 30 statewide. When data is thin the product does not estimate; it says “Insufficient Data”.
The second is an independence layer that applies only to the three finest tiers:
- Layer 1, distance (hard): the largest pairwise distance between contributors’ res8 cell centers must reach 0.16 miles for micro and tight, 0.23 for neighborhood. One tight cluster of cameras cannot open an area on its own.
- Layer 2, sub-hex (hard): the observations must spread over at least 3 distinct cells one resolution finer.
- Layer 3, temporal (soft): at least 2 distinct days; logged only, never a gate.
Micro’s sub-hex would be res9, which is not stored, so Layer 2 auto-passes there. That is the statistics-side consequence of the decision never to go below res8.
How do weather and moon phase correlate without coordinates?
After an observation is saved, a cron job called enrich-signals enriches it, 200 observations per run. Weather is requested at the center of the observation’s res5 cell (cellCenter(s.h3IndexRes5)), the middle of a cell of roughly 250 km². Weather is regional, a cell center is plenty, and no raw point reaches an outside service.
The source is Open-Meteo’s hourly series, with at most 92 days of history and an 8-second timeout. The rules:
- If the nearest hour is more than 3 hours from the observation time, the weather fields stay empty rather than attach conditions from the wrong time.
- Pressure trend uses a 6-hour window and a 0.02 inHg threshold, temperature trend a 24-hour window and 2.0 °F, to label rising, falling or steady.
- Pressure is
pressure_msl, reduced to sea level. The first version used surface pressure, which ran half an inch low on Illinois ground and piled most observations into the lowest pressure bucket. - A failed fetch never erases a stored reading; it only marks the row for retry. An hour the provider has no data for becomes
null, not 0 °F with a north wind.
Moon phase is computed without any network call: a reference new moon at 2000-01-06 18:14 UTC, a synodic month of 29.530588853 days, binned into 8 named phases. The condition cards are count-based: per value, the observation count, daylight share and morning/evening split. “Time spent in this condition” is deliberately missing, because no continuous hourly weather timeline is collected per property.
How are trail-cam frames and harvest records counted without skewing the stats?
Trail cameras fire in bursts, three frames a second apart as one deer walks by, and the first version counted every frame. The rule now: same hunter, same res8 cell, frames less than two minutes apart form one encounter. The camera is unknown, so the res8 cell stands in for it, with no raw coordinate needed. The first frame of a chain is flagged encounter_lead, intelligence counts only those, and the feed still shows every frame. The chains are recomputed nightly before snapshots.
Clock trust is a separate gate. A frame that reads as night (mean brightness below 0.26) but claims a capture time between 10:00 and 14:59 is treated as a camera with AM/PM set wrong and dropped to manual confidence: it stays in the feed and day-level counts but out of everything that needs the clock (peaks, daylight share, condition cards). A morning or evening peak is only published with at least 4 observations holding at least 20% of that half’s traffic.
Movement trend is cell-based too: it compares only res8 cells that had observations in both windows, so growing participation cannot pass for growing movement. A percentage needs at least 4 camera locations, and no single location may carry more than 60% of the change.
Harvests live in a one-to-one child table, content.harvests, and never enter movement intelligence; those queries read only the standard_sighting subtype. A harvest stays in the gallery of the state it happened in. Hunting logs (content.hunting_logs) carry a property id, not a coordinate, plus the property’s weather at save.
If you are building something similar: a checklist
Decisions from HuntLine IQ that carry over to other domains:
- Use the raw coordinate at write time, then drop it. Keep only cell ids on the row, and make metadata (EXIF) stripping fail the upload if it fails.
- Choose the finest resolution by exposure risk, not by product appetite. A resolution you never store is one nobody can leak.
- Write several resolutions as denormalized columns; even without an H3 database extension, statistics queries become indexed string equality.
- Measure distance from the cell center and show it as a range or a ring; an exact number is enough to triangulate.
- Never show the same record to a multi-property member in more than one feed.
- Put statistics behind a contributor floor and an independence check; when data is thin, say so.
- Send outside services (weather and the like) the coarse cell center. Set a pairing tolerance and write a missing value as
null, never 0. - Write down the cost up front: without the coordinate there is no retroactive recompute.
At AMG Software Studio we build location-sensitive mobile and web products with the privacy rules built into the data model.
APPLY IT TO YOUR PRODUCT
Mobile App Development
Native iOS with Swift and SwiftUI, or cross-platform with React Native. Idea to store, end to end.
