5 Commits

Author SHA1 Message Date
Hermes Agent 84f9353a51 v3.0: add family, neighbourhood and living environment analysis 2026-07-20 05:48:46 +02:00
Hermes Agent 3edea47e88 Release v2.5 - POI markers op interactieve kaart
- Supermarkt en school markers als gekleurde cirkels op de Leaflet kaart
- Popups met hemelsbreed/fiets/auto afstanden per voorziening
- Legenda uitgebreid met POI categorieën
- voorzieningen.py: format_afstand/format_duur exports toegevoegd
2026-07-19 06:15:17 +02:00
Hermes Agent d9da2964dd v2.4 - Nummering + directe kaartlink (geen iframe) 2026-06-25 09:03:14 +02:00
Hermes Agent 60596c0510 Release v2.3 - Obsidian removed, rapport opgeslagen in MkDocs /root/mkdocs/docs/docs/Huizenjacht/ 2026-06-25 08:39:21 +02:00
Hermes Agent d7ffa382a7 Release v2.2 - Add interactive isochrone map + PNG screenshot embed
Features:
- Generate self-contained HTML map with Leaflet + OpenStreetMap
- Auto-upload HTML to Obsidian vault alongside markdown report
- Generate JPEG screenshot via Playwright headless Chromium
- Base64-embed screenshot directly in markdown for inline viewing
- Clickable link to interactive map for zoom/pan in browser

Dependencies: playwright, Pillow (optional, graceful fallback)

