From 6d7709e0087d45443cf8cf2fc2217dcd7173c0c3 Mon Sep 17 00:00:00 2001 From: bramval Date: Fri, 21 Aug 2026 15:04:46 +0200 Subject: [PATCH] sync: 2026-08-21 15:04:46 --- README.md | 84 +++++++ app.py | 67 ++++++ templates/index.html | 515 +++++++++++++++++++++++++++++++++++-------- 3 files changed, 579 insertions(+), 87 deletions(-) create mode 100644 README.md diff --git a/README.md b/README.md new file mode 100644 index 0000000..6c9a874 --- /dev/null +++ b/README.md @@ -0,0 +1,84 @@ +# LibreMaps + +A lightweight, self-hosted map viewer built with **Flask** and **Leaflet**, powered entirely by free open data from **OpenStreetMap** (via the Nominatim geocoding API) and **MapTiler** street tiles. + +## Features + +- **Interactive map** — pan/zoom over MapTiler "Streets" tiles, centered on Amsterdam by default. +- **Search** — type a place or POI to search. Results are first restricted to the area currently visible on the map; if nothing is found locally, it automatically falls back to searching the entire world. +- **Category shortcuts** — quick chips for common places (Supermarkt, Café, Kroeg, Camping, Park, Restaurant, Vegan, Hotel, Parkeerplek). +- **Typed pins** — each result is drawn as a colored circular badge whose emoji and color reflect the POI type (e.g. 🍴 orange for food, 🛏 purple for hotels, 🚆 blue for transport, 💊 red for pharmacies, 🌳 green for parks). A small permanent label shows the location name above each pin. +- **Place details card** — click a result or a pin to see the name, type, address, opening hours, phone, website, email, and extra tags (cuisine, brand, wheelchair access, payment methods, etc.). +- **Actions** — get directions (Google Maps), web search (Qwant), call, visit website, or email directly from the detail card. +- **Reverse geocoding** — click anywhere on the map (when zoomed in enough) to look up what's at that spot. +- **"My location"** button using browser geolocation. +- **Responsive layout** — adapts to mobile screens. + +## Tech Stack + +| Layer | Technology | +|-----------|-------------------------------------| +| Backend | Python 3, Flask | +| Frontend | Vanilla JS, Leaflet 1.9.4 | +| Geocoding | OpenStreetMap Nominatim | +| Tiles | MapTiler Streets v4 | + +## Project Structure + +``` +openmaps/ +├── app.py # Flask app: /, /search, /geocode routes +├── templates/ +│ └── index.html # Single-page UI (map + all client logic) +├── sync.py # Sync helper script +├── requirements.txt # Python dependencies +├── Dockerfile # Container image definition +├── docker-compose.yml # Local container orchestration +├── .dockerignore +└── .gitignore +``` + +## How It Works + +### Backend (`app.py`) + +Three endpoints proxy requests to Nominatim while applying a simple rate limit (minimum 1 second between calls) and a descriptive `User-Agent`: + +- `GET /` — serves the single-page UI. +- `GET /search?q=...&viewbox=left,top,right,bottom` — forwards a forward-geocoding query. When a valid viewbox is supplied, results are bounded to that region (`bounded=1`). Returns normalized JSON (coordinates, address, extratags, namedetails, importance, bounding box). +- `GET /geocode?lat=...&lon=...` — reverse-geocodes a coordinate into a place record. + +### Frontend (`templates/index.html`) + +- Initializes a Leaflet map with MapTiler tiles. +- Builds a viewbox from the current map bounds and sends it with each search so results stay local. +- Renders results in a scrollable card and drops typed pins on the map. +- Falls back to a worldwide search when the local search returns nothing. +- Shows a detail card with contact info, opening hours, and action buttons. + +## Getting Started + +### Prerequisites + +- Python 3.x +- (Optional) Docker / Docker Compose + +### Run locally + +```bash +pip install -r requirements.txt +python app.py +``` + +Then open . + +### Run with Docker + +```bash +docker compose up --build +``` + +## Notes + +- The MapTiler tile layer uses an embedded API key in `templates/index.html`; replace it with your own key for production use. +- Nominatim has usage policies — keep the built-in rate limiting in place and identify your application via the `User-Agent`. \ No newline at end of file diff --git a/app.py b/app.py index 82a063b..0b79d86 100644 --- a/app.py +++ b/app.py @@ -17,6 +17,70 @@ 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 @@ -124,6 +188,9 @@ def search(): "category": item.get("category"), + "kind": + classify_place(item), + "address": item.get("address", {}), diff --git a/templates/index.html b/templates/index.html index 0859676..a94f3f7 100644 --- a/templates/index.html +++ b/templates/index.html @@ -634,6 +634,89 @@ font-size: 10px; } + /* Permanent name label shown above each pin */ + .pin-label { + background: rgba(255, 255, 255, 0.92); + border: none; + border-radius: 4px; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); + color: #202124; + font-size: 12px; + font-weight: 500; + padding: 2px 7px; + white-space: nowrap; + } + + .pin-label::before { + display: none; + } + + /* Custom circular pin that reflects the POI type */ + .poi-pin { + background: transparent; + border: none; + } + + .poi-pin-inner { + width: 34px; + height: 34px; + + border-radius: 50%; + + display: flex; + align-items: center; + justify-content: center; + + font-size: 17px; + line-height: 1; + + color: white; + + border: 2px solid white; + + box-shadow: + 0 2px 6px rgba(0, 0, 0, 0.35); + } + + + /* User location marker (blue dot) */ + .user-loc-marker { + background: transparent; + border: none; + } + + .user-loc-dot { + position: relative; + width: 18px; + height: 18px; + + border-radius: 50%; + + background: #1a73e8; + + border: 3px solid white; + + box-shadow: + 0 0 0 2px rgba(26, 115, 232, 0.35), + 0 2px 6px rgba(0, 0, 0, 0.4); + } + + .user-loc-dot::after { + content: ""; + + position: absolute; + inset: -10px; + + border-radius: 50%; + + background: + radial-gradient( + circle, + rgba(26, 115, 232, 0.25) 0%, + rgba(26, 115, 232, 0) 70% + ); + } + /* ===================================================== MOBILE @@ -931,6 +1014,8 @@ let selectedMarker = null; + let userLocationMarker = null; + let zoomMessageTimer = null; @@ -977,55 +1062,55 @@ ].join(","); - try { + let data = + await performSearch( + query, + viewbox + ); - const response = - await fetch( - "/search?q=" + - encodeURIComponent( - query + ", " - ) + - "&viewbox=" + - encodeURIComponent(viewbox) + + /* + * If nothing was found in the visible area, + * fall back to searching the entire world. + */ + if (!data.length) { + + data = + await performSearch( + query, + null ); + } - const data = - await response.json(); + if ( + data === null + ) { - - if ( - !response.ok || - data.error - ) { - - resultsCard.innerHTML = ` -
-
- ${escapeHtml( - data.error || - "Search failed" - )} -
+ resultsCard.innerHTML = ` +
+
+ Search failed
- `; +
+ `; - return; - } + return; + } - if (!data.length) { + if (!data.length) { - resultsCard.innerHTML = ` -
-
- No results found -
+ resultsCard.innerHTML = ` +
+
+ No results found
- `; +
+ `; - return; - } + return; + } resultsCard.innerHTML = ""; @@ -1101,11 +1186,19 @@ L.marker([ place.lat, place.lon - ]).addTo(map); + ], { + icon: createPoiIcon(place) + }).addTo(map); marker.bindTooltip( - getName(place) + escapeHtml(getName(place)), + { + permanent: true, + direction: "top", + offset: [0, -20], + className: "pin-label" + } ); @@ -1136,9 +1229,11 @@ if (bounds.length === 1) { + const zoom = getZoomForPlace(data[0]); + map.flyTo( bounds[0], - 17, + zoom, { duration: 0.7 } @@ -1158,19 +1253,53 @@ ); } - } - catch (error) { + } - console.error(error); - resultsCard.innerHTML = ` -
-
- Search failed -
-
- `; + /* ========================================================= + PERFORM SEARCH + ========================================================= */ + + async function performSearch( + query, + viewbox + ) { + + let url = + "/search?q=" + + encodeURIComponent( + query + ", " + ); + + + if (viewbox) { + + url += + "&viewbox=" + + encodeURIComponent( + viewbox + ); } + + + const response = + await fetch(url); + + + const data = + await response.json(); + + + if ( + !response.ok || + data.error + ) { + + return null; + } + + + return data; } @@ -1178,6 +1307,51 @@ SELECT PLACE ========================================================= */ + /* + * Determine an appropriate zoom level based on the + * kind and type of the selected place. + */ + function getZoomForPlace(place) { + + const kind = place.kind || "other"; + const type = (place.type || "").toLowerCase(); + + // POIs: zoom in close to see the building/spot. + if (kind === "poi") { + return 17; + } + + // Addresses: street-level view. + if (kind === "address") { + return 18; + } + + // Areas: vary by administrative level. + if (kind === "area") { + if (type === "country" || type === "state" || type === "region") { + return 6; + } + if (type === "county" || type === "municipality") { + return 9; + } + if (type === "city" || type === "town") { + return 13; + } + if (type === "village" || type === "hamlet") { + return 14; + } + if (type === "suburb" || type === "neighbourhood" || type === "quarter") { + return 15; + } + // Water bodies, natural features, etc. + return 12; + } + + // Default fallback. + return 15; + } + + function selectPlace(place) { placeCard.style.display = @@ -1202,12 +1376,15 @@ ]).addTo(map); + const zoom = getZoomForPlace(place); + + map.flyTo( [ place.lat, place.lon ], - 17, + zoom, { duration: 0.7 } @@ -2006,51 +2183,125 @@ LOCATION BUTTON ========================================================= */ + function showUserLocation(lat, lon) { + + const coords = [lat, lon]; + + + if (!userLocationMarker) { + + userLocationMarker = + L.marker(coords, { + icon: createUserLocationIcon(), + zIndexOffset: 1000 + }).addTo(map); + + } else { + + userLocationMarker.setLatLng(coords); + } + + + map.flyTo( + coords, + 16, + { + duration: 0.8 + } + ); + } + + + function locateMe() { + + if ( + !navigator.geolocation + ) { + + alert( + "Geolocation is not supported." + ); + + return; + } + + + navigator.geolocation + .getCurrentPosition( + position => { + + showUserLocation( + position.coords.latitude, + position.coords.longitude + ); + + }, + + error => { + + console.warn( + "Geolocation failed:", + error + ); + + alert( + "Could not determine your location." + ); + } + ); + } + + document .getElementById( "location-button" ) .addEventListener( "click", - () => { - - if ( - !navigator.geolocation - ) { - - alert( - "Geolocation is not supported." - ); - - return; - } - - - navigator.geolocation - .getCurrentPosition( - position => { - - map.flyTo( - [ - position.coords.latitude, - position.coords.longitude - ], - 16 - ); - - }, - - () => { - - alert( - "Could not determine your location." - ); - } - ); - } + locateMe ); + /* + * On launch, focus the map around the viewer's device + * location and drop a pin there. If the location cannot + * be determined, fall back to Amsterdam. + */ + window.addEventListener( + "load", + () => { + + if ( + !navigator.geolocation + ) { + + return; + } + + + navigator.geolocation + .getCurrentPosition( + position => { + + showUserLocation( + position.coords.latitude, + position.coords.longitude + ); + + }, + + () => { + + /* + * Location unavailable — keep the + * default Amsterdam view. + */ + } + ); + } + ); + + /* ========================================================= ZOOM MESSAGE ========================================================= */ @@ -2346,6 +2597,96 @@ } + function getPoiColor(place) { + + const type = + getType(place) + .toLowerCase(); + + + if ( + type.includes("restaurant") || + type.includes("cafe") || + type.includes("food") + ) + return "#e8710a"; + + + if ( + type.includes("hotel") || + type.includes("hostel") + ) + return "#7b1fa2"; + + + if ( + type.includes("museum") + ) + return "#5c6bc0"; + + + if ( + type.includes("shop") || + type.includes("supermarket") + ) + return "#43a047"; + + + if ( + type.includes("station") || + type.includes("transport") + ) + return "#0288d1"; + + + if ( + type.includes("pharmacy") + ) + return "#e53935"; + + + if ( + type.includes("park") + ) + return "#2e7d32"; + + + return "#1a73e8"; + } + + + function createPoiIcon(place) { + + const html = ` +
+ ${getIcon(place)} +
+ `; + + + return L.divIcon({ + className: "poi-pin", + html: html, + iconSize: [34, 34], + iconAnchor: [17, 17] + }); + } + + + function createUserLocationIcon() { + + return L.divIcon({ + className: "user-loc-marker", + html: `
`, + iconSize: [18, 18], + iconAnchor: [9, 9] + }); + } + + function safeUrl(url) { if (!url)