# Portolan Thumbnails

> Generate framed, checked thumbnails from Portolan collections using chiitiler (MapLibre GL Native). Renders the collection's actual styles/default.json server-side with an optional basemap, frames every bbox to the browser card's 3:2 shape, and gates each image on an automated blank probe plus a visual review. Requires Node.js 18+.

- **Type:** Skill
- **Install:** `agentstack add skill-portolan-sdi-portolan-skills-portolan-thumbnails`
- **Verified:** Yes — security-reviewed for prompt injection and unsafe behavior
- **Seller:** [portolan-sdi](https://agentstack.voostack.com/s/portolan-sdi)
- **Installs:** 0
- **Category:** [Web & Browser](https://agentstack.voostack.com/c/web-and-browser)
- **Latest version:** 0.1.0
- **License:** Apache-2.0
- **Upstream author:** [portolan-sdi](https://github.com/portolan-sdi)
- **Source:** https://github.com/portolan-sdi/portolan-skills/tree/main/skills/portolan-thumbnails

## Install

```sh
agentstack add skill-portolan-sdi-portolan-skills-portolan-thumbnails
```

Requires the [AgentStack CLI](https://agentstack.voostack.com/docs/cli). Works with Claude Code, Cursor, and any MCP-compatible agent.

## About

# Framed Thumbnails with Chiitiler

Render a collection's own `styles/default.json` server-side with
[chiitiler](https://github.com/Kanahiro/chiitiler), frame it to the shape of the
browser card, and check the result before it ships. The rendering half of this is
the easy half. Most bad thumbnails come from the bbox, not the renderer.

**When to use**: any collection whose thumbnail people will actually look at. The
matplotlib thumbnails that `portolan check --fix` writes stay the baseline; this
replaces them with something that matches the portal.

## Requirements

- Node.js 18+ and npm
- Git, to clone chiitiler
- A collection with a `.pmtiles` file and `styles/default.json`
- An agent that can view images. Step 5 is not optional and cannot be automated away.
- DuckDB, optional. Needed only to find a dense subregion in a large collection.

## Why Framing Is the Whole Job

chiitiler's `/clip` endpoint takes four parameters: `url` (or a posted style),
`bbox`, `size`, and `quality`. The output aspect ratio and the zoom both fall out
of the bbox, and `size` sets the longest edge. Every framing decision is therefore
a decision about which bbox to send.

Passing the collection's raw extent produces the failures the St. Louis catalog hit
in August 2026: tall narrow rectangles floating in wide cards, dense parcel layers
rendered so far out that tippecanoe's thinning shows as holes, and six cards in a
row that could be swapped without anyone noticing.

Rewriting the bbox is reframing, not distortion. Nothing gets stretched. The
`conversion-defaults` best practice forbids changing the aspect of the data, which
this does not do; it changes how much context sits around the data.

## Step 1 — Read the Collection

Every signal you need is in `collection.json` and the PMTiles header.

| Signal | Where | Tells you |
|---|---|---|
| `geoparquet:geometry_type` | collection.json | Points and lines need more zoom than polygons |
| `geoparquet:feature_count` | collection.json | The single strongest strategy signal |
| `extent.spatial.bbox[0]` | collection.json | The starting frame |
| thumbnail-role asset `href` | collection.json | Where to write, and in which format |
| `pmtiles:max_zoom` | collection.json, when present | The deepest zoom with complete data |
| `pmtiles:center` | collection.json, when present | tippecanoe's densest-area guess |
| `pmtiles:layers` | collection.json, when present | Ground truth for `source-layer` |

The `pmtiles:*` properties come from newer conversions and are missing from older
catalogs; only 63 of 188 collections in the pergamino-ide test catalog carry them.
Read the PMTiles header instead, which always works.

```bash
python3 /tmp/portolan-thumbs/frame.py \
    --pmtiles publico_arbolado/publico_arbolado.pmtiles
# {"min_zoom": 0, "max_zoom": 13, "center": [-60.578613, -33.888655, 13],
#  "bounds": [-60.638266, -33.923181, -60.512274, -33.860783]}
```

Treat `center` as a hint. tippecanoe sometimes writes the bbox corner rather than a
dense cluster, as it did for two of the six collections checked in August 2026.
Confirm it with a feature count before you build a window around it.

## Step 2 — Choose a Strategy

**A, full extent.** Frame the collection's whole bbox. Right for boundaries,
borders, districts, wards, precincts, neighborhoods, watersheds, city limits, and
anything with a small feature count.

**B, zoomed window.** A 3:2 window at a chosen zoom, centred on a dense cluster.
Right for parcels, buildings, blocks, addresses, service requests, permits, sales,
and trees.

Work through the signals cheapest first. These are defaults, not rules, and you
should override them when the data says otherwise.

| Signal | Default |
|---|---|
| Polygons, `feature_count` = 5000 | B |
| Points or lines, `feature_count` >= 1000 | B |
| Anything else | A when `fill` >= 0.4, otherwise B |
| `aspect` outside 2.2:1 after capping | Decide explicitly and record why |

`fill` and `aspect` come from Step 3, so the last two rows mean running `frame.py`
first and then reconsidering.

## Step 3 — Compute the Bbox

Write the helper once per session. Run `mkdir -p /tmp/portolan-thumbs` and save the
following as `/tmp/portolan-thumbs/frame.py`.

```python
#!/usr/bin/env python3
"""Frame a bbox for thumbnail rendering. Prints JSON."""
import argparse, json, math, struct, sys

R = 6378137.0
EARTH_CIRC = 40075016.686
TARGET_ASPECT, MARGIN, MAX_CONTEXT, FRAME_ASPECT_LIMIT = 1.5, 0.05, 2.5, 2.2

def mx(lon): return math.radians(lon) * R
def inv_mx(x): return math.degrees(x / R)
def inv_my(y): return math.degrees(2 * math.atan(math.exp(y / R)) - math.pi / 2)

def my(lat):
    lat = max(min(lat, 85.05112878), -85.05112878)
    return R * math.log(math.tan(math.pi / 4 + math.radians(lat) / 2))

def eff_zoom(span_m, size_px): return math.log2(EARTH_CIRC * size_px / (256 * span_m))
def span_for_zoom(z, size_px): return EARTH_CIRC * size_px / (256 * 2 ** z)

def clamp(lo, hi, axis, warnings):
    """Keep the frame inside the Mercator world by shifting, never squashing."""
    half = EARTH_CIRC / 2
    if hi - lo > EARTH_CIRC:
        warnings.append("clamped-%s: frame larger than the world" % axis)
        return -half, half
    shift = (half - hi) if hi > half else ((-half - lo) if lo = 1.0:
            continue
        c = (lo + hi) / 2
        if axis == "x":
            x0, x1 = c - 500, c + 500
        else:
            y0, y1 = c - 500, c + 500
        warnings.append("degenerate-%s: span floored at 1 km" % axis)
    # Margin first: fractional per axis, so the aspect is unchanged.
    dx, dy = (x1 - x0) * margin, (y1 - y0) * margin
    x0, x1, y0, y1 = x0 - dx, x1 + dx, y0 - dy, y1 + dy
    dataW = W = x1 - x0
    dataH = H = y1 - y0
    if W / H  0:
            x0, x1 = x0 - pad, x1 + pad
    elif W / H > target:    # too wide, heighten
        pad = (min(W / target, dataH * max_context) - H) / 2
        if pad > 0:
            y0, y1 = y0 - pad, y1 + pad
    x0, x1 = clamp(x0, x1, "x", warnings)
    y0, y1 = clamp(y0, y1, "y", warnings)
    fill = min(dataW / (x1 - x0), dataH / (y1 - y0))
    return (inv_mx(x0), inv_my(y0), inv_mx(x1), inv_my(y1)), fill, warnings

def window(clon, clat, z, size_px, target=TARGET_ASPECT):
    """Strategy B: a target-aspect window at zoom z, centred on (clon, clat)."""
    span = span_for_zoom(z, size_px)
    hw, hh = span / 2, span / (2 * target)
    cx, cy = mx(clon), my(clat)
    return inv_mx(cx - hw), inv_my(cy - hh), inv_mx(cx + hw), inv_my(cy + hh)

def report(bbox, size_px, warnings):
    x0, x1, y0, y1 = mx(bbox[0]), mx(bbox[2]), my(bbox[1]), my(bbox[3])
    aspect = (x1 - x0) / (y1 - y0)
    span = max(x1 - x0, y1 - y0)
    if not 1 / FRAME_ASPECT_LIMIT  /tmp/chiitiler.log 2>&1  /dev/null
render "$WORK/blank-style.json" "$BBOX" 256 "$WORK/blank.png" png 100 > /dev/null

if [ "$MAIN" != "200" ]; then
    echo "FAIL http=$MAIN $(head -c 120 "$OUT")"
    rm -f "$OUT"
    exit 1
fi

PH=$(sha256sum "$WORK/probe.png" | cut -d' ' -f1)
BH=$(sha256sum "$WORK/blank.png" | cut -d' ' -f1)
PS=$(stat -c%s "$WORK/probe.png"); BS=$(stat -c%s "$WORK/blank.png")

if   [ "$PH" = "$BH" ];                     then GATE1="FAIL-empty"
elif [ "$PS" -lt $(( BS * 115 / 100 )) ];   then GATE1="WARN-sparse"
else                                             GATE1="PASS"; fi

echo "gate1=$GATE1 probe=$PS blank=$BS bytes=$(stat -c%s "$OUT") out=$OUT"
```

Write to the path the collection's thumbnail asset already points at. Read it
rather than assuming an extension:

```bash
OUT=$(python3 -c "
import json
c = json.load(open('collection.json'))
print(next(a['href'] for a in c['assets'].values()
           if 'thumbnail' in (a.get('roles') or [])))")
```

## Step 5 — Check the Result

Two gates. Both run before anything is pushed.

### Gate 1, Automated

`render_one.sh` renders a 256-pixel probe over the same bbox with the collection's
layers on a white background and no basemap, plus a blank reference that is the
white background alone. Identical hashes mean no data landed in the frame. A probe
within 15% of the blank's file size means almost none did. The hashes are
deterministic: three identical renders of the same style and bbox produced
byte-identical PNGs in August 2026.

Gate 1 replaces the old 100-byte file-size check, which passed any image chiitiler
managed to encode, including a uniformly blank one. Keep checking that the file
exists and that the status was 200, since that catches transport failures. A render
error returns 500 with a short text body, and `curl -o` writes that text into your
`.png`.

### Gate 2, Visual

View every image. Six questions; any "no" is a failure.

1. **Data present.** Are features visible, rather than basemap alone? An
   all-basemap, blank, or solid-colour image is a failure, never a valid result.
2. **Data is the subject.** Do features occupy a quarter of the frame or more?
3. **Shape.** Is it landscape and close to 3:2? A tall portrait image fails unless
   you deliberately chose full-extent framing for a boundary layer and said so.
4. **Completeness.** Do continuous fabrics such as parcels and blocks run edge to
   edge without scattered holes? Holes mean the render sits below the archive's
   maximum zoom.
5. **Legibility at card size.** Imagine it at 350x230. Pale fills over a light
   basemap read as flat grey.
6. **Distinctness.** Set beside its siblings, is it recognisable? If two cards
   could be swapped without anyone noticing, change the framing depth or palette.

### Remediation

| Symptom | Fix |
|---|---|
| All basemap, probe empty | Confirm the source declares `minzoom`/`maxzoom`, that `source-layer` matches `pmtiles:layers`, that the source key survived the rewrite, and that the bbox intersects the data |
| All basemap, probe has data | The data is under the basemap or fully transparent. Check layer order and paint opacity |
| Black background | The basemap failed to fetch and transparency became black. Keep a white background layer beneath it, and check the `{z}/{x}/{y}` template survived the shell |
| Thin or portrait image | Framing was skipped, or `MAX_CONTEXT` capped it. Re-run `frame.py`, then either `--max-context 99` or Strategy B |
| Sliver of data in a big frame | `fill` is too low. Switch to Strategy B, or crop with a quantile trim |
| Scattered holes in a continuous fabric | Shrink the window until the render sits at or above `max_zoom`. If it already does, the archive needs retiling |
| Dense points render as one blob | Zoom in. Structure appears when features separate |
| Washed out, flat grey | Lower `BASEMAP_OPACITY`, use a no-labels basemap, or raise the fill opacity in the style |
| Identical to a sibling | Change strategy, raise the `OFFSET` rank, or change the palette |

Retry at most three times per collection, then report what is left and why. Some
collections cannot produce a good thumbnail. A two-point collection is a locator
map and nothing more; say so rather than burning attempts on it.

Legibility problems that survive reframing belong to the style, not to this skill.
The `sourcecoop` skill covers varying default styles.

## Defects the Validators Miss

Rendering 182 pergamino-ide collections in August 2026 surfaced four faults that
`rashid` and `stac-check` both pass. Each reaches this skill as a failed request
or a blank image, never as a finding.

**A `symbol` layer with no `glyphs` endpoint kills the renderer.** MapLibre GL
Native crashes when a layer needs a font it cannot fetch. The chiitiler worker
dies and curl reports an empty reply rather than a status code, which reads like
a server crash. Adding a `glyphs` entry turns the crash into a readable HTTP 500
with `unknown pbf field type`. Keep the symbol layers in `styles/default.json`,
where browsers render the labels, and strip them from the render and probe
styles in `buildstyle.py`:

```python
layers = [l for l in layers if l.get("type") != "symbol"]
```

`https://fonts.openmaptiles.org/{fontstack}/{range}.pbf` is a `glyphs` source
that works, for the case where you want labels in the image.

**A `match` expression needs all-integer or all-string labels.** MapLibre
rejects the style with HTTP 400 when the labels are floats (`0.9`, `1.0`, `1.1`)
and again when integers sit beside a string (`227`, `330`, `"Linea 2"`). Coerce
both sides to string. Wrap the getter as `["to-string", ["get", "col"]]`, write
each integral float as an integer so `1.0` becomes `"1"`, and drop the duplicate
labels that coercion creates.

**Three paint patterns render blank, and no renderer is at fault.**
`fill-opacity` of `0.0`, `circle-color` of `#ffffff` against the white
background, and a white fill at partial opacity each produce a correct render of
nothing. Raise the zero opacity. Give a white circle a `circle-stroke-color` and
a `circle-stroke-width`, and a white fill a `fill-outline-color`.

**A sparse collection can be tiled to zoom 0.** One two-feature collection had
an archive stopping at `maxzoom 0`, which puts both points inside a single pixel,
and no framing recovers that. Set `pmtiles.max_zoom` in `.portolan/config.yaml`
and regenerate. That collection went to 23 tiles.

Regenerating needs the generation path switched on. `portolan add` skips PMTiles
for a collection unless `--pmtiles` was passed or `pmtiles.enabled` is true in
config, and `--force-pmtiles` only sets the force flag on a generator it never
reaches otherwise. Run both flags together:

```bash
portolan add publico_arbolado/ --pmtiles --force-pmtiles
```

Unchanged files do not block this. `add` counts a skipped file's collection as
affected so that tile generation still runs over already-tracked data.

## Step 6 — Work Through the Catalog

Cards are seen side by side, so judge them as a set. Aim for roughly a third
full-extent and the rest zoomed at varying depths, some at an overview that fills
the box and some deep enough to show real detail. Never put two thumbnails on the
same neighborhood; raise the `OFFSET` rank instead. A portrait boundary layer is
fine as variety when it passes Gate 2.

Keep a record as you go, at `/tmp/portolan-thumbs/framing.tsv`:

```
collection	strategy	bbox	zoom	rank	verdict
publico_arbolado	B	-60.603932,-33.906046,-60.559986,-33.881728	15.0	0	pass
publico_barrios	A	-60.651220,-33.935211,-60.495382,-33.848971	13.17	-	pass
```

It makes the second pass over a failing collection cheap, and it is the evidence
that Gate 2 actually ran.

## Step 7 — Register and Push

The thumbnail asset was registered when the collection was converted, and
`portolan check --fix` will not repoint a stale `href`. Writing
`collection.thumb.png` next to a registered `collection.thumb.jpg` leaves the
catalog pointing at the old image, so write to the existing `href` by default. To
change format deliberately, delete the old file and update the asset's `href` and
`type` together, then push. `versions.json` picks up the new checksums.

```bash
python3 - <<'PY'
import json
c = json.load(open('collection.json'))
for a in c['assets'].values():
    if 'thumbnail' in (a.get('roles') or []):
        a['href'] = './publico_arbolado.thumb.png'
        a['type'] = 'image/png'
json.dump(c, open('collection.json', 'w'), indent=2)
PY
rm -f publico_arbolado.thumb.jpg
portolan push s3://bucket/catalog
```

## Basemap Options

| Style | URL |
|-------|-----|
| Carto Light (default) | `https://basemaps.cartocdn.com/light_all/{z}/{x}/{y}.png` |
| Carto Light, no labels | `https://basemaps.cartocdn.com/light_nolabels/{z}/{x}/{y}.png` |
| Carto Dark | `https://basemaps.cartocdn.com/dark_all/{z}/{x}/{y}.png` |
| Carto Voyager | `https://basemaps.cartocdn.com/rastertiles/voyager/{z}/{x}/{y}.png` |
| OpenStreetMap | `https://tile.openstreetmap.org/{z}/{x}/{y}.png` |
| Stadia Smooth | `https://tiles.stadiamaps.com/tiles/alidade_smooth/{z}/{x}/{y}.png` |

`BASEMAP_OPACITY=0.55` keeps the basemap as context. Raise it when sparse data
needs geographic anchoring, lower it when pale fills are getting lost, and use the
no-lab

…

## Source & license

This open-source skill is cataloged on AgentStack and links to its original source — we do not rehost the code.

- **Author:** [portolan-sdi](https://github.com/portolan-sdi)
- **Source:** [portolan-sdi/portolan-skills](https://github.com/portolan-sdi/portolan-skills)
- **License:** Apache-2.0

Install and usage instructions live in the source repository linked above.

## Pricing

- **Free** — Free

## Security capabilities

Automated source analysis of v0.1.0 — what this tool can access:

- **Network access:** yes
- **Filesystem access:** yes
- **Shell / process execution:** no
- **Environment & secrets:** no
- **Dynamic code execution:** no

*"Yes" means the capability is present in the source — more access means more to trust, not that it is unsafe.*


## Versions

- **0.1.0** — security scan: passed — Imported from the upstream source.

## Links

- Listing page: https://agentstack.voostack.com/l/skill-portolan-sdi-portolan-skills-portolan-thumbnails
- Seller: https://agentstack.voostack.com/s/portolan-sdi
- Browse the marketplace: https://agentstack.voostack.com/browse

---
Listed on AgentStack — the marketplace for AI agent skills and MCP servers. Every listing is security-reviewed. Creators keep 70%.
