Skip to content
Open
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
123 changes: 123 additions & 0 deletions .claude/skills/analyze-tiles/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,123 @@
---
name: analyze-tiles
description: >
Inspect a built .pmtiles archive — header/metadata, per-layer size breakdown, and the biggest
tiles — using pmtiles show and planetiler's TileSizeStats plus duckdb. Use when asked to
analyze/inspect/profile a tileset, find what's making tiles big, compare two archives' sizes,
or check an archive's metadata. Takes the archive name as its argument.
---

# Analyze tiles (what's in the archive, and what's making it big)

Read-only inspection of `tiles/data/<name>.pmtiles`. Run from the `tiles/` directory at the
repo root.

**Requires two external tools** that this repo does not vendor: the `pmtiles` binary
([go-pmtiles](https://github.com/protomaps/go-pmtiles) releases) and
[`duckdb`](https://duckdb.org). Check with `command -v pmtiles duckdb` before relying on
them; if either is missing, say so and point at the install rather than improvising a
substitute. Note `tiles/Makefile` invokes `pmtiles` as `~/Downloads/pmtiles`, so a checkout
may have the binary without it being on PATH.

Two independent tools, for two different questions. Pick by what was asked — don't run both by
reflex:

- **"What is this archive?"** → `pmtiles show` (instant).
- **"Why is it big / which layer dominates?"** → `TileSizeStats` + duckdb (a minute on a large
archive).

## 0. Resolve the archive (the argument)

Required — names `tiles/data/<name>.pmtiles` (e.g. `/analyze-tiles new-york`). List with
`ls -lh data/*.pmtiles`. If it doesn't exist, stop and offer `/build-tiles <area>`.

## 1. Header and metadata — `pmtiles show`

```
pmtiles show data/<name>.pmtiles
```

Gives bounds, min/max zoom, center, tile/entry/content counts, compression, clustering, and
the planetiler provenance keys. The useful ones to actually read back:

- `planetiler:osm:osmosisreplicationtime` — **the OSM snapshot date of the source extract.**
This is how you tell whether an archive predates a data change you're looking for; a missing
feature is often a stale extract, not a code bug.
- `addressed tiles count` vs `tile contents count` — the gap is deduplication (identical tiles
stored once). A big gap is normal and healthy, especially for sparse extracts.
- `min zoom` / `max zoom` — confirms a `--maxzoom` actually took effect.

`vector_layers` is elided in the default output; add `--metadata` for the full JSON when the
question is about layer/attribute schema.

Related subcommands worth knowing: `pmtiles verify <input>` checks archive structure (use when
a build was interrupted and corruption is suspected — the Makefile has a `clean-pmtiles`
target for exactly this), and `pmtiles tile <path> <z> <x> <y>` dumps one tile.

## 2. Size breakdown — TileSizeStats + duckdb

Planetiler's stats tool runs standalone against an already-built archive:

```
java -cp target/*-with-deps.jar com.onthegomap.planetiler.util.TileSizeStats \
--input=data/<name>.pmtiles \
--output=/tmp/<name>-layerstats.tsv.gz
```

It logs the biggest tiles directly (with a demo-map link and the dominant layer per tile),
which is often the whole answer — read the `Biggest tiles (gzipped)` block before writing any
query.

It also writes a gzipped TSV, one row per tile *per layer*, with columns:

```
z x y hilbert archived_tile_bytes layer layer_bytes layer_features
layer_geometries layer_attr_bytes layer_attr_keys layer_attr_values
```

Query it with duckdb (reads the `.gz` directly, no decompression step):

```
duckdb -c "select layer, sum(layer_bytes)/1024/1024 as mb, sum(layer_features) as feats
from read_csv('/tmp/<name>-layerstats.tsv.gz', delim='\t', header=true)
group by layer order by mb desc;"
```

Other cuts that answer real questions:

- **Which zoom is expensive** — `group by z` instead of `layer`.
- **Worst individual tiles** — `select z,x,y,layer,layer_bytes … order by layer_bytes desc limit 20`.
- **Attribute bloat** — compare `layer_attr_bytes` against `layer_bytes`; a high ratio means
the tags are the payload, not the geometry. Relevant when a change adds per-feature
attributes (shield text, network names) rather than geometry.

To get layerstats at build time instead, planetiler takes `--output-layerstats`, which writes
`<output>.layerstats.tsv.gz` alongside the tileset — cheaper than a separate pass if you know
in advance you'll want it.

## Comparing two archives

The common real task ("did my change bloat the tiles?") is a diff, and the repo keeps prior
builds around (`ny-shields.pmtiles`, `ny-norefs.pmtiles`, …) for this. Run TileSizeStats over
both, then join:

```
duckdb -c "select a.layer, sum(a.layer_bytes)/1024/1024 as before_mb,
sum(b.layer_bytes)/1024/1024 as after_mb
from read_csv('/tmp/before.tsv.gz', delim='\t', header=true) a
full join read_csv('/tmp/after.tsv.gz', delim='\t', header=true) b
on a.z=b.z and a.x=b.x and a.y=b.y and a.layer=b.layer
group by a.layer order by after_mb - before_mb desc;"
```

Only compare archives built from the **same extract** — a different source area or snapshot
date makes the numbers meaningless, and `pmtiles show` tells you both.

## Notes

- TileSizeStats decompresses and re-parses every tile, so it's minutes on a large archive and
seconds on a small one. Consider testing a query shape against a small archive
(`data/output.pmtiles`) before running it on a 500MB one.
- Report layer sizes in MB with the dominant layer named, not raw duckdb tables — the answer
is usually "pois is 40% of the archive", not a grid of numbers.
- These tools read the archive only; nothing here can corrupt a build.
80 changes: 80 additions & 0 deletions .claude/skills/build-tiles/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,80 @@
---
name: build-tiles
description: >
Compile the current working-tree Java source into a fresh planetiler jar and build a
pmtiles tileset for a user-specified source area, streaming live build progress into the
chat. Use when asked to build tiles, (re)build the tileset, compile-and-run planetiler, or
build/generate a pmtiles for an area/extract. Takes the source area as its argument.
---

# Build tiles (compile current source, then run + watch)

Always does three things, in order: **compile the current source**, **build the area**, and
**stream progress**. Run from the `tiles/` directory at the repo root.

## 0. Resolve the source area (the argument)

The area is user-defined and required — it's the skill's argument (e.g. `/build-tiles sfbay`).

- A local extract lives at `data/sources/<area>.osm.pbf`; pass `--area=<area>` and planetiler
uses it directly (no download). List what's available with `ls data/sources/*.osm.pbf`.
- If no area was given, list the local extracts and ask which one.
- If the area isn't present locally but is a known Geofabrik region, add `--download` so
planetiler fetches it (slower). Default the output to `data/<area>.pmtiles`.

## 1. Compile the current source (always)

Rebuild the fat jar so it reflects working-tree changes — this is the whole point of the
skill, so never skip it:

```
mvn -q package -DskipTests
```

Tests are skipped for speed; this only compiles + packages. Confirm
`target/*-with-deps.jar` exists and its mtime is fresh before continuing. If the build fails,
stop and report the compile error — do not run the old jar.

## 2. Build the area under a progress watch

`Monitor` is a deferred tool: load it first with `ToolSearch` query `select:Monitor`.

Launch planetiler *as* the Monitor command so the watch ends when the build exits. Pipe
through a **single unbuffered `awk` stage** that splits planetiler's carriage-return progress
bar into lines, keeps the per-stage milestones, and `fflush()`es each so events stream live
(a `tr | grep` pipeline block-buffers and dumps everything at the end — do not use it):

```
Monitor(
description = "build-tiles <area>",
command = "cd \"$(git rev-parse --show-toplevel)/tiles\" && java -jar target/*-with-deps.jar --area=<AREA> --output=data/<OUT>.pmtiles --force 2>&1 | awk 'BEGIN{RS=\"[\\r\\n]\"} /INF \\[|Finished in|Exception|ERROR/{print; fflush()}'",
timeout_ms = <expected build seconds * 1000, generously; ~300000 for an NY-size extract>,
)
```

(`git rev-parse --show-toplevel` resolves the repo root wherever the checkout lives, so the
command works regardless of the session's cwd.)

You get one milestone line per ~10s tick — a sequence of progress snapshots, not a single
in-place bar (the chat renders each event as its own message and cannot rewrite a prior one).

## Reading the stream

- `[ne]` — Natural Earth prep (~13s).
- `[osm_pass1]` — reads nodes/ways/relations (fast).
- `[osm_pass2]` — the long phase; watch `ways: [ … NN% … ]` climb (e.g. 7→39→68→98%).
- `[osm_water]`, `[osm_land]`, `[landcover]`, `[sort]` — quick.
- `[archive]` — encodes/writes tiles z0…z15; `archive NNNMB` is the final size.
- `Finished in …` / `FINISHED!` — done.

Relay progress by stage and percentage, not raw line dumps.

## Notes

- `--force` is required when re-running to an existing `--output`; planetiler otherwise aborts
immediately (`… already exists, use the --force argument …`). That abort exits before any
`INF [stage]` line, which is why the filter also matches `Exception|ERROR` — otherwise a
config crash looks like a silent hang.
- A full NY extract is ~2.5 min; sfbay ~1 min. Set `timeout_ms` generously above that.
- The harness re-invokes you when the build process exits, so you always get a completion
signal even between progress ticks.
148 changes: 148 additions & 0 deletions .claude/skills/download-osm/SKILL.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
---
name: download-osm
description: >
Cut an OSM extract for a named place using the SliceOSM API and save it into
tiles/data/sources/ as a .osm.pbf ready for /build-tiles. Use when asked to download/fetch/cut
an OSM extract, get the data for a place, or prepare sources before a build. Takes the place
name as its argument.
---

# Download OSM (SliceOSM extract for a named place)

Turns a **place name** into a `.osm.pbf` in `tiles/data/sources/`. Does not build anything —
`/build-tiles <name>` picks it up from there.

Do **not** use planetiler's `--download` or Geofabrik for this. SliceOSM cuts an arbitrary
bbox from a live OSM Express database, so it isn't limited to Geofabrik's fixed regions and
the snapshot is current.

API: **`https://slice.openstreetmap.us/api/`** (docs: https://github.com/SliceOSM/sliceosm-api)

## 0. Place name → bbox (do this yourself, no geocoder)

The place is the skill's argument (e.g. `/download-osm minneapolis`). **Derive the bounding
box from your own world knowledge — do not call a geocoding service.**

Aim for a box that covers the place's built-up extent plus a little margin. Rough is fine and
expected; a slightly generous box costs extract time, a too-tight one silently clips the map
at the edges, so err generous.

State the bbox you chose and roughly what it covers before submitting — it's a guess, and the
user is the one who knows if it's wrong. Ask first only when the name is genuinely ambiguous
(Portland OR vs ME; Cambridge UK vs MA) or the box is large enough to risk the node limit.

## 1. Submit the task

`POST /` with a JSON body. **The bbox order is `[min_lat, min_lon, max_lat, max_lon]` — latitude
first.** This is the single biggest trap here: it matches neither GeoJSON (lon,lat) nor
planetiler's `--bounds=w,s,e,n` (lon first). Swapping them either errors or silently cuts a
region on the other side of the world.

```
curl -s -X POST https://slice.openstreetmap.us/api/ \
-d '{"Name":"<slug>","RegionType":"bbox","RegionData":[<min_lat>,<min_lon>,<max_lat>,<max_lon>]}'
```

The response is a bare **UUID** on success, or an error message — it is not JSON, so check it
looks like a UUID before polling. `Name` is just a human label; use the slug.

For a non-rectangular cut, `"RegionType":"geojson"` takes a Polygon/MultiPolygon geometry in
`RegionData` (normal GeoJSON lon,lat order, unlike the bbox form). There's an unspecified
vertex limit.

## 2. Poll until complete

`GET /{uuid}` returns progress; the fields are strings:

```
{"Timestamp":"","CellsProg":"","CellsTotal":"","NodesProg":"","NodesTotal":"",
"ElemsProg":"","ElemsTotal":"","SizeBytes":"","Elapsed":"","Complete":""}
```

Poll with `Monitor` (a deferred tool: load it with `ToolSearch` query `select:Monitor`) so
progress streams and the watch ends when the job does:

```
Monitor(
description = "sliceosm <slug>",
command = "for i in $(seq 1 120); do r=$(curl -s https://slice.openstreetmap.us/api/<uuid> || true); echo \"$r\" | grep -q '\"Complete\":\"\\?true' && { echo \"COMPLETE $r\"; break; }; echo \"$r\"; sleep 5; done",
timeout_ms = 600000,
)
```

Emit the raw progress line each tick and break on `Complete` — a loop that only prints on
success is silent through a stuck job, which is indistinguishable from a slow one. The task
URL is valid for 24 hours.

Relay progress as nodes/elements percentages, not raw JSON dumps.

## 3. Save with the naming convention

Download to `data/sources/` — **never** to the repo root or `tiles/`:

```
curl -s -o data/sources/<slug>-<YYMMDD>.osm.pbf https://slice.openstreetmap.us/api/<uuid>.osm.pbf
```

**Naming convention: `<place-slug>-<YYMMDD>.osm.pbf`**, where:

- `<place-slug>` is the place, lowercased and hyphenated (`new-york`, `sf-bay`).
- `<YYMMDD>` is the **OSM snapshot date** from the task's `Timestamp` field (or the API root's
`Timestamp`) — *not* today's date, and not the download date.

This matches the existing `new-york-260503.osm.pbf` (a 2026-05-03 snapshot). The date is the
point: extracts of the same place from different days are different data, and `pmtiles show`
on a built archive reports `planetiler:osm:osmosisreplicationtime`, so a dated filename is
what lets you line an archive up with its source. Undated names like `nyc.osm.pbf` are older
files that predate this convention — don't add more.

The slug becomes the `/build-tiles` argument, since planetiler resolves `--area=<name>` to
`data/sources/<name>.osm.pbf`. So this saves as `new-york-260503.osm.pbf` and builds with
`/build-tiles new-york-260503`.

Verify before reporting success:

```
ls -lh data/sources/<slug>-<YYMMDD>.osm.pbf
```

A 0-byte or few-hundred-byte file means the download raced ahead of completion or returned an
error body — check it's really a pbf, delete it if not. A corrupt pbf fails confusingly later,
deep inside a build. `GET /{uuid}.osm.pbf` 404s until the task completes, so an early grab
saves an error page under a `.osm.pbf` name.

Two cheap confirmations:

```
file data/sources/<slug>-<YYMMDD>.osm.pbf # "OpenStreetMap Protocolbuffer Binary Format"
osmium fileinfo -e data/sources/<slug>-<YYMMDD>.osm.pbf # bbox, counts, replication timestamp
```

`file` needs nothing extra. `osmium` is a separate install (`osmium-tool`; `brew install
osmium-tool` on macOS) — `render-tests/generate_pmtiles.sh` depends on it too, so a full
checkout usually has it, but skip this check rather than failing the skill if it's absent.

`osmium fileinfo -e` reports `osmosis_replication_timestamp` — the authoritative source for
the `<YYMMDD>` in the filename, and the same value `pmtiles show` will later report on any
archive built from this extract.

**The extract's actual bbox is wider than the one you asked for.** SliceOSM keeps ways and
relations whole when they cross the boundary, so long features (highways, boundary relations)
drag geometry well past the box — a San Diego request of `-117.35,-116.85` came back spanning
to `-116.11`, over half a degree beyond. This is normal and not a bug. If you need a hard
edge, clip at *build* time with planetiler's `--bounds=<w,s,e,n>` (lon-first there, unlike
here); don't try to fix it by shrinking the request.

## Notes

- **Node limit is 100,000,000** (`NodesLimit` at `GET /`). A bbox over it is rejected. For
scale: the New York state extract is ~56M nodes, so metro-area boxes are comfortable and a
whole-country box is not. If rejected, cut a smaller box rather than retrying.
- `GET /` also reports `QueueSize` — if a submit seems to hang, check whether the server is
backed up rather than assuming the job failed.
- `GET /{uuid}_region.json` returns the submitted geometry immediately, which is a quick way
to confirm the bbox landed where you meant before waiting on the extract.
- Check `ls data/sources/*.osm.pbf` first — if a recent extract for the place is already
there, offer it instead of re-cutting.
- SliceOSM has no fixed-region concept, so there's no `us/` path prefix and none of the
slash-in-the-name problems the Geofabrik path had.
Loading
Loading