Metadata-Version: 2.5
Name: 3tears-geo
Version: 0.66.0
Summary: Slippy-map tile geometry: WKB decoding, zoom bands, and MVT encoding in application code (no PostGIS)
Project-URL: Repository, https://github.com/pacepace/3tears
Author: pace
License-Expression: MIT
License-File: LICENSE
Classifier: Development Status :: 3 - Alpha
Classifier: Framework :: AsyncIO
Classifier: Intended Audience :: Developers
Classifier: Programming Language :: Python :: 3
Classifier: Programming Language :: Python :: 3.14
Classifier: Topic :: Scientific/Engineering :: GIS
Classifier: Topic :: Software Development :: Libraries
Classifier: Typing :: Typed
Requires-Python: >=3.14
Requires-Dist: 3tears-media-contracts<0.67.0,>=0.66.0
Requires-Dist: 3tears-observe<0.67.0,>=0.66.0
Requires-Dist: 3tears<0.67.0,>=0.66.0
Requires-Dist: mapbox-vector-tile>=2.1
Requires-Dist: shapely>=2.0
Description-Content-Type: text/markdown

# 3tears-geo

Slippy-map tile geometry for the 3tears platform: WKB decoding, zoom bands,
and MVT encoding — all in application code.

## Why this exists in Python rather than in SQL

Every off-the-shelf tile server (Martin, pg_tileserv, Tegola) assumes
PostGIS in the database and calls `ST_AsMVT`. YugabyteDB ships no postgis
extension — verified empirically against `yugabytedb/yugabyte:2025.2.5.2-b5`,
where `CREATE EXTENSION postgis` fails outright with no control file on the
image. There is therefore no `ST_Intersects`, no `ST_Simplify`, and no
`ST_AsMVT` to call.

So this package does that work: `shapely` for the geometry, and
`mapbox-vector-tile` for the encoding. The relevant prior art is Tippecanoe
and supercluster, not the Postgres tile servers.

## The tiling scheme, stated explicitly

Leaving this implicit guarantees a defect, because the two common
conventions differ only in the direction of one axis:

- **Web Mercator (EPSG:3857)**
- **XYZ orientation** — `y` increases *southward* from the top-left origin.
  OpenStreetMap / MapLibre / Google. **Not TMS**, whose `y` increases
  northward.
- Source coordinates are **WGS84 (EPSG:4326)**, projected at build time.
- MVT geometry uses tile-local integer coordinates over a **4096-unit
  extent**, the format default, not varied per layer.

Latitude clamps to ±85.0511287798066°, the bound of the Mercator square.

## Public surface

Imported via `from threetears.geo import …`:

- **tiles** — `TileId`, `BoundingBox`, `tile_for_point`, `tile_bounds`,
  `bounds_to_tile_range`, `TILE_EXTENT`, `MAX_MERCATOR_LATITUDE`.
- **attributes** — `coerce_attribute`, `coerce_attributes`,
  `validate_attribute_value`, `UnsupportedAttributeError`.
- **geometry** — `decode_geometry`, `geometry_bounds`, `point_geometry`.
- **bands** — `feature_band`, `aggregate_band`, `FeatureSpec`,
  `AggregateSpec`, `TileFeature`, `simplification_tolerance`.
- **mvt** — `encode_tile`, `project_to_tile`.
- **features** — `FeatureCache` (per-pod source cache + R-Tree),
  `FeatureEntity`, `FeatureLoader`.
- **collection** — `TileCollection`, `TileEntity`, `LayerDefinition`,
  `ViewportRequest`.
- **scope** — `check_cache_scope`, `MAX_CACHE_SCOPE_LENGTH`.
- **overlay** — `KeyedFeature`, `Overlay`, `cut`, `replace_features`,
  `missing_features`, `area_km2`, `mean_width_m`, `LayerOverlayError`.

`threetears.geo.__all__` is the full list.

## Every tile source names a cache scope

`TileCollection` and `FeatureCache` both **require** `cache_scope`, a keyword
argument with no default. Their caches are keyed by layer name and version,
and nothing in that key says whose layer it is: two sources serving a layer
of the same name (two datasources, or a customer's datasource and a platform
layer) would otherwise share the pod-local L1 table, the NATS L2 keys, the
cross-pod build lock and the registry entry, and whichever built a tile first
would answer for both.

The scope goes into the collections' table names (`geo_tiles_<scope>`,
`geo_features_<scope>`), which are interpolated into SQL and NATS KV keys, so
`check_cache_scope` holds it to a grammar both accept unquoted: a lowercase
letter, then lowercase letters, digits and underscores, at most
`MAX_CACHE_SCOPE_LENGTH` characters. `ds_<32 hex>` for a datasource and
`ns_<32 hex>` for a provider namespace both fit.

