Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
24 changes: 15 additions & 9 deletions Where/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -85,9 +85,13 @@ views or thrown errors.
- **WhereUI:** funnel every string through `Strings.swift` (keys in the module
`Localizable.xcstrings`, `bundle: .module`). Counts use catalog plural
variations; years use a grouping-free number style ("2026", not "2,026").
- **WhereCore** (user-visible errors) and **RegionKit** (region names via
`Region.localizedName`) use static `String(localized:bundle: .module)` keys
in their own catalogs.
- **WhereCore:** user-visible errors use static
`String(localized:bundle: .module)` keys in its own catalog.
- **RegionKit:** region names (`Region.localizedName`) come from the
`regions.json` manifest, with an optional `localizationKey` overriding from
RegionKit's own `Localizable.xcstrings` (`bundle: .module`) — so, unlike the
other modules, region names lose static string-catalog extraction (a
deliberate trade-off for a data-driven catalog).
- **Extensions** (WhereWidgets, WhereShareExtension) keep their chrome in
their own catalogs and reuse WhereUI's public presentation helpers for
shared copy.
Expand Down Expand Up @@ -162,12 +166,14 @@ path.
- **New library target:** add to root [`Package.swift`](../Package.swift)
under `Where/<Name>/Sources`, then wire a hosted test bundle in
[`Project.swift`](../Project.swift) via the `unitTests` helper.
- **New region:** add the `Region` case in **`RegionKit`**, then resolve the two
compile errors it forces: a `region.<rawValue>` entry in RegionKit's
`Resources/Localizable.xcstrings` (for `localizedName`) and a
`Region.geometrySource` case (`.usStateFeature(name:)` or `.bundledFile` with a
new `<rawValue>.geojson` in RegionKit's `Resources/`). Add a
`RegionAttributorTests` spot-check (in `RegionKit/Tests`).
- **New region:** it's **pure data** now — add geometry under
`RegionKit/Tools/source/`, run `ruby Where/RegionKit/Tools/generate-regions.rb`
to regenerate `Resources/regions/` + `regions.json` (extend the script's id
map / `NON_US` list as needed), optionally add a `region.<key>` string +
`localizationKey`, and add a `RegionAttributorTests` spot-check. No `Region`
case, no code — `RegionStyle`, region pickers, and the App Intents
`RegionEntity` all derive from the catalog. (See
[`RegionKit/README.md`](RegionKit/README.md#adding-a-region).)
- **New evidence kind / sample source:** add the case and follow the compile
errors through the exhaustive switches.
- **New app icon:** run `./icons --add` (see the root
Expand Down
43 changes: 27 additions & 16 deletions Where/RegionKit/AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,25 +15,36 @@ This file complements the root [`AGENTS.md`](../../AGENTS.md) and the feature
the lowest layer of the feature, and `WhereCore` depends on *it*, never the
reverse.
- Library target in [`Package.swift`](../../Package.swift)
(`Where/RegionKit/Sources`). Bundled region polygons and the region-name
string catalog ship in `Sources/Resources/`.
(`Where/RegionKit/Sources`). The generated catalog manifest + per-region
polygons and the region-name string catalog ship in `Sources/Resources/`; the
(non-bundled) source geometry lives in `Tools/source/`.

## Invariants

- **`Region.geometrySource` is the single source of truth** for where a region's
polygons come from; `Region.localizedName` and `geometrySource` are exhaustive
switches, so adding a `Region` case is a compile error until its name and
geometry are declared (see [README](README.md#adding-a-region)).
- **`Region.allCases` order fixes attribution priority** — `RegionAttributor`
checks regions in declaration order and the first polygon match wins (regions
are mutually exclusive at our resolution). (Day-count ranking of regions lives
in `WhereCore`'s `Region+Ordering`, not here.)
- **Attribution loads once, lazily** (`RegionAttributor.shared`) and is UI-free:
`BoundingBox` / `LongitudeSpan` expose the min/max math, but MapKit conversion
lives in the UI layer.
- **Missing/corrupt bundled geometry is a programmer error** — the loader logs a
`fault` via `RegionLog` *and* `assertionFailure`s (debug), degrading to
`.other` in release rather than crashing.
- **`Region` is a data-driven value type, not a hardcoded enum.** It wraps a
stable `rawValue` id; the set of available regions and their metadata live in
the bundled `regions.json` manifest, read by `RegionCatalog`. Adding a region
is a data change (regenerate via `Tools/generate-regions.rb`), never a new case
— see [README](README.md#adding-a-region). `regions/` + `regions.json` are
generated; never hand-edit them.
- **The catalog's canonical order (`RegionCatalog.all`, hence `Region.allCases`
= catalog order then `.other`) fixes attribution priority** — an attributor
checks its regions in order and the first polygon match wins (regions are
mutually exclusive at our resolution). (Day-count ranking lives in `WhereCore`'s
`Region+Ordering`, not here.)
- **Attribution is per-region, on demand.** `RegionAttributor(for:)` loads only
the passed regions' `regions/<id>.geojson` files, so the app parses only the
tracked set — never the whole US at launch. `.all` loads the whole catalog
(dev viewer/tests); `.shared` the default four. It's UI-free: `BoundingBox` /
`LongitudeSpan` expose the min/max math, but MapKit conversion lives in the UI
layer. `RegionAttributing` lets `WhereCore` supply a live, swappable attributor.
- **Region names are manifest data (a documented trade-off).** `localizedName`
resolves a manifest entry's optional `localizationKey` from the string catalog,
else the manifest's English `name` — so dynamic ids cost static string-catalog
extraction for region names.
- **Missing/corrupt bundled geometry (or manifest) is a programmer error** — the
loader logs a `fault` via `RegionLog` *and* `assertionFailure`s (debug),
degrading to `.other`/an empty catalog in release rather than crashing.
- **Logging goes through `RegionLog.channel(_:)`** (subsystem
`com.stuff.regionkit`), never `WhereLog` — RegionKit owns its own channel and
in-memory store.
Expand Down
118 changes: 94 additions & 24 deletions Where/RegionKit/README.md
Original file line number Diff line number Diff line change
@@ -1,31 +1,39 @@
# RegionKit

The geometry and region-lookup engine behind the Where app. Given a WGS84
`Coordinate`, RegionKit answers *which tracked `Region` is it in?* — backed by
bundled GeoJSON polygons loaded once at process start. It is pure Swift +
Foundation (no SwiftUI, UIKit, SwiftData, or CoreLocation), so it can be reused
and unit-tested in isolation.
`Coordinate`, RegionKit answers *which `Region` is it in?* — backed by bundled
GeoJSON polygons loaded **on demand, per region**. It is pure Swift + Foundation
(no SwiftUI, UIKit, SwiftData, or CoreLocation), so it can be reused and
unit-tested in isolation.

RegionKit is the lowest layer of the Where feature: `WhereCore` (and, through
it, `WhereUI`, the widgets, and the RegionViewer) depend on RegionKit and call
into it for lookup. RegionKit depends only on [`LogKit`](../../Shared/LogKit).

## What you get

- **`Region`** — the tracked-region enum (`.california`, `.newYork`, `.canada`,
`.europeanUnion`, `.other`), with a `localizedName` (from RegionKit's own
string catalog) and a `geometrySource` describing where its polygons come
from. (Day-count *ranking* of regions lives in `WhereCore`, not here —
RegionKit stays about regions and geofencing.)
- **`Region`** — a `Hashable`/`Codable` value type wrapping a stable string id
(`rawValue`, e.g. `"us-CA"`, `"canada"`), with a `localizedName`. It is **not**
a hardcoded enum: the set of *available* regions is data (see `RegionCatalog`).
Conveniences (`.california`, `.newYork`, `.canada`, `.europeanUnion`, `.other`)
read naturally at call sites; `.other` is the catch-all sentinel (no geometry).
(Day-count *ranking* lives in `WhereCore`, not here.)
- **`RegionCatalog`** — the catalog of available regions, loaded from the bundled
`regions.json` manifest: `all`, `localizedName(for:)`, canonical order, and the
per-region geometry files. Adding a region is a data change, not a code change.
- **`Coordinate`** — a plain WGS84 latitude/longitude value type (no
CoreLocation), plus geometry primitives `GeoPolygon`, `BoundingBox`, and the
antimeridian-aware `LongitudeSpan`.
- **`RegionAttributor`** — `region(at:)` maps a coordinate to its `Region`
(bounding-box pre-pass, then an even-odd ray-cast), and `distanceToBoundary`
measures nearness to a region's edge. `RegionAttributor.shared` loads the
bundled polygons lazily.
- **`RegionAttributor`** / **`RegionAttributing`** — `region(at:)` maps a
coordinate to its `Region` (bounding-box pre-pass, then an even-odd ray-cast),
and `distanceToBoundary` measures nearness to a region's edge. An attributor is
built for a **specific set of regions** (`RegionAttributor(for:)`) and loads
only those regions' files; `.all` covers the whole catalog and `.shared` the
default four. `RegionAttributing` is the protocol the app's live, swappable
attributor also conforms to.
- **`RegionGeometryCatalog`** — read-only drawable `RegionOutline`s for the
developer region-map viewer (`.attribution` vs `.source` geometry).
developer region-map viewer (`.attribution` for a given attributor vs `.source`
for the whole catalog).
- **`RegionLog`** — RegionKit's LogKit facade (subsystem `com.stuff.regionkit`).

## Installation
Expand All @@ -49,19 +57,81 @@ print(region.localizedName) // "California"

## Bundled data

Region polygons ship in `Sources/Resources/*.geojson`; see
[`Sources/Resources/README.md`](Sources/Resources/README.md) for provenance and
fidelity notes. Region names resolve through RegionKit's own
`Localizable.xcstrings` (`Region.localizedName`, `bundle: .module`).
The catalog manifest and one GeoJSON file per region ship in
`Sources/Resources/`. Both are **generated** — never hand-edit them.

### `regions.json` — the catalog manifest

An ordered array of entries, one per available region:

```json
{ "id": "us-CA", "name": "California", "localizationKey": "region.california",
"geometry": { "file": "us-CA.geojson" } }
```

- `id` — a stable data identifier, never shown to the user. US states are
`us-<USPS>` (`us-CA`, `us-NY`, …); countries/blocs use a slug (`canada`,
`european-union`). The `other` catch-all isn't in the manifest — it's a
sentinel with no geometry.
- `name` — the English display name (the `localizedName` fallback).
- `localizationKey` — optional; when present, `localizedName` resolves it from
`Localizable.xcstrings` (`bundle: .module`), else falls back to `name`. Only
the handful with existing translations carry one. (Dynamic ids mean names are
data, so region names lose static string-catalog extraction — a deliberate
trade-off.)
- `geometry.file` — the per-region file under `regions/`.
- **Array order is the catalog's canonical order** (US states alphabetically,
then countries/blocs, blocs last): it fixes attribution first-match priority
(regions are mutually exclusive at our resolution) and the day-count ranking
tiebreak.

### `regions/<id>.geojson` — per-region geometry

One FeatureCollection per region (a single `Polygon`/`MultiPolygon` feature,
exterior rings only). `RegionAttributor` loads only the files for the regions
it's asked to attribute, so we never parse the whole US at launch.

### Regenerating

`regions/` and `regions.json` are generated from the (non-bundled) source data
under [`Tools/source/`](Tools/source) by
[`Tools/generate-regions.rb`](Tools/generate-regions.rb) — re-run it from the
repo root after changing the source (the `NAME → us-<USPS>` map lives in the
script):

```sh
ruby Where/RegionKit/Tools/generate-regions.rb
```

### Source data (not bundled)

- **`us-states.geojson`** — US state boundaries (50 states + DC + PR),
`MultiPolygon` per feature keyed by `properties.NAME`; the generator splits it
into one `regions/us-<USPS>.geojson` per feature. Originally
`gz_2010_us_040_00_5m.json` (5m, 2010 census) from
[eric.clst.org/tech/usgeojson](https://eric.clst.org/tech/usgeojson/),
converted from US Census Cartographic Boundary Files. License: US Government
works are public domain (17 U.S.C. § 105); attribution requested (see the repo
`README.md`).
- **`canada.geojson` / `europeanUnion.geojson`** — hand-simplified outlines,
deliberately coarse (fine for `RegionAttributorTests` spot-checks; should be
replaced with higher-fidelity public-domain sources before any production
residency-audit use).

## Adding a region

Add the `Region` case, then resolve the two compile errors it forces: a
`region.<rawValue>` entry in `Sources/Resources/Localizable.xcstrings` (for
`localizedName`) and a `Region.geometrySource` case — either
`.usStateFeature(name:)` (a feature already in `us-states.geojson`, no new file)
or `.bundledFile` with a new `<rawValue>.geojson` in `Sources/Resources/`. Add a
`RegionAttributorTests` spot-check.
Adding a region is now **pure data** — no new `Region` case, no code:

1. Add its geometry to `Tools/source/` (a new feature, or a new source file).
2. Run `ruby Where/RegionKit/Tools/generate-regions.rb` to regenerate
`regions/` + `regions.json` (add the `NAME → id` mapping in the script if it's
a new US feature; blocs/countries get an entry in the script's `NON_US` list).
3. Optionally add a `region.<key>` entry to `Localizable.xcstrings` and point the
manifest entry's `localizationKey` at it (otherwise the English `name` shows).
4. Add a `RegionAttributorTests` spot-check.

Everything downstream (`RegionStyle`, region pickers, the App Intents
`RegionEntity`) derives from the catalog automatically.

## Testing

Expand Down
Loading
Loading