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
This commit is contained in:
Hermes Agent
2026-06-22 11:33:24 +02:00
parent 5c6fc8c277
commit d7ffa382a7
+229 -26
View File
@@ -2,19 +2,37 @@
""" """
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).
""" """
import json import json
import sys import sys
import urllib.request import urllib.request
import urllib.parse import urllib.parse
import re
import base64
from datetime import datetime from datetime import datetime
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
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"
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"""
@@ -80,52 +98,237 @@ def format_oppervlakte(m2):
else: else:
return f"{m2:.0f} m²" return f"{m2:.0f} m²"
def print_isochroon_sectie(titel, icoon, iso_data): def schrijf_naar_obsidian(rapport_text, adres, subpath="huizenjacht", ext=".md"):
print(f"{icoon} **{titel}**") """Schrijf rapport naar Obsidian vault"""
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
def genereer_isochroon_html(adres_naam, lat, lon, auto_iso, fiets_iso):
"""Genereer een zelf-contained HTML kaart met Leaflet"""
html = f'''<!DOCTYPE html>
<html lang="nl">
<head>
<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>
<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)};
var map = L.map('map').setView([{lat}, {lon}], 11);
L.tileLayer('https://{{s}}.tile.openstreetmap.org/{{z}}/{{x}}/{{y}}.png', {{
attribution: '&copy; <a href="https://www.openstreetmap.org/copyright">OpenStreetMap</a> contributors'
}}).addTo(map);
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));
}}
}}).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\u00b2<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 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)
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)
print(f"**Oppervlakte:** {format_oppervlakte(area_m2)}") text = f"**Oppervlakte:** {format_oppervlakte(area_m2)}"
print(f"**Reach factor:** {reach:.3f}") 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)
bbox = iso_data.get('bbox', []) bbox = iso_data.get('bbox', [])
if len(bbox) == 4: if len(bbox) == 4:
print(f"**Bereik:** {bbox[1]:.3f}°-{bbox[3]:.3f}°N, {bbox[0]:.3f}°-{bbox[2]:.3f}°E") 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)
else: else:
print("❌ Geen isochroon data ontvangen") text = "❌ Geen isochroon data ontvangen"
print(text)
if rapport_lines is not None:
rapport_lines.append(text)
print() 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"""
print(f"📍 **Adres Analyse Rapport**") rapport_lines = []
print(f"Gegenereerd: {datetime.now().strftime('%d-%m-%Y %H:%M')}")
print() def print_line(text=""):
print(text)
print("🔍 Geocoding...") 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...")
locatie = geocode_adres(adres_input) locatie = geocode_adres(adres_input)
print(f"**Adres:** {locatie['adres']}") print_line(f"**Adres:** {locatie['adres']}")
print(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}") print_line(f"**Coördinaten:** {locatie['lat']:.6f}, {locatie['lon']:.6f}")
print() print_line()
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) print_isochroon_sectie("Bereikbaarheid (Auto 20 min)", "🚗", auto_iso, rapport_lines)
print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso) print_isochroon_sectie("Bereikbaarheid (Fiets 30 min)", "🚲", fiets_iso, rapport_lines)
print("🏙️ **Voorzieningen dichtbij**") print_line("🏙️ **Voorzieningen dichtbij**")
voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5) voorzieningen = analyse_voorzieningen(locatie['lon'], locatie['lat'], limit=5)
print(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten'])) print_line(format_voorzieningen_markdown("🛒 **Supermarkten**", voorzieningen['supermarkten']))
print() print_line()
print(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen'])) print_line(format_voorzieningen_markdown("🏫 **Scholen**", voorzieningen['scholen']))
print() print_line()
# Genereer interactieve HTML kaart en upload naar Obsidian
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)
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(f"💾 Isochroondata opgeslagen: `{output_file}`") print_line(f"💾 Ruwe isochroondata: `{output_file}`")
print() print_line()
print("---") print_line("---")
print("_Onderdelen: geocoding, isochronen, voorzieningen_") 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")
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"