Skip to content

Commit b4a3706

Browse files
CopilotPapaBravo
andauthored
feat: multi-species enclosures and multipolygon OSM relations (#11)
* feat: support multi-species enclosures and multipolygon OSM relations Agent-Logs-Url: https://github.com/PapaBravo/zoomap/sessions/107cf439-c2d5-484e-95b1-43cbe88be8ae Co-authored-by: PapaBravo <2863907+PapaBravo@users.noreply.github.com> * fix: exclude closing vertex from multipolygon centroid; clarify inner-ring comment Agent-Logs-Url: https://github.com/PapaBravo/zoomap/sessions/107cf439-c2d5-484e-95b1-43cbe88be8ae Co-authored-by: PapaBravo <2863907+PapaBravo@users.noreply.github.com> --------- Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com> Co-authored-by: PapaBravo <2863907+PapaBravo@users.noreply.github.com>
1 parent 99eab6d commit b4a3706

8 files changed

Lines changed: 234 additions & 57 deletions

File tree

app.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -38,10 +38,19 @@ function buildPopupHtml(props) {
3838
const img = props.image
3939
? `<img src="${escapeHtml(props.image)}" alt="${escapeHtml(props.name)}" class="popup-image" />`
4040
: '';
41+
const species = props.species
42+
? `<em>${escapeHtml(props.species)}</em>`
43+
: '';
44+
const enclosureTag = props.enclosure && props.enclosure !== props.name
45+
? `<div class="popup-enclosure">📍 ${escapeHtml(props.enclosure)}</div>`
46+
: '';
47+
const desc = props.description
48+
? `<p>${escapeHtml(props.description)}</p>`
49+
: '';
4150
const wikiLink = props.wikipedia
4251
? `<a href="${escapeHtml(props.wikipedia)}" target="_blank" rel="noopener noreferrer" class="popup-wiki-link">Wikipedia ↗</a>`
4352
: '';
44-
return `${img}<strong>${escapeHtml(props.name)}</strong><em>${escapeHtml(props.species)}</em><p>${escapeHtml(props.description)}</p>${wikiLink}`;
53+
return `${img}<strong>${escapeHtml(props.name)}</strong>${species}${enclosureTag}${desc}${wikiLink}`;
4554
}
4655

4756
function defaultStyle() {
@@ -140,7 +149,7 @@ fetch(DATA_URL)
140149

141150
const listItem = document.createElement('li');
142151
listItem.dataset.id = id;
143-
listItem.dataset.search = `${props.name} ${props.species} ${props.description}`.toLowerCase();
152+
listItem.dataset.search = `${props.name} ${props.species || ''} ${props.description || ''} ${props.enclosure || ''}`.toLowerCase();
144153

145154
const thumb = props.image
146155
? `<img src="${escapeHtml(props.image)}" alt="${escapeHtml(props.name)}" class="animal-thumb" />`

docs/architecture.adoc

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -49,7 +49,7 @@ The pipeline follows a *medallion architecture* (Bronze → Silver → Gold) imp
4949

5050
| `download_osm.py`
5151
| `pipeline/scripts/`
52-
| Queries the Overpass API for POI nodes, ways, and relations within the configured bounding box. Caches raw JSON responses, computes centroids for multi-point geometries, and loads results into the `raw_osm_elements` table in DuckDB (Bronze layer).
52+
| 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 `raw_osm_elements` table in DuckDB (Bronze layer).
5353

5454
| `bronze_osm_pois.sql`
5555
| `pipeline/dbt/models/bronze/`
@@ -61,11 +61,11 @@ The pipeline follows a *medallion architecture* (Bronze → Silver → Gold) imp
6161

6262
| `enrich_wiki.py`
6363
| `pipeline/scripts/`
64-
| 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 `wiki_enrichment` table.
64+
| 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 `wiki_enrichment` table. When the OSM `species:wikidata` tag contains multiple semicolon-separated QIDs, one enrichment record is produced per QID so that each species in a shared enclosure gets its own GeoJSON feature.
6565

6666
| `gold_animals.sql`
6767
| `pipeline/dbt/models/gold/`
68-
| dbt model that joins Silver POIs with enrichment data, coalesces display names and images, constructs Wikipedia URLs, and produces the final `gold_animals` table.
68+
| dbt model that joins Silver POIs with enrichment data, coalesces display names and images, constructs Wikipedia URLs, and produces the final `gold_animals` table. Because `wiki_enrichment` may contain multiple records per OSM element (one per species), the join naturally expands to one output row per species, each sharing the same geometry. The `enclosure` column always carries the raw OSM `name` tag to remain stable across multi-species expansions.
6969

7070
| `export_geojson.py`
7171
| `pipeline/scripts/`
@@ -88,7 +88,7 @@ The pipeline follows a *medallion architecture* (Bronze → Silver → Gold) imp
8888

8989
| `app.js`
9090
| project root
91-
| Fetches `data/animals.geojson` at startup, renders the Leaflet map with OSM tiles, populates the animal list, handles full-text search, and synchronises list selection with map highlights.
91+
| Fetches `data/animals.geojson` at startup, renders the Leaflet map with OSM tiles, populates the animal list, handles full-text search (including enclosure name), and synchronises list selection with map highlights. Multiple features sharing the same geometry (multi-species enclosures) each receive their own list entry; MultiPolygon geometries are handled natively by Leaflet.
9292

9393
| `style.css`
9494
| project root
@@ -120,7 +120,7 @@ Each element in the `features` array represents one animal enclosure.
120120

121121
*Geometry*
122122

123-
The `geometry` field is one of two GeoJSON geometry types:
123+
The `geometry` field is one of three GeoJSON geometry types:
124124

125125
[cols="1,3", options="header"]
126126
|===
@@ -130,7 +130,10 @@ The `geometry` field is one of two GeoJSON geometry types:
130130
| Single coordinate `[longitude, latitude]`; used when the OSM source element is a node.
131131

132132
| `Polygon`
133-
| Closed ring of coordinates; used when the OSM source element is a way or relation. The pipeline computes the centroid for display purposes but preserves the full polygon.
133+
| 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.
134+
135+
| `MultiPolygon`
136+
| 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).
134137
|===
135138

136139
*Properties*
@@ -152,7 +155,7 @@ The `geometry` field is one of two GeoJSON geometry types:
152155
| `enclosure`
153156
| string
154157
| ✓
155-
| Raw OSM `name` tag of the enclosure; may differ slightly from `name` when the pipeline normalizes the display value.
158+
| Raw OSM `name` tag of the enclosure element. For multi-species enclosures this value is the same across all species features sharing that enclosure, while `name` holds the individual species common name.
156159

157160
| `species`
158161
| string

pipeline/dbt/models/gold/gold_animals.sql

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -33,9 +33,9 @@ joined as (
3333
coalesce(w.common_name, s.name) as name,
3434
coalesce(w.scientific_name, s.species) as species,
3535
w.description as description,
36-
-- Use element name as the enclosure name for enclosure ways;
37-
-- for animal nodes the enclosure is usually their own name
38-
coalesce(w.common_name, s.name) as enclosure,
36+
-- Enclosure is always the raw OSM name tag so that all species sharing
37+
-- the same enclosure display a consistent enclosure name.
38+
s.name as enclosure,
3939
-- Build Wikipedia URL from enrichment title or OSM tag
4040
case
4141
when w.wikipedia_title is not null

pipeline/scripts/download_osm.py

Lines changed: 60 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -192,6 +192,15 @@ def _centroid_from_geometry(geometry: list) -> tuple[float, float]:
192192
return (sum(lats) / len(lats), sum(lons) / len(lons))
193193

194194

195+
def _centroid_from_coord_pairs(coord_pairs: list) -> tuple[float, float]:
196+
"""Compute centroid (lat, lon) from a list of [lon, lat] coordinate pairs."""
197+
if not coord_pairs:
198+
return (None, None)
199+
lons = [c[0] for c in coord_pairs]
200+
lats = [c[1] for c in coord_pairs]
201+
return (sum(lats) / len(lats), sum(lons) / len(lons))
202+
203+
195204
def _coords_from_geometry(geometry: list) -> list:
196205
"""Convert Overpass geometry list to GeoJSON coordinate pairs [lon, lat]."""
197206
return [[p["lon"], p["lat"]] for p in geometry if "lat" in p and "lon" in p]
@@ -230,14 +239,57 @@ def parse_elements(elements: list) -> list[dict]:
230239
geom_coords = json.dumps(coords)
231240

232241
elif osm_type == "relation":
233-
# Relations are complex; skip for now unless they have a center
234-
center = elem.get("center", {})
235-
lat = center.get("lat")
236-
lon = center.get("lon")
237-
if lat is None:
238-
continue
239-
geom_type = "Point"
240-
geom_coords = json.dumps([lon, lat])
242+
members = elem.get("members", [])
243+
244+
# Collect outer and inner rings from way members (multipolygon support)
245+
outer_rings = []
246+
inner_rings = []
247+
for member in members:
248+
if member.get("type") != "way":
249+
continue
250+
geom = member.get("geometry", [])
251+
if not geom:
252+
continue
253+
coords = _coords_from_geometry(geom)
254+
if len(coords) < 2:
255+
continue
256+
role = member.get("role", "")
257+
if role == "outer":
258+
outer_rings.append(coords)
259+
elif role == "inner":
260+
inner_rings.append(coords)
261+
262+
if outer_rings:
263+
# Compute centroid from unique outer ring vertices (excluding the
264+
# duplicate closing vertex present in closed GeoJSON rings).
265+
all_outer = [
266+
c
267+
for ring in outer_rings
268+
for c in (ring[:-1] if len(ring) > 1 and ring[0] == ring[-1] else ring)
269+
]
270+
lat, lon = _centroid_from_coord_pairs(all_outer)
271+
if lat is None:
272+
continue
273+
if len(outer_rings) == 1:
274+
# Single outer ring, possibly with holes
275+
geom_type = "Polygon"
276+
geom_coords = json.dumps([outer_rings[0]] + inner_rings)
277+
else:
278+
# Multiple outer rings → MultiPolygon.
279+
# Associating inner rings with specific outer rings requires
280+
# spatial containment tests; zoo enclosures rarely have holes
281+
# in multi-part geometries, so inner rings are omitted here.
282+
geom_type = "MultiPolygon"
283+
geom_coords = json.dumps([[ring] for ring in outer_rings])
284+
else:
285+
# Fall back to center point if no member geometries are available
286+
center = elem.get("center", {})
287+
lat = center.get("lat")
288+
lon = center.get("lon")
289+
if lat is None:
290+
continue
291+
geom_type = "Point"
292+
geom_coords = json.dumps([lon, lat])
241293

242294
else:
243295
continue

pipeline/scripts/enrich_wiki.py

Lines changed: 53 additions & 37 deletions
Original file line numberDiff line numberDiff line change
@@ -208,7 +208,12 @@ def parse_osm_wikipedia_tag(tag: str) -> str | None:
208208
# ---------------------------------------------------------------------------
209209

210210
def enrich() -> list[dict]:
211-
"""Read silver_pois, enrich each row, return list of enrichment dicts."""
211+
"""Read silver_pois, enrich each row, return list of enrichment dicts.
212+
213+
When the OSM ``species:wikidata`` tag contains multiple QIDs separated by
214+
semicolons (e.g. ``Q1;Q2``), one enrichment record is produced per QID so
215+
that each species gets its own GeoJSON feature with the same geometry.
216+
"""
212217
con = duckdb.connect(DB_PATH)
213218
try:
214219
rows = con.execute(
@@ -219,42 +224,53 @@ def enrich() -> list[dict]:
219224

220225
enrichments = []
221226
for osm_id, species_wikidata_id, wikidata_id, wikipedia_tag in rows:
222-
record = {
223-
"osm_id": osm_id,
224-
"wikidata_id": species_wikidata_id or wikidata_id,
225-
"common_name": None,
226-
"scientific_name": None,
227-
"wikipedia_title": None,
228-
"description": None,
229-
"image_url": None,
230-
}
231-
232-
# --- Wikidata: prefer species:wikidata, fall back to wikidata ---
233-
effective_qid = species_wikidata_id or wikidata_id
234-
if effective_qid:
235-
entity = fetch_wikidata_entity(effective_qid)
236-
if entity:
237-
wd_info = extract_wikidata_info(entity, effective_qid)
238-
record["common_name"] = wd_info["common_name"]
239-
record["scientific_name"] = wd_info["scientific_name"]
240-
if wd_info["wikipedia_title"]:
241-
record["wikipedia_title"] = wd_info["wikipedia_title"]
242-
if wd_info["image_filename"]:
243-
record["image_url"] = commons_image_url(wd_info["image_filename"])
244-
245-
# --- Wikipedia (from Wikidata sitelink or OSM tag) ---
246-
wp_title = record["wikipedia_title"] or parse_osm_wikipedia_tag(wikipedia_tag)
247-
if wp_title:
248-
summary = fetch_wikipedia_summary(wp_title)
249-
if summary:
250-
wp_info = extract_wikipedia_info(summary)
251-
record["wikipedia_title"] = wp_title
252-
record["description"] = wp_info["description"]
253-
# Prefer Wikidata P18 image; fall back to Wikipedia thumbnail
254-
if not record["image_url"]:
255-
record["image_url"] = wp_info["image_url"]
256-
257-
enrichments.append(record)
227+
# Split species:wikidata on ';' to handle multiple species per enclosure
228+
species_qids = (
229+
[q.strip() for q in species_wikidata_id.split(";") if q.strip()]
230+
if species_wikidata_id
231+
else []
232+
)
233+
234+
# Produce one record per species QID; fall back to a single record using
235+
# the enclosure-level wikidata tag when no species QIDs are present.
236+
effective_qids = species_qids if species_qids else [wikidata_id]
237+
238+
for qid in effective_qids:
239+
record = {
240+
"osm_id": osm_id,
241+
"wikidata_id": qid,
242+
"common_name": None,
243+
"scientific_name": None,
244+
"wikipedia_title": None,
245+
"description": None,
246+
"image_url": None,
247+
}
248+
249+
# --- Wikidata lookup ---
250+
if qid:
251+
entity = fetch_wikidata_entity(qid)
252+
if entity:
253+
wd_info = extract_wikidata_info(entity, qid)
254+
record["common_name"] = wd_info["common_name"]
255+
record["scientific_name"] = wd_info["scientific_name"]
256+
if wd_info["wikipedia_title"]:
257+
record["wikipedia_title"] = wd_info["wikipedia_title"]
258+
if wd_info["image_filename"]:
259+
record["image_url"] = commons_image_url(wd_info["image_filename"])
260+
261+
# --- Wikipedia (from Wikidata sitelink or OSM tag) ---
262+
wp_title = record["wikipedia_title"] or parse_osm_wikipedia_tag(wikipedia_tag)
263+
if wp_title:
264+
summary = fetch_wikipedia_summary(wp_title)
265+
if summary:
266+
wp_info = extract_wikipedia_info(summary)
267+
record["wikipedia_title"] = wp_title
268+
record["description"] = wp_info["description"]
269+
# Prefer Wikidata P18 image; fall back to Wikipedia thumbnail
270+
if not record["image_url"]:
271+
record["image_url"] = wp_info["image_url"]
272+
273+
enrichments.append(record)
258274

259275
return enrichments
260276

pipeline/scripts/export_geojson.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -41,6 +41,8 @@ def _build_geometry(geom_type: str, geom_coords: str) -> dict | None:
4141
return {"type": "Point", "coordinates": coords}
4242
if geom_type == "Polygon":
4343
return {"type": "Polygon", "coordinates": coords}
44+
if geom_type == "MultiPolygon":
45+
return {"type": "MultiPolygon", "coordinates": coords}
4446
if geom_type == "LineString":
4547
return {"type": "LineString", "coordinates": coords}
4648
return None

pipeline/scripts/test_download_osm.py

Lines changed: 88 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -146,5 +146,93 @@ def test_has_retry_adapter_mounted(self):
146146
self.assertEqual(adapter.max_retries.total, 3)
147147

148148

149+
class TestParseElementsRelations(unittest.TestCase):
150+
"""parse_elements should build polygon/multipolygon geometry from relation members."""
151+
152+
def _make_relation(self, members, center=None, tags=None):
153+
elem = {
154+
"type": "relation",
155+
"id": 1,
156+
"members": members,
157+
"tags": tags or {"attraction": "animal"},
158+
}
159+
if center:
160+
elem["center"] = center
161+
return elem
162+
163+
def _outer(self, coords):
164+
return {
165+
"type": "way",
166+
"ref": 1,
167+
"role": "outer",
168+
"geometry": [{"lat": lat, "lon": lon} for lon, lat in coords],
169+
}
170+
171+
def _inner(self, coords):
172+
return {
173+
"type": "way",
174+
"ref": 2,
175+
"role": "inner",
176+
"geometry": [{"lat": lat, "lon": lon} for lon, lat in coords],
177+
}
178+
179+
def test_relation_with_single_outer_ring_produces_polygon(self):
180+
ring = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]
181+
elem = self._make_relation([self._outer(ring)])
182+
rows = dl.parse_elements([elem])
183+
self.assertEqual(len(rows), 1)
184+
self.assertEqual(rows[0]["geom_type"], "Polygon")
185+
coords = json.loads(rows[0]["geom_coords"])
186+
# Polygon coords: list of rings; first ring is outer
187+
self.assertEqual(coords[0], ring)
188+
189+
def test_relation_with_outer_and_inner_ring_produces_polygon_with_hole(self):
190+
outer = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]
191+
inner = [[0.5, 0.5], [1.5, 0.5], [1.5, 1.5], [0.5, 1.5], [0.5, 0.5]]
192+
elem = self._make_relation([self._outer(outer), self._inner(inner)])
193+
rows = dl.parse_elements([elem])
194+
self.assertEqual(len(rows), 1)
195+
self.assertEqual(rows[0]["geom_type"], "Polygon")
196+
coords = json.loads(rows[0]["geom_coords"])
197+
self.assertEqual(len(coords), 2) # outer + one hole
198+
self.assertEqual(coords[0], outer)
199+
self.assertEqual(coords[1], inner)
200+
201+
def test_relation_with_multiple_outer_rings_produces_multipolygon(self):
202+
ring1 = [[0.0, 0.0], [1.0, 0.0], [1.0, 1.0], [0.0, 1.0], [0.0, 0.0]]
203+
ring2 = [[2.0, 2.0], [3.0, 2.0], [3.0, 3.0], [2.0, 3.0], [2.0, 2.0]]
204+
elem = self._make_relation([self._outer(ring1), self._outer(ring2)])
205+
rows = dl.parse_elements([elem])
206+
self.assertEqual(len(rows), 1)
207+
self.assertEqual(rows[0]["geom_type"], "MultiPolygon")
208+
coords = json.loads(rows[0]["geom_coords"])
209+
self.assertEqual(len(coords), 2) # two polygons
210+
self.assertEqual(coords[0], [ring1])
211+
self.assertEqual(coords[1], [ring2])
212+
213+
def test_relation_without_members_falls_back_to_center_point(self):
214+
elem = self._make_relation([], center={"lat": 52.5, "lon": 13.5})
215+
rows = dl.parse_elements([elem])
216+
self.assertEqual(len(rows), 1)
217+
self.assertEqual(rows[0]["geom_type"], "Point")
218+
self.assertAlmostEqual(rows[0]["centroid_lat"], 52.5)
219+
self.assertAlmostEqual(rows[0]["centroid_lon"], 13.5)
220+
221+
def test_relation_without_members_and_no_center_is_skipped(self):
222+
elem = self._make_relation([])
223+
rows = dl.parse_elements([elem])
224+
self.assertEqual(len(rows), 0)
225+
226+
def test_relation_centroid_computed_from_outer_ring_vertices(self):
227+
# A closed ring: [0,0]-[2,0]-[2,2]-[0,2]-[0,0].
228+
# The closing vertex is excluded from the centroid calculation so the
229+
# 4 unique vertices are averaged: lon=(0+2+2+0)/4=1.0, lat=(0+0+2+2)/4=1.0
230+
ring = [[0.0, 0.0], [2.0, 0.0], [2.0, 2.0], [0.0, 2.0], [0.0, 0.0]]
231+
elem = self._make_relation([self._outer(ring)])
232+
rows = dl.parse_elements([elem])
233+
self.assertAlmostEqual(rows[0]["centroid_lat"], 1.0)
234+
self.assertAlmostEqual(rows[0]["centroid_lon"], 1.0)
235+
236+
149237
if __name__ == "__main__":
150238
unittest.main()

0 commit comments

Comments
 (0)