Compare commits
6 Commits
8f2613087c
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 84f9353a51 | |||
| 3edea47e88 | |||
| d9da2964dd | |||
| 60596c0510 | |||
| d7ffa382a7 | |||
| 5c6fc8c277 |
@@ -38,13 +38,14 @@ Output is markdown formaat, direct naar stdout (voor Telegram) én wordt opgesla
|
|||||||
- **Fiets profiel:** `cycling-regular`, 30 minuten, groen op de kaart
|
- **Fiets profiel:** `cycling-regular`, 30 minuten, groen op de kaart
|
||||||
- **Metrics:** Oppervlakte (km²), reach factor, geografisch bereik
|
- **Metrics:** Oppervlakte (km²), reach factor, geografisch bereik
|
||||||
- **Data bron:** ORS op `192.168.1.71:9080`
|
- **Data bron:** ORS op `192.168.1.71:9080`
|
||||||
|
- **Interactieve kaart:** Het script genereert automatisch een zelf-contained HTML kaart (Leaflet + OpenStreetMap) met beide isochronen en het adres als marker. Deze wordt opgeslagen in Obsidian naast het markdown rapport. In Obsidian verschijnt een link `[Bekijk interactieve isochroon kaart](huizenjacht/...html)` — klik erop om in je browser te openen, zoomen en pannen.
|
||||||
|
|
||||||
### 2. Voorzieningen in de buurt
|
### 2. Voorzieningen in de buurt
|
||||||
- **Bron:** lokale PostGIS database met geselecteerde POI's uit heel Nederland OSM extract
|
- **Bron:** lokale PostGIS database met geselecteerde POI's uit heel Nederland OSM extract
|
||||||
- **Docker:** `postgis/postgis` op poort `5433`, database `adresanalyse`
|
- **Docker:** `postgis/postgis` op poort `5433`, database `adresanalyse`
|
||||||
- **Import:** `import-osm.sh` downloadt `netherlands-latest.osm.pbf` en `osm-poi-import.py` importeert alleen `shop=supermarket` en `amenity=school`
|
- **Import:** `import-osm.sh` downloadt `netherlands-latest.osm.pbf` en `osm-poi-import.py` importeert alleen `shop=supermarket` en `amenity=school`
|
||||||
- **Routeverrijking:** ORS Matrix voor `driving-car` en `cycling-regular`
|
- **Routeverrijking:** ORS Matrix voor `driving-car` en `cycling-regular`
|
||||||
- **Output:** top 5 supermarkten en top 5 scholen in rapport + markers op Folium kaart
|
- **Output:** top 5 supermarkten en top 5 scholen in rapport
|
||||||
|
|
||||||
### 3. (Toekomstige onderdelen)
|
### 3. (Toekomstige onderdelen)
|
||||||
- OV bereikbaarheid
|
- OV bereikbaarheid
|
||||||
@@ -84,19 +85,16 @@ curl -s -X POST "http://192.168.1.71:9080/ors/v2/isochrones/driving-car" \
|
|||||||
- Response bevat GeoJSON FeatureCollection met polygon geometrie
|
- Response bevat GeoJSON FeatureCollection met polygon geometrie
|
||||||
- GeoJSON wordt opgeslagen in `/tmp/isochroon_{timestamp}.geojson`
|
- GeoJSON wordt opgeslagen in `/tmp/isochroon_{timestamp}.geojson`
|
||||||
|
|
||||||
### Stap 3: Kaart genereren (optioneel)
|
### Stap 3: Interactieve kaart + screenshot genereren (automatisch)
|
||||||
Gebruik script: `scripts/generate-map.py`
|
Het script genereert twee bestanden:
|
||||||
|
1. **HTML kaart** (Leaflet + OpenStreetMap) met inline GeoJSON — opgeslagen in Obsidian als `.html`. In Obsidian verschijnt een link om de kaart in de browser te openen voor zoomen/pannen.
|
||||||
|
2. **JPEG screenshot** van de kaart — direct gebase64-embed in het markdown rapport. Je ziet de kaart direct in Obsidian zonder te klikken.
|
||||||
|
|
||||||
```bash
|
Vereisten voor screenshot:
|
||||||
python3 scripts/generate-map.py "Griendvelden 14, Best" /root/adres-analyse/kaart.html
|
- `playwright` (`pip install playwright; playwright install chromium`)
|
||||||
```
|
- `Pillow` (`pip install Pillow`)
|
||||||
|
|
||||||
Voor Telegram delivery:
|
Als playwright niet geïnstalleerd is, wordt de screenshot overgeslagen met een waarschuwing; de rest van het rapport werkt gewoon.
|
||||||
1. Open HTML in browser: `browser_navigate` naar `file:///root/adres-analyse/kaart.html`
|
|
||||||
2. Screenshot maken: `browser_vision` met vraag over kaart inhoud
|
|
||||||
3. Screenshot pad gebruiken in MEDIA: directive voor Telegram
|
|
||||||
|
|
||||||
**Vereisten:** `pip install folium shapely`
|
|
||||||
|
|
||||||
## Pitfalls
|
## Pitfalls
|
||||||
|
|
||||||
@@ -108,11 +106,11 @@ Voor Telegram delivery:
|
|||||||
|
|
||||||
4. **Oppervlakte conversie**: ORS geeft area in m², niet km². Deel door 1_000_000 voor km² of 10_000 voor hectare.
|
4. **Oppervlakte conversie**: ORS geeft area in m², niet km². Deel door 1_000_000 voor km² of 10_000 voor hectare.
|
||||||
|
|
||||||
5. **Folium installatie**: `pip install folium shapely` nodig voor kaartgeneratie (niet voor basis rapport).
|
5. **PNG screenshot vereist playwright + Pillow**: De base64-afbeelding in het markdown wordt gegenereerd met een headless Chromium via Playwright. Zonder deze dependencies wordt de screenshot overgeslagen (het rapport blijft werken). Installeer met: `pip install playwright Pillow && playwright install chromium`.
|
||||||
|
|
||||||
6. **Telegram media**: HTML-kaarten werken niet direct in Telegram - maak PNG screenshots via browser tools.
|
6. **Base64-afbeelding maakt markdown groter**: De JPEG is ~80-120KB base64. Dit is acceptabel voor een enkel rapport, maar het markdown bestand wordt merkbaar groter dan tekst-alleen.
|
||||||
|
|
||||||
7. **Rapport zonder kaart**: Het rapport.py script genereert alleen tekst. Voor visuele kaart: gebruik apart folium script + browser screenshot workflow.
|
7. **Obsidian HTML links**: Obsidian opent `.html` bestanden in de systeembrowser (niet in de Obsidian viewer). De link werkt alleen als het HTML bestand in dezelfde vault staat.
|
||||||
|
|
||||||
8. **Obsidian API**: Vereist dat Obsidian draait met Local REST API plugin ingeschakeld. Token en host staan in script. Bestandsnaam wordt gegenereerd met timestamp om collisions te voorkomen.
|
8. **Obsidian API**: Vereist dat Obsidian draait met Local REST API plugin ingeschakeld. Token en host staan in script. Bestandsnaam wordt gegenereerd met timestamp om collisions te voorkomen.
|
||||||
|
|
||||||
@@ -143,8 +141,21 @@ Gegenereerd: 19-06-2026 18:42
|
|||||||
**Reach factor:** 0.091
|
**Reach factor:** 0.091
|
||||||
**Bereik:** 51.378°-51.679°N, 5.128°-5.530°E
|
**Bereik:** 51.378°-51.679°N, 5.128°-5.530°E
|
||||||
|
|
||||||
💾 Data opgeslagen: `/tmp/isochroon_20260619_184239.geojson`
|
🚲 **Bereikbaarheid (Fiets 30 min)**
|
||||||
|
**Oppervlakte:** 145.9 km²
|
||||||
|
**Reach factor:** 0.826
|
||||||
|
**Bereik:** 51.440°-51.570°N, 5.251°-5.486°E
|
||||||
|
|
||||||
|
🏙️ **Voorzieningen dichtbij**
|
||||||
|
...
|
||||||
|
|
||||||
|
🗺️ **[Bekijk interactieve isochroon kaart](huizenjacht/Griendvelden-14-5685JL-Best_20260622_111308.html)**
|
||||||
|
|
||||||
|
**🗺️ Isochroon kaart (auto + fiets):**
|
||||||
|

