2880 lines
58 KiB
HTML
2880 lines
58 KiB
HTML
<!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: 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: 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%;
|
||
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 {
|
||
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: 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;
|
||
}
|
||
|
||
}
|
||
</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 "#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 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> |