Turns the City of Toronto Green Spaces dataset (~1,750 park-ish polygons) into map-tile layers that OpenStreetMap mappers can add to the JOSM and iD editors as a reference overlay.
Live layer and how to add it: https://skfd.github.io/toronto-parks-layer/
It is a sibling of
toronto-addresses-layer and
toronto-waterways-layer:
the same download -> slim -> tiles -> site -> publish pipeline, pointed at
the Green Spaces polygons. (The City's older "Parks" dataset is deprecated;
its page redirects to Green Spaces.)
- Vector tiles (MVT) — interactive in iD; click a polygon to read its
name,classandarea_idtags. - Raster tiles (PNG) — translucent green fills with park-name labels; each park's outline and label share a stable hashed colour (as in the addresses layer) so a name overhanging a small polygon is still visually tied to it. A readable backdrop for JOSM. A name only renders once the polygon is a visible shape on screen (the text may overhang small polygons), so big parks are labelled from city zoom and small parkettes only when zoomed in; at the top raster zoom every named park is labelled.
- A landing page with copy-paste "add this layer" instructions for both editors.
- A gap-review page (
/gaps/) that compares the City polygons against park areas already in OpenStreetMap and lists the gaps — City parks with no overlapping OSM area ("missing"), matched parks whose OSM name differs ("mismatch"), and matched parks tagged bothleisure=parkandlanduse=recreation_ground("retag") — on an interactive map and a filterable table, with OSM / iD / JOSM links per row (the JOSM link needs JOSM running with Preferences → Remote Control enabled). OSM areas are pulled from Overpass; matching is by the area the polygons really share, withlanduse=allotmentsaccepted only for the City's gardens (seesrc/compare.py). Its history page charts those counts over time, one point per weekly run. Matched parks whose outlines disagree are listed apart as "worth a look", most disputed first and off by default. They are prompts to compare two shapes, not gaps, so they stay out of the gap counts and the chart.
All of it is published to GitHub Pages and rebuilt weekly.
Green Spaces classes kept by the slim filter (INCLUDE_AREA_CLASSES in
src/config.py): Park, Open Green Space, Golf Course,
Cemetery, Civic Centre Square and their OTHER_* variants. Traffic
islands, road slivers, boulevards, hydro corridors, building grounds and the
generic administrative OTHER_* classes are dropped. Names are converted from
the City's ALL CAPS to title case.
The numbered TRCA LANDS ( n) parcels are kept out of the rendered tile layer
(they are conservation-authority land, not real named parks). The slim step
writes them to parks-slim.geojsonl but not to parks-layer.geojsonl, so the
gap-review page still tracks them while the tiles do not.
- Install Python dependencies:
pip install -r requirements.txt - Set up WSL2 + tippecanoe once — see wsl-setup.md.
- Confirm the GitHub repo in
src/config.py(GITHUB_REPO,PAGES_URL).
python run.py download # fetch the latest Green Spaces GeoJSON (smart-cached)
python run.py slim # filter to park-ish polygons -> slim GeoJSONL
python run.py compare # diff City polygons against OSM -> data/gaps.geojson
python run.py backfill # one-off: reconstruct past weeks' gap counts from OSM history
python run.py vector # build vector (MVT) tiles via WSL tippecanoe
python run.py raster # build labelled raster (PNG) tiles
python run.py site # render the landing page (+ the gap-review page)
python run.py publish # force-push the site to the gh-pages branch
python run.py build # download + slim + compare + vector + raster + site
python run.py update # build + publish (the scheduled entry point)
Build output lands in build/site/; that directory is what gets published.
https://skfd.github.io/toronto-parks-layer/tiles/vector/{z}/{x}/{y}.pbf (z10-19)
https://skfd.github.io/toronto-parks-layer/tiles/raster/{z}/{x}/{y}.png (z13-17)
The vector layer name is parks.
The tile pyramid is published to an orphan gh-pages branch, recreated and
force-pushed on every build so repository history never grows. One-time step:
in the GitHub repo, set Settings → Pages → Source to the
gh-pages branch (root).
Run as Administrator:
.\schedule-add.ps1 # registers a weekly task "kk-TorontoParksLayer", Mondays 16:30
.\schedule-remove.ps1 # unregisters itWeekly is enough — the City refreshes the Green Spaces dataset monthly, so
the tiles rarely change; it is the gap page, which diffs against live OSM, that
moves week to week. 16:30 keeps the build clear of the daily address-layer task.
The task runs python run.py update and appends output to logs\scheduler.log.
Both network steps — the dataset download and the gh-pages push —
run behind addressvault.net, the same offline/metered gate the address vault
pulls behind. A dead link is not a build failure: run.py exits 75
(EX_TEMPFAIL), nothing is written, and the task's restart-on-failure retries
three times half an hour apart rather than leaving a seven-day gap. It does not
wait the link out, since the task carries a hard ExecutionTimeLimit that would
kill a long wait anyway.
A download dropped mid-body resumes with a Range request instead of starting
over, and a push that cannot resolve github.com is retried without rebuilding
— the finished tiles stay on disk, so python run.py publish can complete
the run later. Before this, a dead resolver on 2026-07-27 took out the run with
an unhandled traceback out of the first request.
Re-run schedule-add.ps1 to apply the restart settings to an already registered
task.
Every live comparison appends one line to data/history.jsonl -- the date, the
OSM data date, and the missing / mismatch / unnamed / TRCA counts -- and the
site step charts it at /gaps/history.html,
with the rows published beside it as gaps/history.jsonl. That is the whole
record the observer keeps: the tiles and the gap list are rebuilt from scratch
and force-pushed each week, but this file only grows, so the scheduled
update extends the chart by one point every Monday. A run that fell back to
cached OSM is not recorded; it observed nothing new, and a point it drew would
be a false plateau.
Nothing heavier is kept on purpose. OSM keeps its own history, so the record is
reconstructible: python run.py backfill asks overpass-api.de what OSM looked
like on each past Monday (an attic query, [date:...], which only that
instance is relied on for) and re-runs the comparison against it, adding rows
marked attic. It is paced -- one attempt per date, 30 s between dates, and
it stops at the first unanswered query -- because attic queries cost the
instance more than live ones and this is a one-off, not the weekly build. Dates
already on record are skipped, so an interrupted run resumes when re-run.
The default range starts 2026-06-01, the week before the layer went live;
--since, --until, --every and --pause adjust it.
Two caveats the page states: a reconstructed week is compared against today's City polygons (the City side is not archived; Green Spaces changes monthly and slowly), and the counts follow the City dataset, so they move when the City adds or reclassifies parks, not only when someone maps one.
A checkout without data/history.jsonl (a fresh clone, a wiped data/) seeds
itself from the published gaps/history.jsonl before recording, so the record
survives the machine it was made on.
The gap page is only worth acting on if both sides of the diff are current, and
the OSM side is the one that moves daily. It used to be fetched with a single
POST to overpass-api.de, falling back to the cached response on any error —
which meant a transient 504 cost a whole week. That happened twice running, on
2026-08-10 and 2026-08-17, and the task reported success both times while the
page compared against OSM data from Aug 3.
The fetch now works for it. Every mirror in OVERPASS_URLS is tried, and the
whole list is retried OVERPASS_ROUNDS times a minute apart, because what is
being worked around is a loaded instance shedding one request. A reply with
fewer than OSM_MIN_ELEMENTS elements is refused however healthy its status
code: overpass.osm.ch serves a Switzerland extract and answers a Toronto bbox
with a valid, empty result, which taken at face value would report all 1,745
City parks as missing from OSM.
Measured 2026-08-17 with the real query, from this laptop:
| mirror | time | result |
|---|---|---|
overpass-api.de |
5.1s | 200, 6,690 elements |
overpass.private.coffee |
16.9s | 200, 6,577 elements |
overpass.kumi.systems |
32.1s | 504 |
overpass.osm.jp |
0.8s | SSL failure |
overpass.osm.ch |
0.9s | 200, 0 elements (wrong region) |
A full reply is not proof of a current one either. Every Overpass reply
states its own data date (osm3s.timestamp_osm_base), and one more than
OSM_MAX_AGE_DAYS (3) old is refused like a 504. That check exists because
overpass.private.coffee, the fallback in the table above, turned out to answer
from a pool of lagging backends: on 2026-09-28 and 09-29 it served OSM as of
07-15 and 05-06, and both weeks' gap pages presented that as current. It was
dropped from OVERPASS_URLS the same day, leaving overpass-api.de alone; a
cached reply is likewise dated by the data it holds, not by when it was fetched.
If it still cannot reach anyone, the cache is used — a stale gap page beats
none — but the run stops being quiet about it. The page prints the OSM
data's own date beside the City date and carries a warning banner, and update
exits 70 after publishing, so the tiles still ship and the task's
restart-on-failure tries the fetch again in half an hour instead of in a week.
Being offline is told apart from an Overpass outage and stays a 75.
python tests\test_tilemath.py
python tests\test_slim.py
pytest # the above, plus the download / publish tests
Park data is © City of Toronto, published under the Open Government Licence – Toronto. Tiles and the landing page carry that attribution.