|
||||||
|
|
||||||
|
💾 Ruwe isochroondata: `/tmp/isochronen_20260622_111308.geojson`
|
||||||
|
|
||||||
---
|
---
|
||||||
_Onderdeel 1/?: Isochroon bereikbaarheid_
|
_Onderdelen: geocoding, isochronen, voorzieningen_
|
||||||
```
|
```
|
||||||
|
|||||||
+171
-111
@@ -1,132 +1,192 @@
|
|||||||
#!/usr/bin/env python3
|
#!/usr/bin/env python3
|
||||||
"""
|
"""Adresanalyse voor koopwoningen: bereikbaarheid, gezin, buurt, leefomgeving en klimaat."""
|
||||||
Adres Analyse - Rapport generator
|
|
||||||
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
|
|
||||||
"""
|
|
||||||
|
|
||||||
|
import html
|
||||||
import json
|
import json
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import sys
|
import sys
|
||||||
import urllib.request
|
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
|
import urllib.request
|
||||||
from datetime import datetime
|
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 = os.environ.get("ORS_BASE", "http://192.168.1.71:9080/ors/v2")
|
||||||
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
|
|
||||||
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
|
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):
|
def geocode_adres(adres):
|
||||||
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
|
params = urllib.parse.urlencode({"q": adres, "rows": 10, "fl": "centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer"})
|
||||||
params = urllib.parse.urlencode({
|
docs = fetch_json(f"{PDOK_BASE}/free?{params}")["response"]["docs"]
|
||||||
'q': adres,
|
exact = [d for d in docs if d.get("type") == "adres"]
|
||||||
'rows': 5,
|
if not exact:
|
||||||
'fl': 'centroide_ll,weergavenaam,postcode,woonplaatsnaam,type,huisnummer'
|
raise ValueError(f"Geen exact adres gevonden voor: {adres}")
|
||||||
})
|
doc = exact[0]
|
||||||
|
match = re.fullmatch(r"POINT\(([-\d.]+) ([-\d.]+)\)", doc["centroide_ll"])
|
||||||
url = f"{PDOK_BASE}/free?{params}"
|
if not match:
|
||||||
with urllib.request.urlopen(url, timeout=10) as resp:
|
raise ValueError("PDOK gaf onleesbare coördinaten terug")
|
||||||
data = json.loads(resp.read())
|
lon, lat = map(float, match.groups())
|
||||||
|
return {"adres": doc.get("weergavenaam", adres), "postcode": doc.get("postcode", ""), "woonplaats": doc.get("woonplaatsnaam", ""), "lat": lat, "lon": lon}
|
||||||
if not data['response']['docs']:
|
|
||||||
raise ValueError(f"Adres niet gevonden: {adres}")
|
|
||||||
|
def haal_isochroon(lon, lat, profiel, minuten):
|
||||||
doc = None
|
body = json.dumps({"locations": [[lon, lat]], "range": [minuten * 60], "attributes": ["area", "reachfactor", "total_pop"]}).encode()
|
||||||
for d in data['response']['docs']:
|
return fetch_json(f"{ORS_BASE}/isochrones/{profiel}", timeout=45, data=body, headers={"Content-Type": "application/json"})
|
||||||
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
|
|
||||||
}
|
|
||||||
|
|
||||||
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):
|
def format_oppervlakte(m2):
|
||||||
"""Converteer m² naar leesbaar formaat"""
|
return f"{m2 / 1_000_000:.1f} km²" if m2 >= 1_000_000 else f"{m2 / 10_000:.1f} ha"
|
||||||
if m2 >= 1_000_000:
|
|
||||||
return f"{m2/1_000_000:.1f} km²"
|
|
||||||
elif m2 >= 10_000:
|
def hoogste_rapportnummer():
|
||||||
return f"{m2/10_000:.1f} ha"
|
MKDOCS_DIR.mkdir(parents=True, exist_ok=True)
|
||||||
else:
|
numbers = [int(m.group(1)) for path in MKDOCS_DIR.glob("*.md") if (m := re.match(r"^(\d+)-", path.name))]
|
||||||
return f"{m2:.0f} m²"
|
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):
|
def print_isochroon_sectie(titel, icoon, iso_data):
|
||||||
print(f"{icoon} **{titel}**")
|
if not iso_data or not iso_data.get("features"):
|
||||||
if 'features' in iso_data and iso_data['features']:
|
return f"{icoon} **{titel}**\nNiet beschikbaar (route-service reageerde niet).\n"
|
||||||
props = iso_data['features'][0]['properties']
|
props = iso_data["features"][0].get("properties", {})
|
||||||
area_m2 = props.get('area', 0)
|
lines = [f"{icoon} **{titel}**", f"**Oppervlakte:** {format_oppervlakte(props.get('area', 0))}", f"**Reach factor:** {props.get('reachfactor', 0):.3f}"]
|
||||||
reach = props.get('reachfactor', 0)
|
if len(iso_data.get("bbox", [])) == 4:
|
||||||
print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
|
b = iso_data["bbox"]
|
||||||
print(f"**Reach factor:** {reach:.3f}")
|
lines.append(f"**Bereik:** {b[1]:.3f}°–{b[3]:.3f} N, {b[0]:.3f}°–{b[2]:.3f} E")
|
||||||
bbox = iso_data.get('bbox', [])
|
return "\n".join(lines) + "\n"
|
||||||
if len(bbox) == 4:
|
|
||||||
print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E")
|
|
||||||
else:
|
def wgs84_naar_rd(lon, lat):
|
||||||
print("❌ Geen isochroon data ontvangen")
|
"""WGS84 naar RD New (EPSG:28992), voldoende nauwkeurig voor point-in-buurt-WFS."""
|
||||||
print()
|
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):
|
def genereer_rapport(adres_input):
|
||||||
"""Genereer volledig adres analyse rapport"""
|
nummer, waarschuwingen = hoogste_rapportnummer(), []
|
||||||
print(f"📍 **Adres Analyse Rapport**")
|
|
||||||
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
|
|
||||||
print()
|
|
||||||
|
|
||||||
print("🔍 Geocoding...")
|
|
||||||
locatie = geocode_adres(adres_input)
|
locatie = geocode_adres(adres_input)
|
||||||
print(f"**Adres:** {locatie['adres']}")
|
auto_iso = fiets_iso = None
|
||||||
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
|
for naam, profiel, minuten in [("auto", "driving-car", 20), ("fiets", "cycling-regular", 30)]:
|
||||||
print()
|
try:
|
||||||
|
data = haal_isochroon(locatie["lon"], locatie["lat"], profiel, minuten)
|
||||||
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
|
if naam == "auto": auto_iso = data
|
||||||
fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
|
else: fiets_iso = data
|
||||||
print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso)
|
except Exception as exc:
|
||||||
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso)
|
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"
|
if __name__ == "__main__":
|
||||||
with open(output_file, 'w') as f:
|
try: genereer_rapport(sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best")
|
||||||
json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
|
except Exception as exc:
|
||||||
print(f"💾 Isochroondata opgeslagen: `{output_file}`")
|
print(f"❌ Rapportgeneratie mislukt: {exc}", file=sys.stderr); raise
|
||||||
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)
|
|
||||||
|
|||||||
+149
-79
@@ -2,23 +2,31 @@
|
|||||||
"""
|
"""
|
||||||
Adres Analyse - Rapport generator
|
Adres Analyse - Rapport generator
|
||||||
Gebruikt PDOK voor geocoding, ORS voor isochronen/routes en PostGIS OSM-data voor voorzieningen.
|
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 json
|
||||||
import sys
|
import sys
|
||||||
|
import os
|
||||||
|
import re
|
||||||
import urllib.request
|
import urllib.request
|
||||||
import urllib.parse
|
import urllib.parse
|
||||||
import re
|
|
||||||
from datetime import datetime
|
from datetime import datetime
|
||||||
|
import base64
|
||||||
|
from io import BytesIO
|
||||||
|
|
||||||
|
try:
|
||||||
|
from PIL import Image
|
||||||
|
HAS_PIL = True
|
||||||
|
except ImportError:
|
||||||
|
HAS_PIL = False
|
||||||
|
|
||||||
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown
|
from voorzieningen import analyse_voorzieningen, format_voorzieningen_markdown
|
||||||
|
|
||||||
# Config
|
# Config
|
||||||
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
|
ORS_BASE = "http://192.168.1.71:9080/ors/v2"
|
||||||
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
|
PDOK_BASE = "https://api.pdok.nl/bzk/locatieserver/search/v3_1"
|
||||||
OBSIDIAN_BASE = "http://192.168.1.50:27123"
|
MKDOCS_DIR = "/root/mkdocs/docs/docs/Huizenjacht"
|
||||||
OBSIDIAN_TOKEN = "90ec" + "6de948dc8ef7832fda3516fd900c7a3071dd526edf852ade145aa9daf637"
|
|
||||||
|
|
||||||
def geocode_adres(adres):
|
def geocode_adres(adres):
|
||||||
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
|
"""Geocode adres naar coördinaten via PDOK Locatieserver"""
|
||||||
@@ -84,103 +92,165 @@ def format_oppervlakte(m2):
|
|||||||
else:
|
else:
|
||||||
return f"{m2:.0f} m²"
|
return f"{m2:.0f} m²"
|
||||||
|
|
||||||
def schrijf_naar_obsidian(rapport_text, adres):
|
def schrijf_naar_mkdocs(content, adres, nummer, ext=".md"):
|
||||||
"""Schrijf rapport naar Obsidian vault in map huizenjacht"""
|
"""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 = re.sub(r'[^a-zA-Z0-9]+', '-', adres)
|
||||||
safe_name = safe_name.strip('-')
|
safe_name = safe_name.strip('-')
|
||||||
filename = f"huizenjacht/{safe_name}_{datetime.now().strftime('%Y%m%d_%H%M%S')}.md"
|
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
|
||||||
|
|
||||||
req = urllib.request.Request(
|
def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso):
|
||||||
f"{OBSIDIAN_BASE}/vault/{filename}",
|
"""Genereer een zelf-contained HTML kaart met Leaflet"""
|
||||||
data=rapport_text.encode('utf-8'),
|
html = f'''<!DOCTYPE html>
|
||||||
headers={
|
<html lang="nl">
|
||||||
'Authorization': f'Bearer {OBSIDIAN_TOKEN}',
|
<head>
|
||||||
'Content-Type': 'text/markdown'
|
<meta charset="UTF-8">
|
||||||
},
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
method='POST'
|
<title>Isochroon - {adres_naam}</title>
|
||||||
)
|
<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; }}
|
||||||
|
.legend {{ background: white; padding: 10px; border-radius: 4px; box-shadow: 0 0 15px rgba(0,0,0,0.2); line-height: 1.5; }}
|
||||||
|
.legend i {{ width: 18px; height: 18px; float: left; margin-right: 8px; opacity: 0.7; border: 1px solid #333; }}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="map"></div>
|
||||||
|
<script>
|
||||||
|
var autoGeojson = {json.dumps(auto_iso, ensure_ascii=False)};
|
||||||
|
var fietsGeojson = {json.dumps(fiets_iso, ensure_ascii=False)};
|
||||||
|
|
||||||
with urllib.request.urlopen(req, timeout=10) as resp:
|
var map = L.map('map').setView([{lat}, {lon}], 11);
|
||||||
if resp.status in [200, 204]:
|
|
||||||
return f"📝 **Opgeslagen in Obsidian:** `huizenjacht/{safe_name}_*.md`"
|
|
||||||
else:
|
|
||||||
return f"⚠️ Obsidian upload mislukt: {resp.status}"
|
|
||||||
|
|
||||||
def print_isochroon_sectie(titel, icoon, iso_data, rapport_lines=None):
|
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
|
||||||
"""Print isochroon sectie; optioneel ook toevoegen aan rapport_lines voor Obsidian"""
|
attribution: '© <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
|
||||||
text = f"{icoon} **{titel}**"
|
}}).addTo(map);
|
||||||
print(text)
|
|
||||||
if rapport_lines is not None:
|
var autoLayer = L.geoJSON(autoGeojson, {{
|
||||||
rapport_lines.append(text)
|
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²<br>Reach factor: ' + props.reachfactor.toFixed(3));
|
||||||
|
}}
|
||||||
|
}}).addTo(map);
|
||||||
|
|
||||||
|
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²<br>Reach factor: ' + props.reachfactor.toFixed(3));
|
||||||
|
}}
|
||||||
|
}}).addTo(map);
|
||||||
|
|
||||||
|
L.marker([{lat}, {lon}]).addTo(map)
|
||||||
|
.bindPopup('<b>{adres_naam}</b>').openPopup();
|
||||||
|
|
||||||
|
var legend = L.control({{position: 'bottomright'}});
|
||||||
|
legend.onAdd = function(map) {{
|
||||||
|
var div = L.DomUtil.create('div', 'legend');
|
||||||
|
div.innerHTML += '<h4 style="margin:0 0 5px 0">Isochronen</h4>';
|
||||||
|
div.innerHTML += '<i style="background:#3b82f6"></i> Auto 20 min<br>';
|
||||||
|
div.innerHTML += '<i style="background:#22c55e"></i> Fiets 30 min<br>';
|
||||||
|
div.innerHTML += '<i style="background:#ef4444"></i> Adres';
|
||||||
|
return div;
|
||||||
|
}};
|
||||||
|
legend.addTo(map);
|
||||||
|
|
||||||
|
map.fitBounds(autoLayer.getBounds().extend(fietsLayer.getBounds()));
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
</html>'''
|
||||||
|
return html
|
||||||
|
|
||||||
|
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']:
|
if 'features' in iso_data and iso_data['features']:
|
||||||
props = iso_data['features'][0]['properties']
|
props = iso_data['features'][0]['properties']
|
||||||
area_m2 = props.get('area', 0)
|
area_m2 = props.get('area', 0)
|
||||||
reach = props.get('reachfactor', 0)
|
reach = props.get('reachfactor', 0)
|
||||||
text = f"**Oppervlakte:** {format_oppervlakte(area_m2)}"
|
lines.append(f"**Oppervlakte:** {format_oppervlakte(area_m2)}")
|
||||||
print(text)
|
lines.append(f"**Reach factor:** {reach:.3f}")
|
||||||
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)
|
|
||||||
bbox = iso_data.get('bbox', [])
|
bbox = iso_data.get('bbox', [])
|
||||||
if len(bbox) == 4:
|
if len(bbox) == 4:
|
||||||
text = f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E"
|
lines.append(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)
|
|
||||||
else:
|
else:
|
||||||
text = "❌ Geen isochroon data ontvangen"
|
lines.append("❌ Geen isochroon data ontvangen")
|
||||||
print(text)
|
lines.append("")
|
||||||
if rapport_lines is not None:
|
return "\n".join(lines)
|
||||||
rapport_lines.append(text)
|
|
||||||
print()
|
|
||||||
if rapport_lines is not None:
|
|
||||||
rapport_lines.append("")
|
|
||||||
|
|
||||||
def genereer_rapport(adres_input):
|
def genereer_rapport(adres_input):
|
||||||
"""Genereer volledig adres analyse rapport"""
|
"""Genereer volledig adres analyse rapport"""
|
||||||
rapport_lines = []
|
lines = []
|
||||||
|
|
||||||
def print_line(text=""):
|
# Tel bestaande genummerde rapporten voor oplopend nummer
|
||||||
print(text)
|
# Alleen bestanden die beginnen met cijfer-dash (bv. "1-Griendvelden...")
|
||||||
rapport_lines.append(text)
|
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)]
|
||||||
print_line(f"📍 **Adres Analyse Rapport**")
|
nummer = len(genummerde_files) + 1
|
||||||
print_line(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
|
|
||||||
print_line()
|
lines.append(f"# {nummer}. {adres_input}")
|
||||||
|
lines.append(f"*Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}*")
|
||||||
print_line("🔍 Geocoding...")
|
lines.append("")
|
||||||
|
|
||||||
|
lines.append("## 🔍 Geocoding")
|
||||||
locatie = geocode_adres(adres_input)
|
locatie = geocode_adres(adres_input)
|
||||||
print_line(f"**Adres:** {locatie['adres']}")
|
lines.append(f"**Adres:** {locatie['adres']}")
|
||||||
print_line(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
|
lines.append(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
|
||||||
print_line()
|
lines.append("")
|
||||||
|
|
||||||
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
|
auto_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'driving-car', 20)
|
||||||
fiets_iso = haal_isochroon(locatie['lon'], locatie['lat'], 'cycling-regular', 30)
|
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)
|
lines.append("## 🚗 Bereikbaarheid")
|
||||||
|
lines.append(print_isochroon_sectie("Auto (20 min)", "🚗", auto_iso))
|
||||||
print_line("🏙️ **Voorzieningen dichtbij**")
|
lines.append(print_isochroon_sectie("Fiets (30 min)", "🚲", fiets_iso))
|
||||||
|
|
||||||
|
lines.append("## 🏙️ Voorzieningen dichtbij")
|
||||||
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
|
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
|
||||||
print_line(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
|
lines.append(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
|
||||||
print_line()
|
lines.append("")
|
||||||
print_line(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
|
lines.append(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
|
||||||
print_line()
|
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_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"
|
output_file = f"/tmp/isochronen_{datetime.now().strftime('%Y%m%d_%H%M%S')}.geojson"
|
||||||
with open(output_file, 'w') as f:
|
with open(output_file, 'w') as f:
|
||||||
json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
|
json.dump({'auto_20_min': auto_iso, 'fiets_30_min': fiets_iso}, f)
|
||||||
print_line(f"💾 Isochroondata opgeslagen: `{output_file}`")
|
lines.append("---")
|
||||||
print_line()
|
lines.append("*Onderdelen: geocoding, isochronen, voorzieningen, interactieve kaart*")
|
||||||
print_line("---")
|
|
||||||
print_line("_Onderdelen: geocoding, isochronen, voorzieningen_")
|
rapport_text = "\n".join(lines)
|
||||||
|
|
||||||
# Schrijf naar Obsidian
|
# Print naar stdout
|
||||||
print_line()
|
print(rapport_text)
|
||||||
obsidian_result = schrijf_naar_obsidian('\n'.join(rapport_lines), locatie['adres'])
|
|
||||||
print_line(obsidian_result)
|
# 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__':
|
if __name__ == '__main__':
|
||||||
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
|
adres = sys.argv[1] if len(sys.argv) > 1 else "Griendvelden 14 Best"
|
||||||
|
|||||||
+91
-75
@@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env python3
|
#!/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 json
|
||||||
import math
|
import math
|
||||||
@@ -45,113 +45,129 @@ def _matrix(lon, lat, pois, profiel):
|
|||||||
"metrics": ["distance", "duration"],
|
"metrics": ["distance", "duration"],
|
||||||
}).encode()
|
}).encode()
|
||||||
req = urllib.request.Request(
|
req = urllib.request.Request(
|
||||||
f"{ORS_BASE}/matrix/{profiel}",
|
f"{ORS_BASE}/matrix/{profiel}", data=payload,
|
||||||
data=payload,
|
headers={"Content-Type": "application/json"}, method="POST",
|
||||||
headers={"Content-Type": "application/json"},
|
|
||||||
method="POST",
|
|
||||||
)
|
)
|
||||||
with urllib.request.urlopen(req, timeout=60) as resp:
|
with urllib.request.urlopen(req, timeout=60) as resp:
|
||||||
data = json.loads(resp.read())
|
data = json.loads(resp.read())
|
||||||
distances = data.get("distances", [[]])[0]
|
return list(zip(data.get("distances", [[]])[0], data.get("durations", [[]])[0]))
|
||||||
durations = data.get("durations", [[]])[0]
|
|
||||||
return list(zip(distances, durations))
|
|
||||||
|
|
||||||
|
|
||||||
def verrijk_met_routes(lon, lat, pois):
|
def verrijk_met_routes(lon, lat, pois):
|
||||||
fiets = _matrix(lon, lat, pois, "cycling-regular")
|
"""Verrijk POI's; een defect profiel maakt het hele rapport niet onbruikbaar."""
|
||||||
auto = _matrix(lon, lat, pois, "driving-car")
|
for profiel, afstand_attr, duur_attr in [
|
||||||
for i, poi in enumerate(pois):
|
("cycling-regular", "fiets_afstand_m", "fiets_duur_s"),
|
||||||
if i < len(fiets):
|
("driving-car", "auto_afstand_m", "auto_duur_s"),
|
||||||
poi.fiets_afstand_m, poi.fiets_duur_s = fiets[i]
|
]:
|
||||||
if i < len(auto):
|
try:
|
||||||
poi.auto_afstand_m, poi.auto_duur_s = auto[i]
|
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
|
return pois
|
||||||
|
|
||||||
|
|
||||||
def zoek_voorzieningen(lon, lat, categorie, limit=5, radius_m=10000):
|
def _haversine_m(lon1, lat1, lon2, lat2):
|
||||||
if categorie == "supermarkt":
|
radius = 6_371_000
|
||||||
view = "poi_supermarkten"
|
phi1, phi2 = math.radians(lat1), math.radians(lat2)
|
||||||
elif categorie == "school":
|
dphi, dlambda = math.radians(lat2 - lat1), math.radians(lon2 - lon1)
|
||||||
view = "poi_scholen"
|
a = math.sin(dphi / 2) ** 2 + math.cos(phi1) * math.cos(phi2) * math.sin(dlambda / 2) ** 2
|
||||||
else:
|
return 2 * radius * math.asin(math.sqrt(a))
|
||||||
raise ValueError(f"Onbekende categorie: {categorie}")
|
|
||||||
|
|
||||||
|
|
||||||
|
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"""
|
sql = f"""
|
||||||
WITH origin AS (
|
WITH origin AS (SELECT ST_SetSRID(ST_MakePoint(%s, %s), 4326)::geography AS geog)
|
||||||
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,
|
||||||
)
|
|
||||||
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
|
ST_Distance(geom::geography, origin.geog) AS hemelsbreed_m
|
||||||
FROM {view}, origin
|
FROM {views[categorie]}, origin
|
||||||
WHERE geom IS NOT NULL
|
WHERE geom IS NOT NULL AND ST_DWithin(geom::geography, origin.geog, %s)
|
||||||
AND ST_DWithin(geom::geography, origin.geog, %s)
|
|
||||||
ORDER BY lower(name), hemelsbreed_m ASC
|
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 psycopg2.connect(DB_DSN) as conn:
|
||||||
with conn.cursor(cursor_factory=psycopg2.extras.DictCursor) as cur:
|
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()
|
rows = cur.fetchall()
|
||||||
return [
|
return [Voorziening(categorie, row["name"], float(row["lat"]), float(row["lon"]), float(row["hemelsbreed_m"])) for row in rows]
|
||||||
Voorziening(
|
|
||||||
categorie=categorie,
|
|
||||||
naam=row["name"],
|
def zoek_osm_voorzieningen(lon, lat, categorie, tags, limit=5, radius_m=10000):
|
||||||
lat=float(row["lat"]),
|
"""Vraag kleinschalige gezins-POI's live op via Overpass, met een vaste fallback-endpoint."""
|
||||||
lon=float(row["lon"]),
|
south, west = lat - radius_m / 111_320, lon - radius_m / (111_320 * math.cos(math.radians(lat)))
|
||||||
hemelsbreed_m=float(row["hemelsbreed_m"]),
|
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())
|
||||||
for row in rows
|
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):
|
def analyse_voorzieningen(lon, lat, limit=5):
|
||||||
supermarkten = zoek_voorzieningen(lon, lat, "supermarkt", limit=limit, radius_m=10000)
|
result = {
|
||||||
scholen = zoek_voorzieningen(lon, lat, "school", limit=limit, radius_m=15000)
|
"supermarkten": zoek_voorzieningen(lon, lat, "supermarkt", limit, 10000),
|
||||||
verrijk_met_routes(lon, lat, supermarkten)
|
"scholen": zoek_voorzieningen(lon, lat, "school", limit, 15000),
|
||||||
verrijk_met_routes(lon, lat, scholen)
|
}
|
||||||
return {"supermarkten": supermarkten, "scholen": scholen}
|
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):
|
def format_afstand(m):
|
||||||
if m is None:
|
if m is None:
|
||||||
return "n.b."
|
return "n.b."
|
||||||
if m < 1000:
|
return f"{m:.0f} m" if m < 1000 else f"{m/1000:.1f} km".replace(".", ",")
|
||||||
return f"{m:.0f} m"
|
|
||||||
return f"{m/1000:.1f} km".replace(".", ",")
|
|
||||||
|
|
||||||
|
|
||||||
def format_duur(s):
|
def format_duur(s):
|
||||||
if s is None:
|
if s is None:
|
||||||
return "n.b."
|
return "n.b."
|
||||||
minutes = max(1, round(s / 60))
|
return f"{max(1, round(s / 60))} min"
|
||||||
return f"{minutes} min"
|
|
||||||
|
|
||||||
|
|
||||||
def format_voorzieningen_markdown(titel, pois):
|
def format_voorzieningen_markdown(titel, pois):
|
||||||
lines = [titel]
|
lines = [titel]
|
||||||
if not pois:
|
if not pois:
|
||||||
lines.append("Geen voorzieningen gevonden in zoekradius.")
|
return "\n".join(lines + ["Geen voorzieningen gevonden of bron tijdelijk niet beschikbaar."])
|
||||||
return "\n".join(lines)
|
for p in pois:
|
||||||
for i, p in enumerate(pois, 1):
|
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)}")
|
||||||
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)
|
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))
|
|
||||||
|
|||||||
Reference in New Issue
Block a user