sync: 2026-08-21 15:04:46
This commit is contained in:
84
README.md
Normal file
84
README.md
Normal 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
67
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", {}),
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user