ZooMap is a web application that lets zoo visitors browse and locate animals on an interactive map. A data pipeline periodically harvests animal POI data from open data sources, enriches it, and produces a static GeoJSON file that the frontend consumes directly.
graph TD
subgraph External Sources
OSM_API["Overpass API\n(OpenStreetMap)"]
WD_API["Wikidata API"]
WP_API["Wikipedia REST API"]
OSM_TILES["OSM Tile Server"]
end
subgraph ZooMap
PIPELINE["Pipeline\n(Python + dbt + DuckDB)"]
APP["Frontend App\n(HTML / JS / Leaflet)"]
GEOJSON["data/animals.geojson\n(static file)"]
end
VISITOR["Zoo Visitor\n(Browser)"]
OSM_API -->|"POI nodes, ways & relations"| PIPELINE
WD_API -->|"Species names & images"| PIPELINE
WP_API -->|"Article summaries & thumbnails"| PIPELINE
PIPELINE -->|"writes"| GEOJSON
GEOJSON -->|"fetched at startup"| APP
OSM_TILES -->|"background tiles"| APP
APP -->|"interactive map & list"| VISITOR
The pipeline follows a medallion architecture (Bronze → Silver → Gold) implemented with Python scripts and dbt models backed by an embedded DuckDB database.
| Component | Location | Responsibility |
|---|---|---|
|
|
Queries the Overpass API for POI nodes, ways, and relations within the configured bounding box. Caches raw JSON responses, reconstructs Polygon and MultiPolygon geometries from relation member ways, computes centroids for multi-point geometries, and loads results into the |
|
|
dbt model that casts raw JSON fields to typed columns, normalizing the flat OSM tag map into a structured table. |
|
|
dbt model that filters Bronze records (keeping only |
|
|
Reads QIDs and Wikipedia titles from the Silver layer, calls Wikidata and Wikipedia REST APIs, caches individual entity responses, and writes enrichment data to the |
|
|
dbt model that joins Silver POIs with enrichment data, coalesces display names and images, constructs Wikipedia URLs, and produces the final |
|
|
Queries |
|
|
Seed table that lists the OSM tag key/value pairs considered relevant for filtering. |
| Component | Location | Responsibility |
|---|---|---|
|
project root |
Shell page; defines the two-column layout (animal list + map container) and loads all scripts. |
|
project root |
Fetches |
|
project root |
Flexbox-based layout and visual styling for the two-panel interface. |
|
project root |
Static GeoJSON FeatureCollection generated by the pipeline; serves as the application’s database. |
data/animals.geojson is a RFC 7946 FeatureCollection.
Each element in the features array represents one animal enclosure.
{
"type": "FeatureCollection",
"features": [
{
"type": "Feature",
"geometry": { ... }, // Point or Polygon
"properties": { ... } // see table below
}
]
}Geometry
The geometry field is one of three GeoJSON geometry types:
| Type | Usage |
|---|---|
|
Single coordinate |
|
Closed ring of coordinates; used when the OSM source element is a way or a relation with a single outer ring. The pipeline computes the centroid for display purposes but preserves the full polygon. Inner rings (holes) are included when present. |
|
Multiple outer rings; used when an OSM relation contains more than one outer-role member way (e.g. a non-contiguous enclosure spread across two areas). |
Properties
| Field | Type | Required | Description |
|---|---|---|---|
|
string |
✓ |
URL-safe identifier derived from the enclosure name (e.g. |
|
string |
✓ |
Human-readable display name of the animal or enclosure (e.g. |
|
string |
✓ |
Raw OSM |
|
string |
— |
Scientific species name(s) sourced from the OSM |
|
string |
— |
Short article extract fetched from the Wikipedia REST API. |
|
string (URL) |
— |
Full URL to the English Wikipedia article (e.g. |
|
string (URL) |
— |
Wikimedia Commons thumbnail URL with |
The pipeline is triggered by GitHub Actions (weekly schedule, push to main, or manual dispatch) and runs sequentially through four stages.
flowchart TD
GHA(["GitHub Actions\ntrigger"])
subgraph Stage1["① OSM Ingestion"]
PY1["download_osm.py"]
OSM[/"Overpass API"/]
BRONZE[("DuckDB\nraw_osm_elements\n[Bronze]")]
PY1 -->|"Overpass QL query\n(bbox, osm_tags)"| OSM
OSM -->|"raw JSON\n(nodes / ways / relations)"| PY1
PY1 -->|"INSERT"| BRONZE
end
subgraph Stage2["② Bronze → Silver (dbt)"]
DBT_BS["dbt run"]
SILVER[("DuckDB\nsilver_pois\n[Silver]")]
DBT_BS -->|"cast & type"| DBT_BS
DBT_BS -->|"filter & normalize"| SILVER
end
subgraph Stage3["③ Wiki Enrichment"]
PY2["enrich_wiki.py"]
WD[/"Wikidata /\nWikipedia APIs"/]
ENRICH[("DuckDB\nwiki_enrichment")]
PY2 -->|"entity lookup (QIDs)"| WD
WD -->|"taxon names, image filenames"| PY2
PY2 -->|"summary lookup (titles)"| WD
WD -->|"article summaries & thumbnails"| PY2
PY2 -->|"INSERT"| ENRICH
end
subgraph Stage4["④ Gold + Export (dbt + Python)"]
DBT_G["dbt run"]
GOLD[("DuckDB\ngold_animals\n[Gold]")]
PY3["export_geojson.py"]
GEOJSON[/"data/animals.geojson"/]
DBT_G -->|"join & coalesce"| GOLD
GOLD -->|"SELECT *"| PY3
PY3 -->|"write + git commit & push"| GEOJSON
end
GHA --> Stage1
BRONZE --> Stage2
SILVER --> Stage3
ENRICH --> Stage4
| Layer | Storage | Content |
|---|---|---|
Bronze |
DuckDB |
Unmodified OSM data with typed columns; all original POIs retained. |
Silver |
DuckDB |
Filtered and normalized POIs; only animal attractions with extracted tag columns. |
Enrichment |
DuckDB |
Species metadata fetched from Wikidata and Wikipedia. |
Gold |
DuckDB |
Fully enriched, display-ready records with resolved names, descriptions, and image URLs. |
Export |
|
GeoJSON FeatureCollection consumed by the frontend; the single source of truth for the app. |
Status: Accepted
Context: The app needs animal POI data at runtime. Options range from a full backend API to a static file.
Decision:
Serve a pre-generated data/animals.geojson file directly from the repository; the frontend fetches it once on startup.
Alternatives Considered: - Backend REST API – Requires a server, adds infrastructure complexity, overkill for a small, infrequently changing dataset. - Hosted database (Postgres/Supabase) – Same drawbacks; introduces network latency and costs.
Consequences: - () Zero server infrastructure; the app is fully static and can be hosted anywhere. - () Extremely fast startup; the browser caches the file. - (−) Data freshness is limited to pipeline run frequency (weekly by default). - (−) Entire dataset is downloaded even if the user views only a few animals.
Status: Accepted
Context: The pipeline needs an SQL engine to run dbt transformations without a running database server.
Decision: Use DuckDB as an embedded, file-based database throughout the pipeline.
Alternatives Considered: - SQLite – No native dbt adapter; limited analytical SQL capabilities (e.g., no UNNEST). - PostgreSQL – Requires a running server and credentials; heavy for a single-user pipeline. - Pandas DataFrames – Loses the dbt transformation layer and version-controlled SQL models.
Consequences: - () No external service to provision or maintain. - () Full SQL dialect including JSON functions, UNNEST, and window functions. - (+) dbt-duckdb provides a first-class adapter. - (−) Concurrent write access is limited; not suitable if multiple pipeline runs overlap.
Status: Accepted
Context: The pipeline transforms raw OSM data through multiple stages. This logic could live in Python scripts or in SQL managed by a transformation framework.
Decision: Use dbt to define and run all data transformation models (Bronze → Silver → Gold).
Alternatives Considered: - Pure Python (pandas/polars) – Transformations would be harder to test, document, and version independently from orchestration code. - Plain SQL scripts – Possible, but dbt adds lineage tracking, seed management, and a standard project layout.
Consequences:
- () Transformation logic is declarative SQL, easy to read and audit.
- () dbt seeds manage the relevant_tags lookup table cleanly.
- (+) Clear separation of concerns between ingestion (Python) and transformation (dbt).
- (−) Adds a dependency and a learning curve for contributors unfamiliar with dbt.
Status: Accepted
Context: The frontend needs to display an animal list and an interactive map. The project aims for the simplest possible implementation.
Decision: Use plain HTML, CSS, and JavaScript with Leaflet.js as the only runtime dependency.
Alternatives Considered: - React / Vue / Svelte – Would add a build step, larger bundle size, and framework complexity for a UI that has minimal interactivity. - Server-side rendering – Requires a server; contradicts the static-hosting goal.
Consequences: - () No build tooling required; the app can be opened directly in a browser. - () Minimal bundle size; fast load times. - (−) No component model; scaling to a richer UI would require manual DOM management. - (−) No type checking; refactoring is riskier as the codebase grows.