## Building layers from other layers

`threetears.geo.overlay` cuts one layer by another, replaces features, and
checks coverage. A layer is a sequence of `KeyedFeature`: a stable id, a
WGS84 geometry, and a key that partitions the layer (a state, a country) so
only features sharing it are paired. Two layers from different sources
disagree at their boundaries by a few meters, so a cut leaves thin slivers
and small fragments; each part of a cut is judged on its own and dropped when
it is narrower or smaller than the caller's thresholds (`mean_width_m`,
`area_km2` are the measures). The thresholds belong to the data: measure them
on the layers in hand and pass them in. Measurements use a local
equirectangular projection around each shape's own latitude.

## Zoom bands: aggregate below, features above

Low zoom is not simplified high zoom. A z4 tile spans a large fraction of a
country; rendering it by simplifying and dropping individual features leaves
an arbitrary sample of whichever survived. A national view showing 4,000 of
180,000 precincts is not a coarse view of the data — it is a different and
misleading dataset.

So each layer declares a crossover. Below it, rows roll up to a coarser
declared geography and each bucket becomes one feature carrying real totals.
Above it, individual features are simplified per zoom and capped in count.
`bl-ds-ai-lcv-registration` reached the same split independently: its
precomputed z4–z10 band holds cluster aggregates, with individual features
only from z11.

The cap is a hard limit, not advice. An uncapped tile in a dense metro
reaches tens of megabytes, which exceeds the NATS payload ceiling, defeats
the L2 hot band, and renders badly. When it binds, features drop by a
declared ranking column so the survivors are the important ones, and the
result records that it was truncated — a silently capped tile reads as
"this is all the data there is".

## Feature ids are not MVT ids

MVT feature ids are **uint64 by specification**. A census geoid or a UUID is
silently coerced to `0` by the encoder — no error — which would collapse
every feature in a tile onto one id and break any client-side join. So only
genuine integers reach the wire-level id; everything else travels as a
property, which is exactly what MapLibre's `promoteId` is for. The id is
always present as a property either way, so a client has one place to look.

## The R-Tree earns its place via chunk coverage

`FeatureCache` indexes cached source features in a SQLite R-Tree (built in,
unlike SpatiaLite) on the same connection pool as its own managed table, as
a `BaseCollection` subclass rather than a bespoke `SQLiteBackend` wrapper.

An index alone cannot answer "which features are in this rectangle": it can
say what a pod *holds*, never whether it holds *all* of them, and a tile
built from a partial set is wrong rather than slow — then cached as
immutable. So the cache tracks coverage by **chunk**: it loads the coarse
tile containing a request and records that chunk as covered. A run of
neighbouring tiles, which overlap almost entirely in source features, then
pays one L3 read between them instead of one each.

Once a rectangle's chunks are covered, the R-Tree answers which held features
overlap it, and an exact rectangle test follows (the R-Tree stores 32-bit
floats rounded outward, so it can over-answer but never under-answer). What a
pod holds is bounded by `max_cached_rows` and evicted least recently read
first, rows and index entries together. One read newly holds at most half the
bound, summed over every chunk it loads; the chunks past that, or a wide
low-zoom rectangle whose entries would pass it, are answered and not held.
A rectangle over more than `max_chunk_reads` uncovered chunks is one loader
call, and with no L1 bound every read is one loader call.

### Attribute coercion is fixed, not per-caller

MVT carries only strings, numbers and booleans, so every other SQL type
needs a declared mapping. Two cases are quietly lossy if chosen badly:

| SQL | MVT | Note |
|---|---|---|
| integer / numeric / double | number | doubles are IEEE754 |
| text / varchar | string | |
| boolean | bool | checked before `int`, since `bool` subclasses it |
| **NULL** | **key omitted** | MVT has no null |
| timestamp / date | string | ISO-8601 UTC; naive stamps read as UTC |
| JSONB / array / bytes | **rejected** | project a scalar column instead |

Omitting the key for NULL is the only faithful encoding, and it puts a real
obligation on the client: a style expression reading that attribute must
supply its own fallback. The upside is that "no data" and "a genuine zero"
stay distinguishable in the tile — which matters most on a choropleth, where
collapsing them shades unmeasured regions as though they were measured.

## Versioning policy

`3tears-geo` versions in lockstep with the rest of the 3tears monorepo:
every package shares one version, tracking the framework git tag. All
packages move together.
