2753 lines
55 KiB
HTML
2753 lines
55 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/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:
|
||
"© 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> |