Files
LibMap/templates/index.html
2026-08-21 20:49:18 +02:00

2884 lines
58 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>LibreMaps</title>
<link
rel="stylesheet"
href="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.css"
>
<style>
* {
box-sizing: border-box;
}
html,
body {
margin: 0;
padding: 0;
width: 100%;
height: 100%;
overflow: hidden;
font-family: Arial, Helvetica, sans-serif;
}
#map {
position: fixed;
inset: 0;
z-index: 1;
}
/* =====================================================
BOTTOM SEARCH AREA
===================================================== */
.bottom-ui {
position: fixed;
left: 50%;
bottom: 18px;
z-index: 2000;
width: 410px;
transform: translateX(-50%);
pointer-events: none;
}
.search-box {
pointer-events: auto;
width: 100%;
height: 50px;
display: flex;
align-items: center;
background: white;
border-radius: 26px;
box-shadow:
0 2px 8px rgba(0, 0, 0, 0.28);
overflow: hidden;
}
.search-icon {
width: 48px;
flex-shrink: 0;
text-align: center;
font-size: 20px;
color: #5f6368;
}
#search-input {
flex: 1;
height: 100%;
border: none;
outline: none;
font-size: 15px;
color: #202124;
min-width: 0;
}
#search-input::placeholder {
color: #5f6368;
}
.search-clear {
width: 42px;
height: 42px;
border: none;
background: transparent;
cursor: pointer;
font-size: 22px;
color: #5f6368;
}
.search-clear:hover {
background: #f1f3f4;
}
.search-button {
width: 50px;
height: 50px;
border: none;
background: #1a73e8;
color: white;
cursor: pointer;
font-size: 18px;
}
.search-button:hover {
background: #1769d1;
}
/* =====================================================
CATEGORY CHIPS
===================================================== */
.chips {
display: flex;
gap: 8px;
margin-bottom: 9px;
overflow-x: auto;
pointer-events: auto;
scrollbar-width: none;
}
.chips::-webkit-scrollbar {
display: none;
}
.chip {
flex-shrink: 0;
height: 38px;
padding: 0 15px;
display: flex;
align-items: center;
gap: 7px;
background: white;
border: none;
border-radius: 20px;
box-shadow:
0 1px 5px rgba(0, 0, 0, 0.25);
cursor: pointer;
color: #202124;
font-size: 13px;
white-space: nowrap;
}
.chip:hover {
background: #f8f9fa;
}
.chip.active {
background: #e8f0fe;
color: #1967d2;
}
/* =====================================================
SEARCH RESULTS
===================================================== */
.results-card {
position: fixed;
z-index: 2100;
left: 10px;
bottom: 78px;
width: 410px;
max-height: 30vh;
overflow-y: auto;
display: none;
background: white;
border-radius: 10px;
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.30);
}
.results-header {
position: sticky;
top: 0;
display: flex;
align-items: center;
justify-content: space-between;
padding: 6px 10px 6px 14px;
background: white;
border-bottom:
1px solid #eee;
font-size: 12px;
color: #5f6368;
z-index: 1;
}
.results-minimize {
width: 26px;
height: 26px;
border: none;
border-radius: 50%;
background: transparent;
cursor: pointer;
font-size: 16px;
line-height: 1;
color: #5f6368;
}
.results-minimize:hover {
background: #f1f3f4;
}
.result {
display: flex;
gap: 13px;
padding: 14px;
border-bottom:
1px solid #eee;
cursor: pointer;
}
.result:hover {
background: #f8f9fa;
}
.result-icon {
width: 40px;
height: 40px;
flex-shrink: 0;
border-radius: 50%;
background: #f1f3f4;
display: flex;
align-items: center;
justify-content: center;
font-size: 19px;
}
.result-text {
min-width: 0;
}
.result-name {
font-size: 15px;
font-weight: 500;
color: #202124;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.result-address {
margin-top: 4px;
font-size: 13px;
color: #5f6368;
line-height: 1.35;
}
.result-type {
margin-top: 3px;
font-size: 12px;
color: #80868b;
}
/* =====================================================
PLACE CARD
===================================================== */
.place-card {
position: fixed;
z-index: 2000;
left: 10px;
bottom: 125px;
width: 410px;
max-height:
calc(100vh - 160px);
overflow-y: auto;
display: none;
background: white;
border-radius: 9px;
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.30);
}
.place-header {
padding: 18px 20px 16px;
border-bottom:
1px solid #eee;
}
.place-close {
float: right;
width: 32px;
height: 32px;
border: none;
border-radius: 50%;
background: #f1f3f4;
cursor: pointer;
font-size: 18px;
color: #5f6368;
}
.place-close:hover {
background: #e8eaed;
}
.place-title {
padding-right: 35px;
font-size: 15px;
line-height: 1.2;
font-weight: 500;
color: #202124;
}
.place-type {
margin-top: 7px;
font-size: 13px;
color: #5f6368;
}
.place-actions {
display: flex;
gap: 7px;
margin-top: 16px;
flex-wrap: wrap;
}
.place-action {
height: 38px;
padding: 0 13px;
border-radius: 20px;
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
text-decoration: none;
border: 1px solid #dadce0;
color: #1a73e8;
background: white;
font-size: 13px;
font-weight: 500;
cursor: pointer;
}
.place-action:hover {
background: #f8f9fa;
}
.place-action.primary {
background: #1a73e8;
color: white;
border-color: #1a73e8;
}
.place-action.web {
color: #202124;
}
.place-section {
padding: 4px 20px;
}
.place-detail {
display: flex;
gap: 14px;
padding: 14px 0;
border-bottom:
1px solid #eee;
font-size: 14px;
line-height: 1.4;
}
.detail-icon {
width: 20px;
flex-shrink: 0;
text-align: center;
}
.detail-content {
flex: 1;
min-width: 0;
color: #202124;
}
.detail-content a {
color: #1a73e8;
text-decoration: none;
word-break: break-word;
}
.detail-content a:hover {
text-decoration: underline;
}
.hours-status {
font-weight: 500;
color: #188038;
}
.hours-list {
margin-top: 8px;
display: none;
}
.hours-list.visible {
display: block;
}
.hours-row {
display: flex;
justify-content: space-between;
gap: 20px;
padding: 3px 0;
font-size: 13px;
color: #5f6368;
}
.hours-toggle {
margin-top: 5px;
padding: 0;
border: none;
background: transparent;
color: #1a73e8;
cursor: pointer;
font-size: 12px;
}
.tags {
display: flex;
flex-wrap: wrap;
gap: 6px;
padding: 12px 0 18px;
}
.tag {
background: #f1f3f4;
border-radius: 15px;
padding: 6px 10px;
font-size: 12px;
color: #5f6368;
}
/* =====================================================
ZOOM MESSAGE
===================================================== */
.zoom-message {
position: fixed;
z-index: 1500;
left: 50%;
top: 10px;
transform:
translateX(-50%);
background:
rgba(32, 33, 36, 0.9);
color: white;
padding: 10px 16px;
border-radius: 22px;
font-size: 13px;
opacity: 0;
pointer-events: none;
transition:
opacity 0.2s;
}
.zoom-message.visible {
opacity: 1;
}
/* =====================================================
LOCATION BUTTON
===================================================== */
.location-button {
width: 48px;
flex-shrink: 0;
height: 50px;
border: none;
background: transparent;
cursor: pointer;
font-size: 20px;
color: #5f6368;
}
.location-button:hover {
background: #f1f3f4;
}
/* =====================================================
MAPLIBRE CONTROLS
===================================================== */
.maplibregl-ctrl-group {
border: none !important;
box-shadow:
0 2px 7px rgba(0, 0, 0, 0.25) !important;
border-radius: 8px !important;
}
.maplibregl-ctrl-group button {
border: none !important;
}
.maplibregl-ctrl-attrib {
font-size: 10px;
}
/* Position nav controls away from the search UI */
.maplibregl-ctrl-topleft {
top: 10px;
left: 10px;
}
.maplibregl-ctrl-topright {
top: 10px;
right: 10px;
}
/* =====================================================
MARKER STYLES
===================================================== */
/*
* Simple coloured dot marker.
* Kept intentionally minimal so MapLibre's internal
* transform-based positioning stays stable during
* zoom / pan animations on vector-tile maps.
*/
.poi-dot {
width: 34px;
height: 34px;
border-radius: 50%;
border: 3px solid #fff;
box-shadow:
0 3px 10px rgba(0,0,0,0.35),
inset 0 -2px 4px rgba(0,0,0,0.15);
display: flex;
align-items: center;
justify-content: center;
font-size: 15px;
cursor: pointer;
/* No transform transitions — they fight MapLibre's
per-frame camera updates and cause wobble. */
will-change: transform;
}
.poi-dot:hover {
box-shadow:
0 5px 14px rgba(0,0,0,0.45),
inset 0 -2px 4px rgba(0,0,0,0.15);
}
.poi-dot.selected {
box-shadow:
0 0 0 5px rgba(26,115,232,0.30),
0 4px 12px rgba(0,0,0,0.45),
inset 0 -2px 4px rgba(0,0,0,0.15);
}
/* User location marker (blue pulsing dot) */
.user-loc-marker {
cursor: default;
}
@keyframes loc-pulse {
0% { transform: scale(1); opacity: 0.6; }
100% { transform: scale(2.8); opacity: 0; }
}
.user-loc-dot {
position: relative;
width: 16px;
height: 16px;
border-radius: 50%;
background: #1a73e8;
border: 3px solid white;
box-shadow:
0 0 0 2px rgba(26, 115, 232, 0.4),
0 2px 8px rgba(0, 0, 0, 0.35);
}
.user-loc-dot::before {
content: "";
position: absolute;
inset: -3px;
border-radius: 50%;
border: 2px solid rgba(26, 115, 232, 0.5);
animation: loc-pulse 2s ease-out infinite;
}
.user-loc-dot::after {
content: "";
position: absolute;
inset: -12px;
border-radius: 50%;
background:
radial-gradient(
circle,
rgba(26, 115, 232, 0.20) 0%,
rgba(26, 115, 232, 0) 70%
);
}
/* =====================================================
MOBILE
===================================================== */
@media (max-width: 700px) {
.bottom-ui {
left: 50%;
bottom: 8px;
width:
calc(100% - 16px);
transform: translateX(-50%);
}
.search-box {
width: 100%;
}
.results-card {
left: 8px;
bottom: 68px;
width:
calc(100% - 16px);
max-height: 50vh;
}
.place-card {
left: 8px;
right: 8px;
bottom: 118px;
width: auto;
max-height: 60vh;
}
}
</style>
</head>
<body>
<div id="map"></div>
<!-- =========================================================
SEARCH + CATEGORIES AT BOTTOM
========================================================= -->
<div class="bottom-ui">
<div class="chips">
<button
class="chip"
data-search="supermarket"
>
🛒 Supermarkt
</button>
<button
class="chip"
data-search="cafe"
>
☕ Café
</button>
<button
class="chip"
data-search="camping"
>
⛺ Camping
</button>
<button
class="chip"
data-search="park"
>
🌳 Park
</button>
<button
class="chip"
data-search="restaurant"
>
🍴 Restaurant
</button>
<button
class="chip"
data-search="vegan restaurant"
>
🥗 Vegan
</button>
<button
class="chip"
data-search="hotel"
>
🛏 Hotel
</button>
<button
class="chip"
data-search="parking"
>
🅿️ Parkeerplek
</button>
</div>
<div class="search-box">
<button
id="location-button"
class="location-button"
title="My location"
>
◎
</button>
<div class="search-icon">
🔍
</div>
<input
id="search-input"
type="text"
placeholder="Search"
autocomplete="off"
>
<button
id="search-clear"
class="search-clear"
title="Clear"
>
×
</button>
<button
id="search-button"
class="search-button"
title="Search"
>
➜
</button>
</div>
</div>
<!-- =========================================================
SEARCH RESULTS
========================================================= -->
<div
id="results-card"
class="results-card"
></div>
<!-- =========================================================
PLACE INFORMATION
========================================================= -->
<div
id="place-card"
class="place-card"
></div>
<!-- =========================================================
ZOOM MESSAGE
========================================================= -->
<div
id="zoom-message"
class="zoom-message"
>
Zoom in closer to select a location
</div>
<script src="https://unpkg.com/maplibre-gl@4/dist/maplibre-gl.js"></script>
<script>
/* =========================================================
SETTINGS
========================================================= */
const AMSTERDAM_LAT = 52.3676;
const AMSTERDAM_LON = 4.9041;
const DEFAULT_ZOOM = 13;
/*
* A map click only becomes a location selection when
* the map is sufficiently zoomed in.
*/
const CLICK_ZOOM = 15;
const MAPTILER_KEY = "aUftDXS9UHdRVbSuaUWz";
/* =========================================================
MAP
========================================================= */
const map = new maplibregl.Map({
container: "map",
style:
"https://api.maptiler.com/maps/streets-v4/style.json?key=" +
MAPTILER_KEY,
center: [AMSTERDAM_LON, AMSTERDAM_LAT],
zoom: DEFAULT_ZOOM
});
map.addControl(
new maplibregl.NavigationControl(),
"top-right"
);
/* =========================================================
ELEMENTS
========================================================= */
const searchInput =
document.getElementById(
"search-input"
);
const searchButton =
document.getElementById(
"search-button"
);
const searchClear =
document.getElementById(
"search-clear"
);
const resultsCard =
document.getElementById(
"results-card"
);
const placeCard =
document.getElementById(
"place-card"
);
const zoomMessage =
document.getElementById(
"zoom-message"
);
/** All search-result POI markers currently on the map. */
let poiMarkers = [];
/** The single marker representing the user's current selection (click or search). */
let selectedMarker = null;
/** Popup attached to the selected marker (reverse-geocode result). */
let selectedPopup = null;
/** Blue dot showing the user's GPS position. */
let userLocationMarker = null;
let zoomMessageTimer = null;
/* =========================================================
MARKER FACTORY
========================================================= */
/**
* Build a simple coloured dot element for a POI marker.
* Deliberately kept flat (no nested absolutely-positioned
* children, no SVG, no filters) so that MapLibre's per-frame
* transform updates stay rock-stable during zoom / pan on
* vector-tile maps.
*/
function createDotElement(color, icon) {
const el = document.createElement("div");
el.className = "poi-dot";
el.style.background = color;
el.textContent = icon || "";
el.title = "";
return el;
}
/**
* Plain red dot for map-click selections / fallbacks.
*/
function createDefaultDot() {
return createDotElement("#ea4335", "");
}
/* =========================================================
SEARCH
========================================================= */
async function searchPlace(query) {
query = query.trim();
if (!query)
return;
resultsCard.style.display =
"block";
placeCard.style.display =
"none";
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
Searching...
</div>
</div>
`;
clearMarkers();
// Build a viewbox from the currently visible map area so
// results are restricted to what the user can see.
//
// Only send a viewbox once the map has loaded AND the
// bounds are geometrically valid. Otherwise we fall back
// straight to a global search (see below).
const viewbox =
getValidViewbox();
let data =
await performSearch(
query,
viewbox
);
/*
* If nothing was found in the visible area,
* fall back to searching the entire world.
*/
if (!data.length) {
data =
await performSearch(
query,
null
);
}
if (
data === null
) {
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
Search failed
</div>
</div>
`;
return;
}
if (!data.length) {
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
No results found
</div>
</div>
`;
return;
}
resultsCard.innerHTML = `
<div class="results-header">
<span>Results</span>
<button
id="results-minimize"
class="results-minimize"
title="Minimize"
>
\u2212
</button>
</div>
`;
document
.getElementById(
"results-minimize"
)
.addEventListener(
"click",
() => {
resultsCard.style.display = "none";
}
);
const lngLats = [];
data.forEach(place => {
// Skip results with missing/invalid coordinates
if (
typeof place.lat !== "number" ||
typeof place.lon !== "number" ||
!isFinite(place.lat) ||
!isFinite(place.lon) ||
Math.abs(place.lat) > 90 ||
Math.abs(place.lon) > 180
)
return;
// --- Results list item ---
const result = document.createElement("div");
result.className = "result";
result.innerHTML = `
<div class="result-icon">${getIcon(place)}</div>
<div class="result-text">
<div class="result-name">${escapeHtml(getName(place))}</div>
<div class="result-address">${escapeHtml(getShortAddress(place))}</div>
<div class="result-type">${escapeHtml(formatType(getType(place)))}</div>
</div>
`;
result.addEventListener("click", () => {
selectPlace(place);
resultsCard.style.display = "none";
});
resultsCard.appendChild(result);
// --- Map marker (simple dot, centered on coordinate) ---
const dotEl = createDotElement(
getPoiColor(place),
getIcon(place)
);
dotEl.title = getName(place);
const marker = new maplibregl.Marker({
element: dotEl,
anchor: "center"
}).setLngLat([place.lon, place.lat]).addTo(map);
// Stop propagation so clicking a POI pin does NOT
// also trigger the map's reverse-geocode handler.
marker.getElement().addEventListener("click", e => {
e.stopPropagation();
highlightPoiMarker(marker);
selectPlace(place);
resultsCard.style.display = "none";
});
poiMarkers.push(marker);
lngLats.push([place.lon, place.lat]);
});
// Fly / fit to results
if (lngLats.length === 1) {
map.flyTo({
center: lngLats[0],
zoom: getZoomForPlace(data[0]),
duration: 700
});
} else {
const sw = [Math.min(...lngLats.map(p => p[0])), Math.min(...lngLats.map(p => p[1]))];
const ne = [Math.max(...lngLats.map(p => p[0])), Math.max(...lngLats.map(p => p[1]))];
map.fitBounds([sw, ne], { padding: 80 });
}
}
/* =========================================================
VIEWBOX HELPER
========================================================= */
/*
* How far beyond the visible map area we extend the
* search box, in kilometres. This keeps results from
* being cut off at the screen edges.
*/
const VIEWBOX_PADDING_KM = 5;
/*
* Convert a distance in metres to degrees of longitude
* at a given latitude.
*/
function metersToLonDegrees(meters, lat) {
const rad =
lat * Math.PI / 180;
return meters /
(111320 * Math.cos(rad));
}
/*
* Convert a distance in metres to degrees of latitude.
*/
function metersToLatDegrees(meters) {
return meters / 110574;
}
/*
* Return a "west,north,east,south" string for the
* currently visible map area, expanded by
* VIEWBOX_PADDING_KM on every side, or null if the map
* isn't ready yet or the bounds aren't usable. A null
* value makes the caller perform an unrestricted
* (global) search instead.
*/
function getValidViewbox() {
if (!map.loaded())
return null;
const mb = map.getBounds();
let west =
mb.getWest();
let north =
mb.getNorth();
let east =
mb.getEast();
let south =
mb.getSouth();
/*
* Reject NaN / infinite values and inverted boxes.
*/
if (
!isFinite(west) ||
!isFinite(north) ||
!isFinite(east) ||
!isFinite(south)
)
return null;
if (
east <= west ||
north <= south
)
return null;
/*
* Expand the box by the padding distance so that
* places just outside the viewport are still found.
*/
const padMeters =
VIEWBOX_PADDING_KM * 1000;
const midLat =
(north + south) / 2;
const dLat =
metersToLatDegrees(padMeters);
const dLon =
metersToLonDegrees(
padMeters,
midLat
);
west -= dLon;
east += dLon;
north += dLat;
south -= dLat;
/*
* Clamp to valid geographic ranges after expansion.
*/
north = Math.min(north, 90);
south = Math.max(south, -90);
west = Math.max(west, -180);
east = Math.min(east, 180);
return [
west,
north,
east,
south
].join(",");
}
/* =========================================================
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;
}
/* =========================================================
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 15;
}
// Addresses: street-level view.
if (kind === "address") {
return 15;
}
// 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;
}
/**
* Highlight a POI marker by adding the "selected" class.
* Called when the user clicks on an existing search-result pin.
*/
function highlightPoiMarker(marker) {
// Remove highlight from all other POI markers
poiMarkers.forEach(m => {
m.getElement().classList.remove("selected");
});
marker.getElement().classList.add("selected");
}
function selectPlace(place) {
placeCard.style.display = "block";
resultsCard.style.display = "none";
clearSelection();
// Coloured dot for known POIs, red default otherwise
const isKnownPoi = place.kind === "poi" || place.kind === "address";
const dotEl = isKnownPoi
? createDotElement(getPoiColor(place), getIcon(place))
: createDefaultDot();
dotEl.title = getName(place);
selectedMarker = new maplibregl.Marker({
element: dotEl,
anchor: "center"
}).setLngLat([place.lon, place.lat]).addTo(map);
const zoom = getZoomForPlace(place);
map.flyTo({
center: [place.lon, place.lat],
zoom: zoom,
duration: 700
});
renderPlace(place);
}
/* =========================================================
CLEAR SELECTION
========================================================= */
function clearSelection() {
if (selectedMarker) {
selectedMarker.remove();
selectedMarker =
null;
}
if (selectedPopup) {
selectedPopup.remove();
selectedPopup =
null;
}
}
/* =========================================================
MAP CLICK
========================================================= */
map.on(
"click",
async e => {
/*
* Don't reverse-geocode at low zoom.
*/
if (
map.getZoom() < CLICK_ZOOM
) {
showZoomMessage();
return;
}
const lat =
e.lngLat.lat;
const lon =
e.lngLat.lng;
resultsCard.style.display =
"none";
placeCard.style.display =
"block";
placeCard.innerHTML = `
<div class="place-header">
<div class="place-title">
Looking up location...
</div>
</div>
`;
clearSelection();
selectedMarker = new maplibregl.Marker({
element: createDefaultDot(),
anchor: "center"
}).setLngLat([lon, lat]).addTo(map);
try {
const response =
await fetch(
`/geocode?lat=${lat}&lon=${lon}`
);
const place =
await response.json();
if (
!response.ok ||
place.error
) {
throw new Error(
place.error ||
"Reverse geocoding failed"
);
}
if (
!place.display_name
) {
placeCard.innerHTML = `
<div class="place-header">
<div class="place-title">
No location found
</div>
</div>
`;
return;
}
selectedPopup =
new maplibregl.Popup({
offset: 25
})
.setHTML(
escapeHtml(
getName(place)
)
)
.setLngLat([
lon,
lat
])
.addTo(map);
renderPlace(place);
}
catch (error) {
console.error(error);
placeCard.innerHTML = `
<div class="place-header">
<div class="place-title">
Could not find this location
</div>
</div>
`;
}
}
);
/* =========================================================
RENDER PLACE
========================================================= */
function renderPlace(place) {
const name =
getName(place);
const type =
getType(place);
const address =
place.address || {};
const tags =
place.extratags || {};
const addressText =
buildAddress(address);
const phone =
tags.phone ||
tags["contact:phone"] ||
"";
const website =
tags.website ||
tags["contact:website"] ||
"";
const email =
tags.email ||
tags["contact:email"] ||
"";
const openingHours =
tags.opening_hours ||
"";
/*
* City for Qwant search.
*/
const city =
address.city ||
address.town ||
address.village ||
address.municipality ||
"Amsterdam";
/*
* Build the Qwant search.
*
* Example:
*
* NEMO Science Museum Amsterdam
*/
const webSearchQuery =
`${name} ${city}`;
const qwantUrl =
"https://www.qwant.com/?q=" +
encodeURIComponent(
webSearchQuery
);
const directions =
`https://www.google.com/maps/dir/?api=1` +
`&destination=${place.lat},${place.lon}`;
let html = `
<div class="place-header">
<button
class="place-close"
onclick="closePlace()"
>
×
</button>
<div class="place-title">
${escapeHtml(name)}
</div>
<div class="place-type">
${escapeHtml(
formatType(type)
)}
</div>
<div class="place-actions">
<a
class="place-action primary"
href="${directions}"
target="_blank"
rel="noopener"
>
➤ Directions
</a>
<!-- NEW: QWANT WEB SEARCH -->
<a
class="place-action web"
href="${qwantUrl}"
target="_blank"
rel="noopener"
>
🔎 Search web
</a>
${
website
?
`
<a
class="place-action"
href="${safeUrl(website)}"
target="_blank"
rel="noopener"
>
🌐 Website
</a>
`
:
""
}
${
phone
?
`
<a
class="place-action"
href="tel:${escapeHtml(phone)}"
>
☎ Call
</a>
`
:
""
}
</div>
</div>
`;
/* ADDRESS */
if (addressText) {
html += detail(
"📍",
addressText
);
}
/* OPENING HOURS */
if (openingHours) {
html += `
<div class="place-section">
<div class="place-detail">
<div class="detail-icon">
🕐
</div>
<div class="detail-content">
<div class="hours-status">
Opening hours
</div>
<button
class="hours-toggle"
onclick="toggleHours()"
>
See opening hours
</button>
<div
id="hours-list"
class="hours-list"
>
${formatOpeningHours(
openingHours
)}
</div>
</div>
</div>
</div>
`;
}
/* PHONE */
if (phone) {
html += detail(
"☎️",
`
<a
href="tel:${escapeHtml(phone)}"
>
${escapeHtml(phone)}
</a>
`
);
}
/* WEBSITE */
if (website) {
html += detail(
"🌐",
`
<a
href="${safeUrl(website)}"
target="_blank"
rel="noopener"
>
${escapeHtml(
cleanWebsite(
website
)
)}
</a>
`
);
}
/* EMAIL */
if (email) {
html += detail(
"✉️",
`
<a
href="mailto:${escapeHtml(email)}"
>
${escapeHtml(email)}
</a>
`
);
}
/* EXTRA INFORMATION */
const extras = [];
if (tags.cuisine) {
extras.push(
"🍴 " +
tags.cuisine
);
}
if (tags.brand) {
extras.push(
"Brand: " +
tags.brand
);
}
if (tags.operator) {
extras.push(
"Operator: " +
tags.operator
);
}
if (tags.wheelchair) {
extras.push(
"♿ " +
tags.wheelchair
);
}
if (
tags["payment:cards"] === "yes"
) {
extras.push(
"💳 Cards accepted"
);
}
if (extras.length) {
html += `
<div class="place-section">
<div class="tags">
${extras.map(
extra =>
`
<div class="tag">
${escapeHtml(
extra
)}
</div>
`
).join("")}
</div>
</div>
`;
}
placeCard.innerHTML =
html;
}
/* =========================================================
DETAIL
========================================================= */
function detail(
icon,
content
) {
return `
<div class="place-section">
<div class="place-detail">
<div class="detail-icon">
${icon}
</div>
<div class="detail-content">
${content}
</div>
</div>
</div>
`;
}
/* =========================================================
CLOSE PLACE
========================================================= */
function closePlace() {
placeCard.style.display =
"none";
clearSelection();
}
/* =========================================================
OPENING HOURS
========================================================= */
function toggleHours() {
const element =
document.getElementById(
"hours-list"
);
if (!element)
return;
element.classList.toggle(
"visible"
);
}
function formatOpeningHours(
hours
) {
if (hours === "24/7") {
return `
<div class="hours-row">
<span>Every day</span>
<span>24 hours</span>
</div>
`;
}
const parts =
hours.split(";");
let html = "";
parts.forEach(
part => {
part =
part.trim();
if (!part)
return;
const match =
part.match(
/^([A-Za-z]{2}(?:-[A-Za-z]{2})?)\s+(.+)$/
);
if (match) {
html += `
<div class="hours-row">
<span>
${escapeHtml(
expandDays(
match[1]
)
)}
</span>
<span>
${escapeHtml(
match[2]
)}
</span>
</div>
`;
} else {
html += `
<div class="hours-row">
<span>
${escapeHtml(
part
)}
</span>
</div>
`;
}
}
);
return html;
}
function expandDays(
value
) {
const days = {
Mo: "Mon",
Tu: "Tue",
We: "Wed",
Th: "Thu",
Fr: "Fri",
Sa: "Sat",
Su: "Sun"
};
if (
value.includes("-")
) {
const split =
value.split("-");
return (
(days[split[0]] ||
split[0]) +
" – " +
(days[split[1]] ||
split[1])
);
}
return days[value] ||
value;
}
/* =========================================================
CATEGORY BUTTONS
========================================================= */
document
.querySelectorAll(".chip")
.forEach(
chip => {
chip.addEventListener(
"click",
() => {
document
.querySelectorAll(
".chip"
)
.forEach(
c =>
c.classList.remove(
"active"
)
);
chip.classList.add(
"active"
);
const query =
chip.dataset.search;
searchInput.value =
query;
searchPlace(
query
);
}
);
}
);
/* =========================================================
SEARCH BUTTON
========================================================= */
searchButton.addEventListener(
"click",
() => {
searchPlace(
searchInput.value
);
}
);
searchInput.addEventListener(
"keydown",
event => {
if (
event.key === "Enter"
) {
event.preventDefault();
searchPlace(
searchInput.value
);
}
}
);
/* =========================================================
CLEAR SEARCH
========================================================= */
searchClear.addEventListener(
"click",
() => {
searchInput.value =
"";
resultsCard.style.display =
"none";
searchInput.focus();
clearMarkers();
}
);
/* =========================================================
LOCATION BUTTON
========================================================= */
function showUserLocation(lat, lon) {
const coords = [lon, lat];
if (!userLocationMarker) {
const el =
document.createElement(
"div"
);
el.className =
"user-loc-marker";
el.innerHTML =
'<div class="user-loc-dot"></div>';
userLocationMarker =
new maplibregl.Marker({
element: el
})
.setLngLat(coords)
.addTo(map);
} else {
userLocationMarker.setLngLat(coords);
}
map.flyTo({
center: coords,
zoom: 16,
duration: 800
});
}
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",
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
========================================================= */
function showZoomMessage() {
zoomMessage.classList.add(
"visible"
);
clearTimeout(
zoomMessageTimer
);
zoomMessageTimer =
setTimeout(
() => {
zoomMessage.classList.remove(
"visible"
);
},
2500
);
}
/* =========================================================
MARKERS
========================================================= */
function clearMarkers() {
poiMarkers.forEach(marker => marker.remove());
poiMarkers = [];
clearSelection();
}
/* =========================================================
HELPERS
========================================================= */
function getName(place) {
const address =
place.address || {};
const names =
place.namedetails || {};
return (
names.name ||
address.amenity ||
address.shop ||
address.tourism ||
address.leisure ||
address.building ||
place.display_name
?.split(",")[0] ||
"Unknown location"
);
}
function getType(place) {
const tags =
place.extratags || {};
return (
tags.amenity ||
tags.shop ||
tags.tourism ||
tags.leisure ||
tags.office ||
tags.craft ||
place.type ||
place.category ||
"Location"
);
}
function getShortAddress(place) {
const address =
place.address || {};
const parts = [];
if (address.road) {
let road =
address.road;
if (
address.house_number
) {
road +=
" " +
address.house_number;
}
parts.push(
road
);
}
const city =
address.city ||
address.town ||
address.village ||
address.municipality;
if (city) {
parts.push(
city
);
}
return (
parts.join(", ") ||
place.display_name ||
""
);
}
function buildAddress(
address
) {
const parts = [];
if (address.road) {
let road =
address.road;
if (
address.house_number
) {
road +=
" " +
address.house_number;
}
parts.push(
road
);
}
if (address.postcode) {
parts.push(
address.postcode
);
}
const city =
address.city ||
address.town ||
address.village ||
address.municipality;
if (city) {
parts.push(
city
);
}
return parts.join(", ");
}
function formatType(
type
) {
if (!type)
return "Location";
return String(type)
.replace(
/_/g,
" "
)
.replace(
/\b\w/g,
c =>
c.toUpperCase()
);
}
function getIcon(place) {
const type =
getType(place)
.toLowerCase();
if (
type.includes("restaurant") ||
type.includes("cafe") ||
type.includes("food")
)
return "🍴";
if (
type.includes("hotel") ||
type.includes("hostel")
)
return "🛏";
if (
type.includes("museum")
)
return "🏛";
if (
type.includes("shop") ||
type.includes("supermarket")
)
return "🛍";
if (
type.includes("station") ||
type.includes("transport")
)
return "🚆";
if (
type.includes("pharmacy")
)
return "💊";
if (
type.includes("park")
)
return "🌳";
return "📍";
}
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 "#43a047";
if (
type.includes("shop") ||
type.includes("supermarket")
)
return "#0288d1";
if (
type.includes("station") ||
type.includes("transport")
)
return "#0288d1";
if (
type.includes("pharmacy")
)
return "#e53935";
if (
type.includes("park")
)
return "#2e7d32";
return "#1a73e8";
}
function safeUrl(url) {
if (!url)
return "#";
url =
String(url).trim();
if (
!url.startsWith(
"http://"
) &&
!url.startsWith(
"https://"
)
) {
url =
"https://" +
url;
}
return escapeHtml(url);
}
function cleanWebsite(url) {
return String(url)
.replace(
/^https?:\/\//,
""
)
.replace(
/\/$/,
""
);
}
function escapeHtml(value) {
const div =
document.createElement(
"div"
);
div.textContent =
value ?? "";
return div.innerHTML;
}
</script>
</body>
</html>