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

2753 lines
55 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/leaflet@1.9.4/dist/leaflet.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: 10px;
bottom: 18px;
z-index: 2000;
width: 410px;
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: 55vh;
overflow-y: auto;
display: none;
background: white;
border-radius: 10px;
box-shadow:
0 3px 12px rgba(0, 0, 0, 0.30);
}
.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%;
bottom: 30px;
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 {
position: fixed;
z-index: 1500;
right: 18px;
bottom: 25px;
width: 42px;
height: 42px;
border: none;
border-radius: 50%;
background: white;
box-shadow:
0 2px 7px rgba(0, 0, 0, 0.3);
cursor: pointer;
font-size: 19px;
color: #5f6368;
}
.location-button:hover {
background: #f8f9fa;
}
/* =====================================================
LEAFLET
===================================================== */
.leaflet-control-zoom {
margin-right: 18px !important;
margin-bottom: 82px !important;
border: none !important;
box-shadow:
0 2px 7px rgba(0, 0, 0, 0.25) !important;
}
.leaflet-control-zoom a {
border: none !important;
}
.leaflet-control-attribution {
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
===================================================== */
@media (max-width: 700px) {
.bottom-ui {
left: 8px;
bottom: 8px;
width:
calc(100% - 16px);
}
.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;
}
.location-button {
right: 12px;
bottom: 72px;
}
.leaflet-control-zoom {
margin-bottom: 125px !important;
}
}
</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="pub"
>
🍺 Kroeg
</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">
<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>
<!-- =========================================================
LOCATION BUTTON
========================================================= -->
<button
id="location-button"
class="location-button"
title="My location"
>
◎
</button>
<script src="https://unpkg.com/leaflet@1.9.4/dist/leaflet.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;
/* =========================================================
MAP
========================================================= */
const map =
L.map("map").setView(
[
AMSTERDAM_LAT,
AMSTERDAM_LON
],
DEFAULT_ZOOM
);
L.tileLayer(
"https://api.maptiler.com/maps/streets-v4/256/{z}/{x}/{y}{r}.png?key=aUftDXS9UHdRVbSuaUWz",
{
maxZoom: 19,
detectRetina: true,
attribution:
"&copy; OpenStreetMap contributors"
}
).addTo(map);
/* =========================================================
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"
);
let markers = [];
let selectedMarker = null;
let userLocationMarker = null;
let zoomMessageTimer = null;
/* =========================================================
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.
const visibleBounds = map.getBounds();
const viewbox = [
visibleBounds.getWest(),
visibleBounds.getNorth(),
visibleBounds.getEast(),
visibleBounds.getSouth()
].join(",");
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 = "";
const bounds = [];
data.forEach(place => {
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
);
const marker =
L.marker([
place.lat,
place.lon
], {
icon: createPoiIcon(place)
}).addTo(map);
marker.bindTooltip(
escapeHtml(getName(place)),
{
permanent: true,
direction: "top",
offset: [0, -20],
className: "pin-label"
}
);
marker.on(
"click",
() => {
selectPlace(
place
);
resultsCard.style.display =
"none";
}
);
markers.push(marker);
bounds.push([
place.lat,
place.lon
]);
});
if (bounds.length === 1) {
const zoom = getZoomForPlace(data[0]);
map.flyTo(
bounds[0],
zoom,
{
duration: 0.7
}
);
}
else {
map.fitBounds(
bounds,
{
padding: [
80,
80
]
}
);
}
}
/* =========================================================
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 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 =
"block";
resultsCard.style.display =
"none";
if (selectedMarker) {
map.removeLayer(
selectedMarker
);
}
selectedMarker =
L.marker([
place.lat,
place.lon
]).addTo(map);
const zoom = getZoomForPlace(place);
map.flyTo(
[
place.lat,
place.lon
],
zoom,
{
duration: 0.7
}
);
renderPlace(place);
}
/* =========================================================
MAP CLICK
========================================================= */
map.on(
"click",
async event => {
/*
* Don't reverse-geocode at low zoom.
*/
if (
map.getZoom() < CLICK_ZOOM
) {
showZoomMessage();
return;
}
const lat =
event.latlng.lat;
const lon =
event.latlng.lng;
resultsCard.style.display =
"none";
placeCard.style.display =
"block";
placeCard.innerHTML = `
<div class="place-header">
<div class="place-title">
Looking up location...
</div>
</div>
`;
if (selectedMarker) {
map.removeLayer(
selectedMarker
);
}
selectedMarker =
L.marker([
lat,
lon
]).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;
}
selectedMarker.bindPopup(
escapeHtml(
getName(place)
)
).openPopup();
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";
if (selectedMarker) {
map.removeLayer(
selectedMarker
);
selectedMarker =
null;
}
}
/* =========================================================
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 = [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",
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() {
markers.forEach(
marker =>
map.removeLayer(
marker
)
);
markers = [];
if (selectedMarker) {
map.removeLayer(
selectedMarker
);
selectedMarker =
null;
}
}
/* =========================================================
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 "#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)
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>