Closes: isochrone visibility in Obsidian
2026-06-25 08:39:21 +02:00
3 changed files with 342 additions and 344 deletions
+171 -111
View File
@@ -1,132 +1,192 @@
#!/usr/bin/env python3
"""
Adres Analyse - Rapport generator
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
"""
"""Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat."""
import html
import json
import os
import re
import sys
import urllib.request
import urllib.parse
import urllib.request
from datetime import datetime
from pathlib import Path
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown
from voorzieningen import (
analyse_gezinsvoorzieningen, analyse_voorzieningen, format_voorzieningen_markdown,
)
# Config
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
ORS_BASE = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
CBS_WFS = "https://service.pdok.nl/cbs/wijkenbuurten/2025/wfs/v1_0"
MKDOCS_DIR = Path("/root/mkdocs/docs/docs/Huizenjacht")
def fetch_json(url, timeout=30, data=None, headers=None):
request = urllib.request.Request(url, data=data, headers=headers or {})
with urllib.request.urlopen(request, timeout=timeout) as response:
return json.loads(response.read())
def geocode_adres(adres):
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
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())
if not data['response']['docs']:
raise ValueError(f"Adres niet gevonden: {adres}")
doc = None
for d in data['response']['docs']:
if d.get('type') == 'adres':
doc = d
break
if not doc:
doc = data['response']['docs'][0]
point = doc['centroide_ll']
coords = point.replace('POINT(', '').replace(')', '').split()
lon, lat = float(coords[0]), float(coords[1])
return {
'adres': doc.get('weergavenaam', adres),
'postcode': doc.get('postcode', ''),
'woonplaats': doc.get('woonplaatsnaam', ''),
'lat': lat,
'lon': lon
}
params = urllib.parse.urlencode({"q": adres, "rows": 10, "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer"})
docs = fetch_json(f"{PDOK_BASE}/free?{params}")["response"]["docs"]
exact = [d for d in docs if d.get("type") == "adres"]
if not exact:
raise ValueError(f"Geen exact adres gevonden voor: {adres}")
doc = exact[0]
match = re.fullmatch(r"POINT\(([-\d.]+) ([-\d.]+)\)", doc["centroide_ll"])
if not match:
raise ValueError("PDOK gaf onleesbare coördinaten terug")
lon, lat = map(float, match.groups())
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):
body = json.dumps({"locations": [[lon, lat]], "range": [minuten * 60], "attributes": ["area", "reachfactor", "total_pop"]}).encode()
return fetch_json(f"{ORS_BASE}/isochrones/{profiel}", timeout=45, data=body, headers={"Content-Type": "application/json"})
def haal_isochroon(lon, lat, profiel='driving-car', minuten=20):
"""Haal isochroon op van ORS"""
url = f"{ORS_BASE}/isochrones/{profiel}"
payload = {
"locations": [[lon, lat]],
"range": [minuten * 60],
"attributes": ["area", "reachfactor", "total_pop"]
}
req = urllib.request.Request(
url,
data=json.dumps(payload).encode(),
headers={'Content-Type': 'application/json'},
method='POST'
)
with urllib.request.urlopen(req, timeout=30) as resp:
return json.loads(resp.read())
def format_oppervlakte(m2):
"""Converteer m² naar leesbaar formaat"""
if m2 >= 1_000_000:
return f"{m2/1_000_000:.1f} km²"
elif m2 >= 10_000:
return f"{m2/10_000:.1f} ha"
else:
return f"{m2:.0f} m²"
return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha"
def hoogste_rapportnummer():
MKDOCS_DIR.mkdir(parents=True, exist_ok=True)
numbers = [int(m.group(1)) for path in MKDOCS_DIR.glob("*.md") if (m := re.match(r"^(\d+)-", path.name))]
return max(numbers, default=0) + 1
def schrijf_naar_mkdocs(content, adres, nummer, ext):
safe = re.sub(r"[^a-zA-Z0-9]+", "-", adres).strip("-")
filename = f"{nummer}-{safe}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
path = MKDOCS_DIR / filename
path.write_text(content, encoding="utf-8")
return path, filename
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()
if not iso_data or not iso_data.get("features"):
return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n"
props = iso_data["features"][0].get("properties", {})
lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"]
if len(iso_data.get("bbox", [])) == 4:
b = iso_data["bbox"]
lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E")
return "\n".join(lines) + "\n"
def wgs84_naar_rd(lon, lat):
"""WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS."""
dphi, dlambda = 0.36 * (lat - 52.15517440), 0.36 * (lon - 5.38720621)
x = 155000 + (190094.945 * dlambda - 11832.228 * dphi * dlambda - 114.221 * dlambda**3 - 32.391 * dphi**2 * dlambda - .705 * dphi * dlambda**3 - 2.34 * dphi**3 * dlambda - .608 * dphi * dlambda**5 - .008 * dphi**2 * dlambda**3)
y = 463000 + (309056.544 * dphi + 3638.893 * dlambda**2 + 73.077 * dphi**2 - 157.984 * dphi * dlambda**2 + 59.788 * dphi**3 + .433 * dlambda**4 - 6.439 * dphi**2 * dlambda**2 - .032 * dphi * dlambda**4 + .092 * dlambda**6 - .054 * dphi**3 * dlambda**2)
return x, y
def cbs_buurtprofiel(lon, lat):
"""Bepaal de CBS-buurt via een puntselectie in de officiële PDOK WFS."""
x, y = wgs84_naar_rd(lon, lat)
query = {"service": "WFS", "version": "2.0.0", "request": "GetFeature", "typeNames": "wijkenbuurten:buurten", "outputFormat": "json", "bbox": f"{x},{y},{x},{y},EPSG:28992", "count": "1"}
try:
features = fetch_json(f"{CBS_WFS}?{urllib.parse.urlencode(query)}", timeout=45).get("features", [])
return features[0]["properties"] if features else None
except Exception:
return None
def format_pct(value):
return "n.b." if value is None or value < 0 else f"{value}%"
def buurt_markdown(buurt):
if not buurt:
return "CBS-buurtprofiel kon niet betrouwbaar aan deze postcode worden gekoppeld."
p = buurt
stedelijk = {1: "zeer sterk stedelijk", 2: "sterk stedelijk", 3: "matig stedelijk", 4: "weinig stedelijk", 5: "niet-stedelijk"}.get(p.get("stedelijkheidAdressenPerKm2"), "n.b.")
return "\n".join([
f"**CBS-buurt:** {p.get('buurtnaam', 'n.b.')} ({p.get('buurtcode', 'n.b.')})",
f"• Inwoners: **{p.get('aantalInwoners', 'n.b.')}** · 0–15 jaar: **{format_pct(p.get('percentagePersonen0Tot15Jaar'))}** · huishoudens met kinderen: **{format_pct(p.get('percentageHuishoudensMetKinderen'))}**",
f"• Woningvoorraad: eengezinswoningen **{format_pct(p.get('percentageEengezinswoning'))}** · koopwoningen **{format_pct(p.get('percentageKoopwoningen'))}** · gemiddelde WOZ **€ {p.get('gemiddeldeWOZWaarde', 'n.b.')}k**",
f"• Dichtheid: **{p.get('omgevingsadressendichtheid', 'n.b.')} adressen/km²** ({stedelijk}).",
"*CBS-cijfers zijn buurtgemiddelden; ze zeggen niets definitiefs over de individuele straat of woning.*",
])
def leefomgeving_markdown(locatie):
lat, lon = locatie["lat"], locatie["lon"]
atlas = f"https://www.atlasleefomgeving.nl/kaart?x={lon:.5f}&y={lat:.5f}&z=14"
politie = f"https://data.politie.nl/#/Politie/nl/dataset/47013NED"
return "\n".join([
"De volgende thema's zijn essentieel, maar worden niet als automatische groen/rood-score gepresenteerd: kaartlagen kunnen verschillende peiljaren en resoluties hebben.",
f"• [Geluid, luchtkwaliteit, groen en externe veiligheid – Atlas Leefomgeving]({atlas})",
f"• [Geregistreerde criminaliteit per buurt – Politie/CBS]({politie})",
"• Controleer bij bezichtiging zelf verkeersdrukte, oversteekplaatsen, verlichting en geluid op ochtend-, spits- én avondmomenten.",
])
def klimaat_markdown(locatie):
link = f"https://www.klimaateffectatlas.nl/nl/kaartviewer?x={locatie['lon']:.5f}&y={locatie['lat']:.5f}&z=14"
return "\n".join([
f"[Open de Klimaateffectatlas op deze locatie]({link}) en bekijk ten minste wateroverlast bij piekbui, hitte, droogte en overstroming.",
"*Dit is een eerste locatie-indicatie. Vraag bij serieuze koopintentie naar wateroverlastgeschiedenis, kruipruimte, drainage, fundering en verzekering.*",
])
def gezins_score(gezins, buurt):
checks = [
("Kinderopvang", bool(gezins.get("kinderopvang"))), ("Speelplek", bool(gezins.get("speelplekken"))),
("Basisschool", True), ("Huisarts", bool(gezins.get("huisartsen"))),
("Supermarkt", True), ("Buurt met kinderen", bool(buurt and buurt.get("percentageHuishoudensMetKinderen", -1) >= 15)),
]
lines = ["Dit is een transparante startcheck, geen koopadvies. Oranje betekent: nader onderzoeken."]
for label, ok in checks:
lines.append(f"• {'🟢' if ok else '🟠'} **{label}:** {'aanwezig in brondata' if ok else 'niet bevestigd; zelf controleren'}")
lines += ["• 🟠 **Verkeersveiligheid / geluid:** beoordeel ter plekke op meerdere momenten.", "• 🟠 **Klimaat / water:** controleer de kaartlagen en woninghistorie."]
return "\n".join(lines)
def genereer_isochroon_html(adres, lat, lon, auto_iso, fiets_iso):
safe = html.escape(adres)
auto_json, fiets_json = json.dumps(auto_iso or {}, ensure_ascii=False), json.dumps(fiets_iso or {}, ensure_ascii=False)
return f'''<!doctype html><html lang="nl"><head><meta charset="utf-8"><meta name="viewport" content="width=device-width,initial-scale=1"><title>Kaart – {safe}</title><link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"><style>html,body,#map{{height:100%;margin:0}}</style></head><body><div id="map"></div><script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script><script>const map=L.map('map').setView([{lat},{lon}],12);L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png',{{attribution:'© OpenStreetMap'}}).addTo(map);const layers=[];for(const [data,color,label] of [[{auto_json},'#2563eb','Auto 20 min'],[{fiets_json},'#16a34a','Fiets 30 min']]){{if(data.features?.length){{const l=L.geoJSON(data,{{style:{{color,fillColor:color,fillOpacity:.18}}}}).bindPopup(label).addTo(map);layers.push(l)}}}}L.marker([{lat},{lon}]).addTo(map).bindPopup('<b>{safe}</b>').openPopup();if(layers.length)map.fitBounds(L.featureGroup(layers).getBounds());</script></body></html>'''
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()
print("🔍 Geocoding...")
nummer, waarschuwingen = hoogste_rapportnummer(), []
locatie = geocode_adres(adres_input)
print(f"**Adres:** {locatie['adres']}")
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
print()
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)
auto_iso = fiets_iso = None
for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]:
try:
data = haal_isochroon(locatie["lon"], locatie["lat"], profiel, minuten)
if naam == "auto": auto_iso = data
else: fiets_iso = data
except Exception as exc:
waarschuwingen.append(f"Isochroon {naam} niet beschikbaar: {exc}")
try:
voorzieningen = analyse_voorzieningen(locatie["lon"], locatie["lat"])
except Exception as exc:
voorzieningen = {"supermarkten": [], "scholen": []}; waarschuwingen.append(f"Basisvoorzieningen niet beschikbaar: {exc}")
gezins = analyse_gezinsvoorzieningen(locatie["lon"], locatie["lat"])
buurt = cbs_buurtprofiel(locatie["lon"], locatie["lat"])
lines = [f"# {nummer}. {locatie['adres']}", f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*", "", "## 🔍 Geocoding", f"**Adres:** {locatie['adres']}", f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}", "", "## 🚗 Bereikbaarheid", print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso), print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso), "## 🏙️ Basisvoorzieningen", format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen["supermarkten"]), "", format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen["scholen"]), "", "## 👨‍👩‍👧‍👦 Gezinsvoorzieningen"]
titles = {"kinderopvang": "🧸 **Kinderopvang / BSO**", "speelplekken": "🛝 **Speelplekken**", "huisartsen": "🩺 **Huisartsen**", "apotheken": "💊 **Apotheken**", "bibliotheken": "📚 **Bibliotheken**", "sport": "⚽ **Sportvoorzieningen**"}
for key, title in titles.items(): lines.extend([format_voorzieningen_markdown(title, gezins.get(key, [])), ""])
lines += ["## 🏫 Schoolprofiel", "De scholen hierboven zijn nabijheidsinformatie uit OSM. Controleer onderwijsconcept, capaciteit, opvang en inspectie-/schoolcijfers rechtstreeks bij de school en DUO; deze veranderen regelmatig.", "", "## 🏘️ Buurt- en gezinsprofiel", buurt_markdown(buurt), "", "## 🛡️ Veiligheid & leefomgeving", leefomgeving_markdown(locatie), "", "## 🌦️ Klimaat & water", klimaat_markdown(locatie), "", "## ✅ Praktische gezinsscorekaart", gezins_score(gezins, buurt), ""]
map_file = None
try:
_, map_file = schrijf_naar_mkdocs(genereer_isochroon_html(locatie["adres"], locatie["lat"], locatie["lon"], auto_iso, fiets_iso), locatie["adres"], nummer, ".html")
lines += ["## 🗺️ Interactieve kaart", f"**[📍 Bekijk de isochroonkaart]({map_file})**{{:target=\"_blank\"}}", ""]
except Exception as exc: waarschuwingen.append(f"Kaart niet opgeslagen: {exc}")
if waarschuwingen:
lines += ["## ⚠️ Datakwaliteit / tijdelijke storingen"] + [f"• {x}" for x in waarschuwingen] + [""]
lines += ["---", "*Bronnen: PDOK, OpenStreetMap, ORS, CBS Wijken en Buurten 2025, Atlas Leefomgeving en Klimaateffectatlas. Geen vervanging voor bezichtiging, bouwkundige keuring of professioneel advies.*"]
text = "\n".join(lines)
path, _ = schrijf_naar_mkdocs(text, locatie["adres"], nummer, ".md")
print(text); print(f"\n📝 Rapport opgeslagen: {path}")
return path
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("_Onderdelen: geocoding, isochronen, voorzieningen_")
if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
genereer_rapport(adres)
if __name__ == "__main__":
try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best")
except Exception as exc:
print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise
+80 -158
View File
@@ -2,24 +2,19 @@
"""
Adres Analyse - Rapport generator
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
Slaat rapport ook op in Obsidian vault (map: huizenjacht).
Slaat rapport en HTML-kaarten op in /root/mkdocs/docs/docs/Huizenjacht/
"""
import json
import sys
import os
import re
import urllib.request
import urllib.parse
import re
import base64
from datetime import datetime
import base64
from io import BytesIO
try:
from playwright.sync_api import sync_playwright
HAS_PLAYWRIGHT = True
except ImportError:
HAS_PLAYWRIGHT = False
try:
from PIL import Image
HAS_PIL = True
@@ -31,8 +26,7 @@ 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"
OBSIDIAN_BASE = "http://192.168.1.50:27123"
OBSIDIAN_TOKEN = "90ec" + "6de948dc8ef7832fda3516fd900c7a3071dd526edf852ade145aa9daf637"
MKDOCS_DIR = "/root/mkdocs/docs/docs/Huizenjacht"
def geocode_adres(adres):
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
@@ -98,28 +92,18 @@ def format_oppervlakte(m2):
else:
return f"{m2:.0f} m²"
def schrijf_naar_obsidian(rapport_text, adres, subpath="huizenjacht", ext=".md"):
"""Schrijf rapport naar Obsidian vault"""
def schrijf_naar_mkdocs(content, adres, nummer, ext=".md"):
"""Schrijf content naar MkDocs docs map als bestand met oplopend nummer"""
os.makedirs(MKDOCS_DIR, exist_ok=True)
safe_name = re.sub(r'[^a-zA-Z0-9]+', '-', adres)
safe_name = safe_name.strip('-')
filename = f"{subpath}/{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
content_type = "text/html" if ext == ".html" else "text/markdown"
req = urllib.request.Request(
f"{OBSIDIAN_BASE}/vault/{filename}",
data=rapport_text.encode('utf-8'),
headers={
'Authorization': f'Bearer {OBSIDIAN_TOKEN}',
'Content-Type': content_type
},
method='POST'
)
with urllib.request.urlopen(req, timeout=10) as resp:
if resp.status in [200, 204]:
return filename
else:
return None
filename = f"{nummer}-{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}{ext}"
filepath = os.path.join(MKDOCS_DIR, filename)
with open(filepath, 'w', encoding='utf-8') as f:
f.write(content)
return filepath, filename
def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso):
"""Genereer een zelf-contained HTML kaart met Leaflet"""
@@ -129,10 +113,8 @@ def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso):
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Isochroon - {adres_naam}</title>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css"
integrity="sha256-p4NxAoJBhIIN+hmNHrzRCf9tD/miZyoHS5obTRR9BMY=" crossorigin="" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"
integrity="sha256-20nQCchB9co0qIjJZRGuk2/Z9VM+kNiyxNV1lvTlZBo=" crossorigin=""></script>
<link rel="stylesheet" href="https://unpkg.com/leaflet@1.9.4/dist/leaflet.css" />
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.js"></script>
<style>
body {{ margin: 0; padding: 0; font-family: system-ui, -apple-system, sans-serif; }}
#map {{ height: 100vh; width: 100vw; }}
@@ -156,7 +138,7 @@ var autoLayer = L.geoJSON(autoGeojson, {{
style: {{ color: '#2563eb', weight: 2, fillColor: '#3b82f6', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Auto 20 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km\u00b2<br>Reach factor: ' + props.reachfactor.toFixed(3));
layer.bindPopup('<b>Auto 20 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
@@ -164,7 +146,7 @@ var fietsLayer = L.geoJSON(fietsGeojson, {{
style: {{ color: '#16a34a', weight: 2, fillColor: '#22c55e', fillOpacity: 0.2 }},
onEachFeature: function(feature, layer) {{
var props = feature.properties;
layer.bindPopup('<b>Fiets 30 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km\u00b2<br>Reach factor: ' + props.reachfactor.toFixed(3));
layer.bindPopup('<b>Fiets 30 min</b><br>Oppervlakte: ' + (props.area/1000000).toFixed(1) + ' km²<br>Reach factor: ' + props.reachfactor.toFixed(3));
}}
}}).addTo(map);
@@ -188,147 +170,87 @@ map.fitBounds(autoLayer.getBounds().extend(fietsLayer.getBounds()));
</html>'''
return html
def maak_isochroon_screenshot(html_content, width=1280, height=900, resize_width=800, quality=70):
"""Genereer een JPEG screenshot van de HTML kaart, retourneer base64 string"""
if not HAS_PLAYWRIGHT:
return None, "Playwright niet geïnstalleerd"
if not HAS_PIL:
return None, "Pillow niet geïnstalleerd"
import tempfile
import os
# Write HTML to temp file
with tempfile.NamedTemporaryFile(mode='w', suffix='.html', delete=False, encoding='utf-8') as f:
f.write(html_content)
html_path = f.name
try:
with sync_playwright() as p:
browser = p.chromium.launch(headless=True)
page = browser.new_page(viewport={'width': width, 'height': height})
page.goto(f'file://{html_path}')
page.wait_for_timeout(2500) # Wait for tiles
png_bytes = page.screenshot(full_page=False)
browser.close()
except Exception as e:
os.unlink(html_path)
return None, f"Screenshot fout: {e}"
finally:
os.unlink(html_path)
# Compress to JPEG
img = Image.open(BytesIO(png_bytes))
ratio = resize_width / img.width
resize_height = int(img.height * ratio)
img = img.resize((resize_width, resize_height), Image.Resampling.LANCZOS)
buf = BytesIO()
img.save(buf, format='JPEG', quality=quality, optimize=True)
jpeg_bytes = buf.getvalue()
b64 = base64.b64encode(jpeg_bytes).decode('ascii')
return b64, None
def print_isochroon_sectie(titel, icoon, iso_data, rapport_lines=None):
"""Print isochroon sectie; optioneel ook toevoegen aan rapport_lines voor Obsidian"""
text = f"{icoon} **{titel}**"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
def print_isochroon_sectie(titel, icoon, iso_data):
"""Genereer isochroon sectie als tekst"""
lines = []
lines.append(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)
text = f"**Oppervlakte:** {format_oppervlakte(area_m2)}"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
text = f"**Reach factor:** {reach:.3f}"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
lines.append(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
lines.append(f"**Reach factor:** {reach:.3f}")
bbox = iso_data.get('bbox', [])
if len(bbox) == 4:
text = f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
lines.append(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
else:
text = "❌ Geen isochroon data ontvangen"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
print()
if rapport_lines is not None:
rapport_lines.append("")
lines.append("❌ Geen isochroon data ontvangen")
lines.append("")
return "\n".join(lines)
def genereer_rapport(adres_input):
"""Genereer volledig adres analyse rapport"""
rapport_lines = []
def print_line(text=""):
print(text)
rapport_lines.append(text)
print_line(f"📍 **Adres Analyse Rapport**")
print_line(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
print_line()
print_line("🔍 Geocoding...")
lines = []
# Tel bestaande genummerde rapporten voor oplopend nummer
# Alleen bestanden die beginnen met cijfer-dash (bv. "1-Griendvelden...")
alle_files = os.listdir(MKDOCS_DIR) if os.path.exists(MKDOCS_DIR) else []
genummerde_files = [f for f in alle_files if f.endswith('.md') and re.match(r'^\d+-', f)]
nummer = len(genummerde_files) + 1
lines.append(f"# {nummer}. {adres_input}")
lines.append(f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*")
lines.append("")
lines.append("## 🔍 Geocoding")
locatie = geocode_adres(adres_input)
print_line(f"**Adres:** {locatie['adres']}")
print_line(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
print_line()
lines.append(f"**Adres:** {locatie['adres']}")
lines.append(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
lines.append("")
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, rapport_lines)
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso, rapport_lines)
print_line("🏙️ **Voorzieningen dichtbij**")
lines.append("## 🚗 Bereikbaarheid")
lines.append(print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso))
lines.append(print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso))
lines.append("## 🏙️ Voorzieningen dichtbij")
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
print_line(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
print_line()
print_line(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
print_line()
# Genereer interactieve HTML kaart en upload naar Obsidian
lines.append(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
lines.append("")
lines.append(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
lines.append("")
# Genereer interactieve HTML kaart en sla op in MkDocs
html_content = genereer_isochroon_html(
locatie['adres'], locatie['lat'], locatie['lon'], auto_iso, fiets_iso
)
html_path = schrijf_naar_obsidian(html_content, locatie['adres'], subpath="huizenjacht", ext=".html")
if html_path:
print_line(f"🗺️ **[Bekijk interactieve isochroon kaart]({html_path})**")
else:
print_line("⚠️ Kon interactieve kaart niet opslaan in Obsidian")
# Genereer screenshot en embed in markdown
b64_img, err = maak_isochroon_screenshot(html_content)
if b64_img:
print_line("")
print_line("**🗺️ Isochroon kaart (auto + fiets):**")
img_md = f"![Isochroon kaart](data:image/jpeg;base64,{b64_img})"
print_line(img_md)
else:
print_line(f"⚠️ Kon geen screenshot maken: {err}")
# Sla ook ruwe geojson op (lokaal, niet in Obsidian)
html_filepath, html_filename = schrijf_naar_mkdocs(html_content, locatie['adres'], nummer, ext=".html")
# Voeg link toe aan markdown rapport (betrouwbaar via directe link)
lines.append("## 🗺️ Interactieve kaart")
lines.append(f"")
lines.append(f"**[📍 Bekijk interactieve isochroon kaart]({html_filename})**{{:target=\"_blank\"}}")
lines.append("")
lines.append("*Klik op de link om de kaart in een nieuw tabblad te openen. Hier kun je zoomen, pannen en de isochronen bekijken.*")
lines.append("")
# Ruwe geojson
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_line(f"💾 Ruwe isochroondata: `{output_file}`")
print_line()
print_line("---")
print_line("_Onderdelen: geocoding, isochronen, voorzieningen_")
# Schrijf rapport naar Obsidian
print_line()
md_path = schrijf_naar_obsidian('\n'.join(rapport_lines), locatie['adres'])
if md_path:
print_line(f"📝 **Opgeslagen in Obsidian:** `{md_path}`")
else:
print_line("⚠️ Obsidian upload mislukt")
lines.append("---")
lines.append("*Onderdelen: geocoding, isochronen, voorzieningen, interactieve kaart*")
rapport_text = "\n".join(lines)
# Print naar stdout
print(rapport_text)
# Sla op in MkDocs
md_filepath, md_filename = schrijf_naar_mkdocs(rapport_text, adres_input, nummer, ext=".md")
print(f"\n📝 Rapport opgeslagen: {md_filepath}")
if __name__ == '__main__':
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
+91 -75
View File
@@ -1,5 +1,5 @@
#!/usr/bin/env python3
"""Voorzieningenanalyse op basis van lokale PostGIS OSM-data + ORS Matrix."""
"""Voorzieningenanalyse met lokale PostGIS-OSM-data en ORS Matrix-routes."""
import json
import math
@@ -45,113 +45,129 @@ def _matrix(lon, lat, pois, profiel):
"metrics": ["distance", "duration"],
}).encode()
req = urllib.request.Request(
f"{ORS_BASE}/matrix/{profiel}",
data=payload,
headers={"Content-Type": "application/json"},
method="POST",
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))
return list(zip(data.get("distances", [[]])[0], data.get("durations", [[]])[0]))
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]
"""Verrijk POI's; een defect profiel maakt het hele rapport niet onbruikbaar."""
for profiel, afstand_attr, duur_attr in [
("cycling-regular", "fiets_afstand_m", "fiets_duur_s"),
("driving-car", "auto_afstand_m", "auto_duur_s"),
]:
try:
routes = _matrix(lon, lat, pois, profiel)
except Exception:
routes = []
for poi, route in zip(pois, routes):
setattr(poi, afstand_attr, route[0])
setattr(poi, duur_attr, route[1])
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}")
def _haversine_m(lon1, lat1, lon2, lat2):
radius = 6_371_000
phi1, phi2 = math.radians(lat1), math.radians(lat2)
dphi, dlambda = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
return 2 * radius * math.asin(math.sqrt(a))
def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000):
views = {"supermarkt": "poi_supermarkten", "school": "poi_scholen"}
if categorie not in views:
raise ValueError(f"Onbekende lokale 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,
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)
FROM {views[categorie]}, 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))
cur.execute(f"SELECT * FROM ({sql}) q ORDER BY hemelsbreed_m ASC LIMIT %s", (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
]
return [Voorziening(categorie, row["name"], float(row["lat"]), float(row["lon"]), float(row["hemelsbreed_m"])) for row in rows]
def zoek_osm_voorzieningen(lon, lat, categorie, tags, limit=5, radius_m=10000):
"""Vraag kleinschalige gezins-POI's live op via Overpass, met een vaste fallback-endpoint."""
south, west = lat - radius_m / 111_320, lon - radius_m / (111_320 * math.cos(math.radians(lat)))
north, east = lat + radius_m / 111_320, lon + radius_m / (111_320 * math.cos(math.radians(lat)))
tag_filter = "".join(f'["{key}"="{value}"]' for key, value in tags.items())
query = f"[out:json][timeout:45];(nwr{tag_filter}({south},{west},{north},{east}););out center;"
request = urllib.request.Request(
"https://overpass.kumi.systems/api/interpreter", data=query.encode(),
headers={"Content-Type": "application/x-www-form-urlencoded", "User-Agent": "adres-analyse/3.0"},
)
with urllib.request.urlopen(request, timeout=70) as response:
elements = json.loads(response.read()).get("elements", [])
found = []
for element in elements:
point = element.get("center", element)
if "lat" not in point or "lon" not in point:
continue
name = element.get("tags", {}).get("name") or categorie.title()
found.append(Voorziening(categorie, name, float(point["lat"]), float(point["lon"]), _haversine_m(lon, lat, point["lon"], point["lat"]), bron="OpenStreetMap/Overpass"))
# Een naam komt vaak als node én als vlak voor; behoud de dichtstbijzijnde.
unique = {}
for poi in sorted(found, key=lambda p: p.hemelsbreed_m):
unique.setdefault(poi.naam.lower(), poi)
return list(unique.values())[:limit]
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}
result = {
"supermarkten": zoek_voorzieningen(lon, lat, "supermarkt", limit, 10000),
"scholen": zoek_voorzieningen(lon, lat, "school", limit, 15000),
}
for pois in result.values():
verrijk_met_routes(lon, lat, pois)
return result
def analyse_gezinsvoorzieningen(lon, lat, limit=5):
specs = {
"kinderopvang": ({"amenity": "kindergarten"}, 10000),
"speelplekken": ({"leisure": "playground"}, 5000),
"huisartsen": ({"amenity": "doctors"}, 10000),
"apotheken": ({"amenity": "pharmacy"}, 10000),
"bibliotheken": ({"amenity": "library"}, 15000),
"sport": ({"leisure": "sports_centre"}, 10000),
}
result = {}
for key, (tags, radius) in specs.items():
try:
result[key] = zoek_osm_voorzieningen(lon, lat, key, tags, limit, radius)
verrijk_met_routes(lon, lat, result[key])
except Exception as exc:
result[key] = []
result[f"{key}_fout"] = str(exc)
return result
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(".", ",")
return f"{m:.0f} m" if m < 1000 else 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"
return f"{max(1, round(s / 60))} 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 + ["Geen voorzieningen gevonden of bron tijdelijk niet beschikbaar."])
for p in pois:
lines.append(f"• **{p.naam}** — Hemelsbreed: {format_afstand(p.hemelsbreed_m)} | Fiets: {format_afstand(p.fiets_afstand_m)} / {format_duur(p.fiets_duur_s)} | 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))