sync: 2026-08-21 15:04:46

This commit is contained in:
2026-08-21 15:04:46 +02:00
parent 26d9a0dcf7
commit 6d7709e008
3 changed files with 579 additions and 87 deletions

84
README.md Normal file
View File

@@ -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 <http://localhost:5000>.
### 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`.

67
app.py
View File

@@ -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", {}),

View File

@@ -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 = `
<div class="result">
<div class="result-text">
${escapeHtml(
data.error ||
"Search failed"
)}
</div>
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
Search failed
</div>
`;
</div>
`;
return;
}
return;
}
if (!data.length) {
if (!data.length) {
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
No results found
</div>
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
No results found
</div>
`;
</div>
`;
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 = `
<div class="result">
<div class="result-text">
Search failed
</div>
</div>
`;
/* =========================================================
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 = `
<div
class="poi-pin-inner"
style="background:${getPoiColor(place)};"
>
${getIcon(place)}
</div>
`;
return L.divIcon({
className: "poi-pin",
html: html,
iconSize: [34, 34],
iconAnchor: [17, 17]
});
}
function createUserLocationIcon() {
return L.divIcon({
className: "user-loc-marker",
html: `<div class="user-loc-dot"></div>`,
iconSize: [18, 18],
iconAnchor: [9, 9]
});
}
function safeUrl(url) {
if (!url)