AgentStack
Browse Sign in
Browse Why AgentStack Sell Docs
Sign in
SKILL verified Apache-2.0 Self-run

Portolan Thumbnails

skill-portolan-sdi-portolan-skills-portolan-thumbnails · by portolan-sdi

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+.

No reviews yet
0 installs
0 views
view→install

Install

$ agentstack add skill-portolan-sdi-portolan-skills-portolan-thumbnails

✓ scanned · ✓ verified, works with Claude Code, Cursor, and more.

Security review

✓ Passed

No issues found. Passed automated security review. · v0.1.0 How review works →

  • Prompt-injection patterns
  • Secret / credential exfiltration
  • Dangerous shell & filesystem operations
  • Untrusted network calls
  • Known-malicious package signatures

What it can access

  • Network access Used
  • Filesystem access Used
  • Shell / process execution No
  • Environment & secrets No
  • Dynamic code execution No

From automated source analysis of v0.1.0. “Used” means the capability is present in the source — more access means more to trust, not that it’s unsafe.

View the full security report →

Verified badge

Passed review? Show it. Paste this badge into your README, it links to the public security report.

AgentStack Verified badge Links to your public security report.
[![AgentStack Verified](https://agentstack.voostack.com/badges/verified.svg)](https://agentstack.voostack.com/security/report/skill-portolan-sdi-portolan-skills-portolan-thumbnails)

Reliability & compatibility

Security review passed
0 installs to date
no reviews yet
today

Declared compatibility

Claude CodeClaude Desktop

Compatibility is declared by the source manifest. End-to-end runtime verification is coming, see below.

Preview Execution monitoring

We're building live execution health for every listing: tool-call success rate, median latency, uptime, and last-checked timestamps, measured, not self-reported. It isn't live yet, so we don't show numbers we can't stand behind.

How agent discovery & health will work →
Are you the author of Portolan Thumbnails? Claim this listing to set pricing, connect Stripe payouts, and keep 70% of every sale.
Sign up to claim

About

Framed Thumbnails with Chiitiler

Render a collection's own styles/default.json server-side with 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.

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.

#!/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:

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.

  1. Data is the subject. Do features occupy a quarter of the frame or more?
  2. 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.

  1. 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.

  1. Legibility at card size. Imagine it at 350x230. Pale fills over a light

basemap read as flat grey.

  1. 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:

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:

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.

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.

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

Reviews

No reviews yet, be the first.

Versions

  • v0.1.0 Imported from the upstream source.