307 lines
6.9 KiB
Python
307 lines
6.9 KiB
Python
from flask import Flask, render_template, request, jsonify
|
|
import requests
|
|
import time
|
|
|
|
app = Flask(__name__)
|
|
|
|
NOMINATIM_SEARCH_URL = "https://nominatim.openstreetmap.org/search"
|
|
NOMINATIM_REVERSE_URL = "https://nominatim.openstreetmap.org/reverse"
|
|
|
|
# Nominatim asks clients to identify themselves.
|
|
HEADERS = {
|
|
"User-Agent": "LibreMapViewer/1.0 (local Flask application)"
|
|
}
|
|
|
|
# Very simple rate limiting so we don't hammer Nominatim.
|
|
last_request_time = 0
|
|
MIN_REQUEST_INTERVAL = 1.0
|
|
|
|
|
|
def classify_place(item):
|
|
"""
|
|
Classify a Nominatim result into one of:
|
|
- "area" : countries, states, cities, towns, villages, suburbs,
|
|
neighbourhoods, water bodies, natural features, etc.
|
|
- "poi" : shops, restaurants, hotels, buildings, attractions, ...
|
|
- "address" : street / house-number level results
|
|
- "other" : anything else
|
|
|
|
The client uses this to pick an appropriate zoom level when the
|
|
user selects a result.
|
|
"""
|
|
category = (item.get("category") or "").lower()
|
|
place_type = (item.get("type") or "").lower()
|
|
address = item.get("address") or {}
|
|
namedetails = item.get("namedetails") or {}
|
|
|
|
# Administrative boundaries are always areas.
|
|
if place_type.endswith("_boundary"):
|
|
return "area"
|
|
|
|
# Place categories (city, town, village, suburb, ...) are areas.
|
|
if category == "place":
|
|
return "area"
|
|
|
|
# Boundaries are areas.
|
|
if category == "boundary":
|
|
return "area"
|
|
|
|
# Natural & water features are treated as areas (they span regions).
|
|
if category in ("natural", "water", "waterway"):
|
|
return "area"
|
|
|
|
# A real street/house-number result is an address.
|
|
if (
|
|
address.get("house_number")
|
|
or address.get("road")
|
|
):
|
|
if (
|
|
namedetails.get("name")
|
|
or namedetails.get("addr:housenumber")
|
|
or address.get("house_number")
|
|
):
|
|
return "address"
|
|
|
|
# Known POI categories.
|
|
poi_categories = {
|
|
"amenity",
|
|
"shop",
|
|
"tourism",
|
|
"leisure",
|
|
"office",
|
|
"craft",
|
|
"building",
|
|
"historic",
|
|
"industrial",
|
|
"public_transport",
|
|
}
|
|
if category in poi_categories:
|
|
return "poi"
|
|
|
|
return "other"
|
|
|
|
|
|
def nominatim_request(url, params):
|
|
"""
|
|
Make a request to Nominatim while respecting a small
|
|
delay between requests.
|
|
"""
|
|
global last_request_time
|
|
|
|
elapsed = time.time() - last_request_time
|
|
|
|
if elapsed < MIN_REQUEST_INTERVAL:
|
|
time.sleep(MIN_REQUEST_INTERVAL - elapsed)
|
|
|
|
response = requests.get(
|
|
url,
|
|
params=params,
|
|
headers=HEADERS,
|
|
timeout=15
|
|
)
|
|
|
|
last_request_time = time.time()
|
|
|
|
response.raise_for_status()
|
|
|
|
return response.json()
|
|
|
|
|
|
@app.route("/")
|
|
def index():
|
|
return render_template("index.html")
|
|
|
|
|
|
@app.route("/search")
|
|
def search():
|
|
query = request.args.get("q", "").strip()
|
|
|
|
if not query:
|
|
return jsonify({
|
|
"error": "Please enter a location"
|
|
}), 400
|
|
|
|
params = {
|
|
"q": query,
|
|
"format": "jsonv2",
|
|
"limit": 20,
|
|
"addressdetails": 1,
|
|
"extratags": 1,
|
|
"namedetails": 1,
|
|
"dedupe": 1,
|
|
}
|
|
|
|
# Restrict results to the area currently visible on the map.
|
|
# Expected format: "left,top,right,bottom" (lon,lat pairs).
|
|
viewbox = request.args.get("viewbox", "").strip()
|
|
|
|
if viewbox:
|
|
parts = [p.strip() for p in viewbox.split(",")]
|
|
|
|
if len(parts) == 4:
|
|
try:
|
|
left, top, right, bottom = (
|
|
float(p) for p in parts
|
|
)
|
|
|
|
params["viewbox"] = "{},{},{},{}".format(
|
|
left, top, right, bottom
|
|
)
|
|
params["bounded"] = 1
|
|
|
|
except ValueError:
|
|
pass
|
|
|
|
try:
|
|
results = nominatim_request(
|
|
NOMINATIM_SEARCH_URL,
|
|
params
|
|
)
|
|
|
|
except requests.RequestException as e:
|
|
return jsonify({
|
|
"error": f"Search failed: {e}"
|
|
}), 500
|
|
|
|
normalized = []
|
|
|
|
for item in results:
|
|
|
|
normalized.append({
|
|
"place_id": item.get("place_id"),
|
|
|
|
"osm_type": item.get("osm_type"),
|
|
"osm_id": item.get("osm_id"),
|
|
|
|
"display_name":
|
|
item.get("display_name", ""),
|
|
|
|
"lat":
|
|
float(item["lat"]),
|
|
|
|
"lon":
|
|
float(item["lon"]),
|
|
|
|
"type":
|
|
item.get("type"),
|
|
|
|
"category":
|
|
item.get("category"),
|
|
|
|
"kind":
|
|
classify_place(item),
|
|
|
|
"address":
|
|
item.get("address", {}),
|
|
|
|
"extratags":
|
|
item.get("extratags", {}),
|
|
|
|
"namedetails":
|
|
item.get("namedetails", {}),
|
|
|
|
"importance":
|
|
item.get("importance", 0),
|
|
|
|
"boundingbox":
|
|
item.get("boundingbox"),
|
|
})
|
|
|
|
return jsonify(normalized)
|
|
|
|
|
|
@app.route("/geocode")
|
|
def geocode():
|
|
|
|
lat = request.args.get("lat")
|
|
lon = request.args.get("lon")
|
|
|
|
if not lat or not lon:
|
|
return jsonify({
|
|
"error": "Missing latitude or longitude"
|
|
}), 400
|
|
|
|
try:
|
|
lat = float(lat)
|
|
lon = float(lon)
|
|
except ValueError:
|
|
return jsonify({
|
|
"error": "Invalid latitude or longitude"
|
|
}), 400
|
|
|
|
params = {
|
|
"lat": lat,
|
|
"lon": lon,
|
|
|
|
"format": "jsonv2",
|
|
|
|
"zoom": 18,
|
|
|
|
"addressdetails": 1,
|
|
|
|
"extratags": 1,
|
|
|
|
"namedetails": 1,
|
|
}
|
|
|
|
try:
|
|
|
|
result = nominatim_request(
|
|
NOMINATIM_REVERSE_URL,
|
|
params
|
|
)
|
|
|
|
except requests.RequestException as e:
|
|
|
|
return jsonify({
|
|
"error": f"Reverse geocoding failed: {e}"
|
|
}), 500
|
|
|
|
if not result:
|
|
|
|
return jsonify({
|
|
"display_name": None
|
|
})
|
|
|
|
return jsonify({
|
|
|
|
"place_id":
|
|
result.get("place_id"),
|
|
|
|
"osm_type":
|
|
result.get("osm_type"),
|
|
|
|
"osm_id":
|
|
result.get("osm_id"),
|
|
|
|
"display_name":
|
|
result.get("display_name", ""),
|
|
|
|
"lat":
|
|
float(result.get("lat", lat)),
|
|
|
|
"lon":
|
|
float(result.get("lon", lon)),
|
|
|
|
"type":
|
|
result.get("type"),
|
|
|
|
"category":
|
|
result.get("category"),
|
|
|
|
"address":
|
|
result.get("address", {}),
|
|
|
|
"extratags":
|
|
result.get("extratags", {}),
|
|
|
|
"namedetails":
|
|
result.get("namedetails", {}),
|
|
})
|
|
|
|
|
|
if __name__ == "__main__":
|
|
app.run(
|
|
port=5000,
|
|
debug=True
|
|
) |