diff --git a/.gitignore b/.gitignore
index 883f8d5..477ef9c 100644
--- a/.gitignore
+++ b/.gitignore
@@ -4,3 +4,5 @@
__pycache__/
*.pyc
.pytest_cache/
+data/
+*.osm.pbf
diff --git a/docker-compose.yml b/docker-compose.yml
new file mode 100644
index 0000000..bf1792a
--- /dev/null
+++ b/docker-compose.yml
@@ -0,0 +1,48 @@
+services:
+ postgis:
+ image: postgis/postgis:16-3.4
+ container_name: adres-analyse-postgis
+ environment:
+ POSTGRES_DB: adresanalyse
+ POSTGRES_USER: adres
+ POSTGRES_PASSWORD: adres
+ TZ: Europe/Amsterdam
+ ports:
+ - "5433:5432"
+ volumes:
+ - ./data/postgis:/var/lib/postgresql/data
+ shm_size: 1g
+ restart: unless-stopped
+ healthcheck:
+ test: ["CMD-SHELL", "pg_isready -U adres -d adresanalyse"]
+ interval: 10s
+ timeout: 5s
+ retries: 10
+
+ osm2pgsql:
+ image: iboates/osm2pgsql:1.11.0
+ container_name: adres-analyse-osm2pgsql
+ profiles: ["import"]
+ depends_on:
+ postgis:
+ condition: service_healthy
+ volumes:
+ - ./data/osm:/data/osm:ro
+ - ./osm2pgsql.style:/data/osm2pgsql.style:ro
+ environment:
+ PGPASSWORD: adres
+ command: >
+ osm2pgsql
+ --create
+ --slim
+ --drop
+ --cache 2000
+ --number-processes 4
+ --hstore
+ --latlong
+ --style /data/osm2pgsql.style
+ -d adresanalyse
+ -U adres
+ -H postgis
+ -P 5432
+ /data/osm/netherlands-latest.osm.pbf
diff --git a/generate-map.py b/generate-map.py
new file mode 100644
index 0000000..810f6f3
--- /dev/null
+++ b/generate-map.py
@@ -0,0 +1,174 @@
+#!/usr/bin/env python3
+"""
+Genereer interactieve kaart voor adres analyse.
+Toont isochronen plus dichtstbijzijnde supermarkten en scholen uit lokale PostGIS OSM-data.
+"""
+
+import json
+import sys
+import urllib.parse
+import urllib.request
+
+import folium
+from folium.plugins import MarkerCluster
+
+from voorzieningen import analyse_voorzieningen, format_afstand, format_duur
+
+ORS_BASE = "http://192.168.1.71:9080/ors/v2"
+PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
+
+
+def geocode_adres(adres):
+ params = urllib.parse.urlencode({
+ "q": adres,
+ "rows": 5,
+ "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer",
+ })
+ url = f"{PDOK_BASE}/free?{params}"
+ with urllib.request.urlopen(url, timeout=10) as resp:
+ data = json.loads(resp.read())
+
+ docs = data.get("response", {}).get("docs", [])
+ if not docs:
+ raise ValueError(f"Adres niet gevonden: {adres}")
+
+ doc = next((d for d in docs if d.get("type") == "adres"), docs[0])
+ point = doc["centroide_ll"].replace("POINT(", "").replace(")", "").split()
+ lon, lat = float(point[0]), float(point[1])
+ return {
+ "adres": doc.get("weergavenaam", adres),
+ "postcode": doc.get("postcode", ""),
+ "woonplaats": doc.get("woonplaatsnaam", ""),
+ "lat": lat,
+ "lon": lon,
+ }
+
+
+def haal_isochroon(lon, lat, profiel, minuten):
+ payload = json.dumps({
+ "locations": [[lon, lat]],
+ "range": [minuten * 60],
+ "attributes": ["area", "reachfactor", "total_pop"],
+ }).encode()
+ req = urllib.request.Request(
+ f"{ORS_BASE}/isochrones/{profiel}",
+ data=payload,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ return json.loads(resp.read())
+
+
+def polygon_bounds(feature_collection):
+ bbox = feature_collection.get("bbox", [])
+ if len(bbox) == 4:
+ return [[bbox[1], bbox[0]], [bbox[3], bbox[2]]]
+ bounds = []
+ for feature in feature_collection.get("features", []):
+ coords = feature.get("geometry", {}).get("coordinates", [[]])[0]
+ bounds.extend([[lat, lon] for lon, lat in coords])
+ return bounds
+
+
+def voeg_isochroon_toe(group, iso, label, kleur, tooltip):
+ for feature in iso.get("features", []):
+ coords = feature["geometry"]["coordinates"][0]
+ props = feature.get("properties", {})
+ area_km2 = props.get("area", 0) / 1_000_000
+ reach = props.get("reachfactor", 0)
+ folium.Polygon(
+ locations=[[lat, lon] for lon, lat in coords],
+ color=kleur,
+ weight=3,
+ fill=True,
+ fill_color=kleur,
+ fill_opacity=0.18,
+ popup=(
+ f"{label}
"
+ f"Oppervlakte: {area_km2:.1f} km²
"
+ f"Reach factor: {reach:.3f}"
+ ),
+ tooltip=tooltip,
+ ).add_to(group)
+
+
+def voeg_pois_toe(group, pois, kleur, icon):
+ for poi in pois:
+ popup = (
+ f"{poi.naam}
"
+ f"Categorie: {poi.categorie}
"
+ f"Hemelsbreed: {format_afstand(poi.hemelsbreed_m)}
"
+ f"Fiets: {format_afstand(poi.fiets_afstand_m)} / {format_duur(poi.fiets_duur_s)}
"
+ f"Auto: {format_afstand(poi.auto_afstand_m)} / {format_duur(poi.auto_duur_s)}
"
+ f"Bron: {poi.bron}"
+ )
+ folium.Marker(
+ [poi.lat, poi.lon],
+ popup=popup,
+ tooltip=f"{poi.categorie}: {poi.naam}",
+ icon=folium.Icon(color=kleur, icon=icon, prefix="fa"),
+ ).add_to(group)
+
+
+def genereer_kaart(adres, output_file="/root/adres-analyse/kaart.html"):
+ locatie = geocode_adres(adres)
+ lat, lon = locatie["lat"], locatie["lon"]
+
+ auto_iso = haal_isochroon(lon, lat, "driving-car", 20)
+ fiets_iso = haal_isochroon(lon, lat, "cycling-regular", 30)
+ voorzieningen = analyse_voorzieningen(lon, lat, limit=5)
+
+ m = folium.Map(location=[lat, lon], zoom_start=11, tiles="OpenStreetMap")
+
+ folium.Marker(
+ [lat, lon],
+ popup=locatie["adres"],
+ tooltip=locatie["adres"],
+ icon=folium.Icon(color="red", icon="home"),
+ ).add_to(m)
+
+ auto_group = folium.FeatureGroup(name="Isochroon: 20 min auto", show=True)
+ fiets_group = folium.FeatureGroup(name="Isochroon: 30 min fiets", show=True)
+ super_group = MarkerCluster(name="Supermarkten", show=True)
+ school_group = MarkerCluster(name="Scholen", show=True)
+
+ voeg_isochroon_toe(auto_group, auto_iso, "Auto 20 min", "#2196F3", "Bereikbaar in 20 min (auto)")
+ voeg_isochroon_toe(fiets_group, fiets_iso, "Fiets 30 min", "#2E7D32", "Bereikbaar in 30 min (fiets)")
+ voeg_pois_toe(super_group, voorzieningen["supermarkten"], "orange", "shopping-cart")
+ voeg_pois_toe(school_group, voorzieningen["scholen"], "purple", "graduation-cap")
+
+ auto_group.add_to(m)
+ fiets_group.add_to(m)
+ super_group.add_to(m)
+ school_group.add_to(m)
+ folium.LayerControl(collapsed=False).add_to(m)
+
+ all_bounds = polygon_bounds(auto_iso) + polygon_bounds(fiets_iso)
+ all_bounds += [[p.lat, p.lon] for groep in voorzieningen.values() for p in groep]
+ if all_bounds:
+ m.fit_bounds(all_bounds)
+
+ title_html = f'''
+
+ 📍 Adres Analyse
+ {locatie["adres"]}
+ ■ 20 min autorijden
+ ■ 30 min fietsen
+ 🛒 Supermarkten · 🏫 Scholen
+
+ '''
+ m.get_root().html.add_child(folium.Element(title_html))
+
+ m.save(output_file)
+ print(f"Kaart gegenereerd: {output_file}")
+ print(f"Adres: {locatie['adres']}")
+ print(f"Coördinaten: {lat}, {lon}")
+ print(f"Supermarkten: {len(voorzieningen['supermarkten'])}")
+ print(f"Scholen: {len(voorzieningen['scholen'])}")
+
+
+if __name__ == "__main__":
+ adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
+ output = sys.argv[2] if len(sys.argv) > 2 else "/root/adres-analyse/kaart.html"
+ genereer_kaart(adres, output)
diff --git a/import-osm.sh b/import-osm.sh
new file mode 100755
index 0000000..bd7f2a3
--- /dev/null
+++ b/import-osm.sh
@@ -0,0 +1,28 @@
+#!/usr/bin/env bash
+set -euo pipefail
+
+cd "$(dirname "$0")"
+mkdir -p data/osm data/postgis
+
+PBF="data/osm/netherlands-latest.osm.pbf"
+URL="https://download.geofabrik.de/europe/netherlands-latest.osm.pbf"
+
+if [[ ! -s "$PBF" ]]; then
+ echo "Downloading Netherlands OSM extract..."
+ curl -L --fail --continue-at - -o "$PBF" "$URL"
+else
+ echo "Using existing $PBF"
+fi
+
+echo "Starting PostGIS..."
+docker compose up -d postgis
+
+echo "Waiting for PostGIS healthcheck..."
+until docker compose exec -T postgis pg_isready -U adres -d adresanalyse >/dev/null 2>&1; do
+ sleep 2
+done
+
+echo "Importing supermarkets and schools from full Netherlands OSM extract..."
+python3 osm-poi-import.py "$PBF"
+
+echo "Done."
diff --git a/osm-poi-import.py b/osm-poi-import.py
new file mode 100644
index 0000000..a676e63
--- /dev/null
+++ b/osm-poi-import.py
@@ -0,0 +1,136 @@
+#!/usr/bin/env python3
+"""Importeer alleen benodigde POI's uit een OSM PBF naar PostGIS.
+Veel lichter dan volledige osm2pgsql-import voor heel Nederland.
+"""
+
+import argparse
+import os
+
+import osmium
+import psycopg2
+from psycopg2.extras import execute_values
+
+DB_DSN = os.environ.get(
+ "ADRES_ANALYSE_DB_DSN",
+ "host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres",
+)
+
+SCHEMA_SQL = """
+CREATE EXTENSION IF NOT EXISTS postgis;
+DROP TABLE IF EXISTS osm_pois;
+CREATE TABLE osm_pois (
+ id bigserial PRIMARY KEY,
+ osm_type text NOT NULL,
+ osm_id bigint NOT NULL,
+ category text NOT NULL,
+ name text NOT NULL,
+ brand text,
+ operator text,
+ lon double precision NOT NULL,
+ lat double precision NOT NULL,
+ geom geometry(Point, 4326) NOT NULL
+);
+CREATE INDEX osm_pois_category_geom_gix ON osm_pois USING GIST (geom);
+CREATE INDEX osm_pois_category_idx ON osm_pois (category);
+CREATE INDEX osm_pois_name_idx ON osm_pois (lower(name));
+
+CREATE OR REPLACE VIEW poi_supermarkten AS
+SELECT osm_id, name, 'supermarkt'::text AS category, 'supermarket'::text AS shop, NULL::text AS amenity, brand, operator, geom
+FROM osm_pois WHERE category = 'supermarkt';
+
+CREATE OR REPLACE VIEW poi_scholen AS
+SELECT osm_id, name, 'school'::text AS category, NULL::text AS shop, 'school'::text AS amenity, brand, operator, geom
+FROM osm_pois WHERE category = 'school';
+"""
+
+
+def poi_from_tags(tags):
+ if tags.get("shop") == "supermarket":
+ return "supermarkt", tags.get("name") or tags.get("brand") or "Supermarkt"
+ if tags.get("amenity") == "school":
+ return "school", tags.get("name") or "School"
+ return None, None
+
+
+class PoiHandler(osmium.SimpleHandler):
+ def __init__(self, conn, batch_size=1000):
+ super().__init__()
+ self.conn = conn
+ self.batch_size = batch_size
+ self.rows = []
+ self.count = 0
+ self.factory = osmium.geom.WKBFactory()
+
+ def flush(self):
+ if not self.rows:
+ return
+ with self.conn.cursor() as cur:
+ execute_values(
+ cur,
+ """
+ INSERT INTO osm_pois (osm_type, osm_id, category, name, brand, operator, lon, lat, geom)
+ VALUES %s
+ """,
+ self.rows,
+ )
+ self.conn.commit()
+ self.rows.clear()
+
+ def add_row(self, osm_type, osm_id, tags, lon, lat):
+ category, name = poi_from_tags(tags)
+ if not category:
+ return
+ self.rows.append((
+ osm_type,
+ int(osm_id),
+ category,
+ name,
+ tags.get("brand"),
+ tags.get("operator"),
+ float(lon),
+ float(lat),
+ f"SRID=4326;POINT({float(lon)} {float(lat)})",
+ ))
+ self.count += 1
+ if len(self.rows) >= self.batch_size:
+ self.flush()
+
+ def node(self, n):
+ if not n.location.valid():
+ return
+ self.add_row("node", n.id, n.tags, n.location.lon, n.location.lat)
+
+ def area(self, a):
+ category, _ = poi_from_tags(a.tags)
+ if not category:
+ return
+ try:
+ wkb = self.factory.create_multipolygon(a)
+ with self.conn.cursor() as cur:
+ cur.execute("SELECT ST_X(p), ST_Y(p) FROM (SELECT ST_PointOnSurface(ST_GeomFromWKB(%s, 4326)) p) s", (psycopg2.Binary(wkb),))
+ lon, lat = cur.fetchone()
+ self.add_row("area", a.orig_id(), a.tags, lon, lat)
+ except Exception:
+ return
+
+
+def main():
+ parser = argparse.ArgumentParser()
+ parser.add_argument("pbf")
+ args = parser.parse_args()
+
+ with psycopg2.connect(DB_DSN) as conn:
+ with conn.cursor() as cur:
+ cur.execute(SCHEMA_SQL)
+ conn.commit()
+ handler = PoiHandler(conn)
+ handler.apply_file(args.pbf, locations=True)
+ handler.flush()
+ with conn.cursor() as cur:
+ cur.execute("ANALYZE osm_pois")
+ conn.commit()
+ print(f"Imported {handler.count} POIs into osm_pois")
+
+
+if __name__ == "__main__":
+ main()
diff --git a/osm2pgsql.style b/osm2pgsql.style
new file mode 100644
index 0000000..6c22f18
--- /dev/null
+++ b/osm2pgsql.style
@@ -0,0 +1,12 @@
+# OsmType Tag DataType Flags
+node,way name text linear
+node,way amenity text polygon
+node,way shop text polygon
+node,way brand text linear
+node,way operator text linear
+node,way addr:street text linear
+node,way addr:housenumber text linear
+node,way addr:postcode text linear
+node,way addr:city text linear
+node,way wheelchair text linear
+node,way opening_hours text linear
diff --git a/rapport.py b/rapport.py
index 2748671..d6b14d1 100644
--- a/rapport.py
+++ b/rapport.py
@@ -1,7 +1,7 @@
#!/usr/bin/env python3
"""
Adres Analyse - Rapport generator
-Gebruikt PDOK voor geocoding en ORS voor isochronen
+Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
"""
import json
@@ -10,13 +10,14 @@ import urllib.request
import urllib.parse
from datetime import datetime
+from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown
+
# Config
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
def geocode_adres(adres):
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
- # Gebruik suggest endpoint voor betere adres matching
params = urllib.parse.urlencode({
'q': adres,
'rows': 5,
@@ -30,7 +31,6 @@ def geocode_adres(adres):
if not data['response']['docs']:
raise ValueError(f"Adres niet gevonden: {adres}")
- # Zoek naar exact adres match (type=adres)
doc = None
for d in data['response']['docs']:
if d.get('type') == 'adres':
@@ -38,9 +38,8 @@ def geocode_adres(adres):
break
if not doc:
- doc = data['response']['docs'][0] # Fallback naar eerste resultaat
+ doc = data['response']['docs'][0]
- # Parse POINT(lon lat) format
point = doc['centroide_ll']
coords = point.replace('POINT(', '').replace(')', '').split()
lon, lat = float(coords[0]), float(coords[1])
@@ -58,7 +57,7 @@ def haal_isochroon(lon, lat, profiel='driving-car', minuten=20):
url = f"{ORS_BASE}/isochrones/{profiel}"
payload = {
"locations": [[lon, lat]],
- "range": [minuten * 60], # seconden
+ "range": [minuten * 60],
"attributes": ["area", "reachfactor", "total_pop"]
}
@@ -81,49 +80,52 @@ def format_oppervlakte(m2):
else:
return f"{m2:.0f} m²"
+def print_isochroon_sectie(titel, icoon, iso_data):
+ print(f"{icoon} **{titel}**")
+ if 'features' in iso_data and iso_data['features']:
+ props = iso_data['features'][0]['properties']
+ area_m2 = props.get('area', 0)
+ reach = props.get('reachfactor', 0)
+ print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
+ print(f"**Reach factor:** {reach:.3f}")
+ bbox = iso_data.get('bbox', [])
+ if len(bbox) == 4:
+ print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
+ else:
+ print("❌ Geen isochroon data ontvangen")
+ print()
+
def genereer_rapport(adres_input):
"""Genereer volledig adres analyse rapport"""
print(f"📍 **Adres Analyse Rapport**")
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
print()
- # Geocoding
print("🔍 Geocoding...")
locatie = geocode_adres(adres_input)
print(f"**Adres:** {locatie['adres']}")
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
print()
- # Isochroon
- print("🚗 **Bereikbaarheid (Auto 20 min)**")
- iso_data = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
-
- if 'features' in iso_data and iso_data['features']:
- props = iso_data['features'][0]['properties']
- area_m2 = props.get('area', 0)
- reach = props.get('reachfactor', 0)
-
- print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
- print(f"**Reach factor:** {reach:.3f}")
- print()
-
- # Bereken bbox
- bbox = iso_data.get('bbox', [])
- if len(bbox) == 4:
- print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
- print()
-
- # Save GeoJSON voor kaart generatie
- output_file = f"/tmp/isochroon_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
- with open(output_file, 'w') as f:
- json.dump(iso_data, f)
- print(f"💾 Data opgeslagen: `{output_file}`")
- else:
- print("❌ Geen isochroon data ontvangen")
-
+ auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
+ fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
+ print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso)
+ print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso)
+
+ print("🏙️ **Voorzieningen dichtbij**")
+ voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
+ print(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
+ print()
+ print(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
+ print()
+
+ output_file = f"/tmp/isochronen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
+ with open(output_file, 'w') as f:
+ json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
+ print(f"💾 Isochroondata opgeslagen: `{output_file}`")
print()
print("---")
- print("_Onderdeel 1/?: Isochroon bereikbaarheid_")
+ print("_Onderdelen: geocoding, isochronen, voorzieningen_")
if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
diff --git a/sql/create-poi-views.sql b/sql/create-poi-views.sql
new file mode 100644
index 0000000..b0ee38e
--- /dev/null
+++ b/sql/create-poi-views.sql
@@ -0,0 +1,57 @@
+CREATE EXTENSION IF NOT EXISTS postgis;
+CREATE EXTENSION IF NOT EXISTS hstore;
+
+CREATE OR REPLACE VIEW poi_supermarkten AS
+SELECT
+ osm_id,
+ COALESCE(NULLIF(name, ''), NULLIF(brand, ''), 'Supermarkt') AS name,
+ 'supermarkt'::text AS category,
+ shop,
+ amenity,
+ brand,
+ operator,
+ way AS geom
+FROM planet_osm_point
+WHERE shop = 'supermarket'
+UNION ALL
+SELECT
+ osm_id,
+ COALESCE(NULLIF(name, ''), NULLIF(brand, ''), 'Supermarkt') AS name,
+ 'supermarkt'::text AS category,
+ shop,
+ amenity,
+ brand,
+ operator,
+ ST_PointOnSurface(way) AS geom
+FROM planet_osm_polygon
+WHERE shop = 'supermarket';
+
+CREATE OR REPLACE VIEW poi_scholen AS
+SELECT
+ osm_id,
+ COALESCE(NULLIF(name, ''), 'School') AS name,
+ 'school'::text AS category,
+ shop,
+ amenity,
+ brand,
+ operator,
+ way AS geom
+FROM planet_osm_point
+WHERE amenity = 'school'
+UNION ALL
+SELECT
+ osm_id,
+ COALESCE(NULLIF(name, ''), 'School') AS name,
+ 'school'::text AS category,
+ shop,
+ amenity,
+ brand,
+ operator,
+ ST_PointOnSurface(way) AS geom
+FROM planet_osm_polygon
+WHERE amenity = 'school';
+
+CREATE INDEX IF NOT EXISTS planet_osm_point_way_gix ON planet_osm_point USING GIST (way);
+CREATE INDEX IF NOT EXISTS planet_osm_polygon_way_gix ON planet_osm_polygon USING GIST (way);
+ANALYZE planet_osm_point;
+ANALYZE planet_osm_polygon;
diff --git a/voorzieningen.py b/voorzieningen.py
new file mode 100644
index 0000000..8be76a0
--- /dev/null
+++ b/voorzieningen.py
@@ -0,0 +1,157 @@
+#!/usr/bin/env python3
+"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix."""
+
+import json
+import math
+import os
+import urllib.request
+from dataclasses import dataclass, asdict
+
+import psycopg2
+import psycopg2.extras
+
+DB_DSN = os.environ.get(
+ "ADRES_ANALYSE_DB_DSN",
+ "host=127.0.0.1 port=5433 dbname=adresanalyse user=adres password=adres",
+)
+ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
+
+
+@dataclass
+class Voorziening:
+ categorie: str
+ naam: str
+ lat: float
+ lon: float
+ hemelsbreed_m: float
+ fiets_afstand_m: float | None = None
+ fiets_duur_s: float | None = None
+ auto_afstand_m: float | None = None
+ auto_duur_s: float | None = None
+ bron: str = "OSM/PostGIS"
+
+ def to_dict(self):
+ return asdict(self)
+
+
+def _matrix(lon, lat, pois, profiel):
+ if not pois:
+ return []
+ locations = [[lon, lat]] + [[p.lon, p.lat] for p in pois]
+ payload = json.dumps({
+ "locations": locations,
+ "sources": [0],
+ "destinations": list(range(1, len(locations))),
+ "metrics": ["distance", "duration"],
+ }).encode()
+ req = urllib.request.Request(
+ f"{ORS_BASE}/matrix/{profiel}",
+ data=payload,
+ headers={"Content-Type": "application/json"},
+ method="POST",
+ )
+ with urllib.request.urlopen(req, timeout=60) as resp:
+ data = json.loads(resp.read())
+ distances = data.get("distances", [[]])[0]
+ durations = data.get("durations", [[]])[0]
+ return list(zip(distances, durations))
+
+
+def verrijk_met_routes(lon, lat, pois):
+ fiets = _matrix(lon, lat, pois, "cycling-regular")
+ auto = _matrix(lon, lat, pois, "driving-car")
+ for i, poi in enumerate(pois):
+ if i < len(fiets):
+ poi.fiets_afstand_m, poi.fiets_duur_s = fiets[i]
+ if i < len(auto):
+ poi.auto_afstand_m, poi.auto_duur_s = auto[i]
+ return pois
+
+
+def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000):
+ if categorie == "supermarkt":
+ view = "poi_supermarkten"
+ elif categorie == "school":
+ view = "poi_scholen"
+ else:
+ raise ValueError(f"Onbekende categorie: {categorie}")
+
+ sql = f"""
+ WITH origin AS (
+ SELECT ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography AS geog
+ )
+ SELECT DISTINCT ON (lower(name))
+ name,
+ ST_Y(geom) AS lat,
+ ST_X(geom) AS lon,
+ ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m
+ FROM {view}, origin
+ WHERE geom IS NOT NULL
+ AND ST_DWithin(geom::geography, origin.geog, %s)
+ ORDER BY lower(name), hemelsbreed_m ASC
+ """
+ # DISTINCT ON kiest dichtstbijzijnde per naam; buitenquery sorteert weer op afstand.
+ wrapped = f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s"
+ with psycopg2.connect(DB_DSN) as conn:
+ with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
+ cur.execute(wrapped, (lon, lat, radius_m, limit))
+ rows = cur.fetchall()
+ return [
+ Voorziening(
+ categorie=categorie,
+ naam=row["name"],
+ lat=float(row["lat"]),
+ lon=float(row["lon"]),
+ hemelsbreed_m=float(row["hemelsbreed_m"]),
+ )
+ for row in rows
+ ]
+
+
+def analyse_voorzieningen(lon, lat, limit=5):
+ supermarkten = zoek_voorzieningen(lon, lat, "supermarkt", limit=limit, radius_m=10000)
+ scholen = zoek_voorzieningen(lon, lat, "school", limit=limit, radius_m=15000)
+ verrijk_met_routes(lon, lat, supermarkten)
+ verrijk_met_routes(lon, lat, scholen)
+ return {"supermarkten": supermarkten, "scholen": scholen}
+
+
+def format_afstand(m):
+ if m is None:
+ return "n.b."
+ if m < 1000:
+ return f"{m:.0f} m"
+ return f"{m/1000:.1f} km".replace(".", ",")
+
+
+def format_duur(s):
+ if s is None:
+ return "n.b."
+ minutes = max(1, round(s / 60))
+ return f"{minutes} min"
+
+
+def format_voorzieningen_markdown(titel, pois):
+ lines = [titel]
+ if not pois:
+ lines.append("Geen voorzieningen gevonden in zoekradius.")
+ return "\n".join(lines)
+ for i, p in enumerate(pois, 1):
+ lines.extend([
+ f"{i}. **{p.naam}**",
+ f" Hemelsbreed: {format_afstand(p.hemelsbreed_m)}",
+ f" Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)}",
+ f" Auto: {format_afstand(p.auto_afstand_m)} / {format_duur(p.auto_duur_s)}",
+ ])
+ return "\n".join(lines)
+
+
+if __name__ == "__main__":
+ import argparse
+ parser = argparse.ArgumentParser()
+ parser.add_argument("lon", type=float)
+ parser.add_argument("lat", type=float)
+ parser.add_argument("--limit", type=int, default=5)
+ args = parser.parse_args()
+ result = analyse_voorzieningen(args.lon, args.lat, args.limit)
+ print(json.dumps({k: [p.to_dict() for p in v] for k, v in result.items()}, indent=2, ensure_ascii=False))