Files
LibMap/templates/index.html
bramval 439d389a44 Add viewbox-bounded search and enable Dutch quick-search chips
- Pass the currently visible map bounds as a Nominatim viewbox so
  results are restricted to what the user can see (bounded=1).
- Increase default result limit from 8 to 20.
- Enable the quick-search chip row with localized Dutch labels
  (Supermarkt, Café, Kroeg, Camping, Park, Restaurant, Vegan,
  Hotel, Parkeerplek).
2026-08-21 14:10:30 +02:00

2410 lines
48 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;
}
/* =====================================================
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}.png?key=aUftDXS9UHdRVbSuaUWz",
{
maxZoom: 19,
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 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(",");
try {
const response =
await fetch(
"/search?q=" +
encodeURIComponent(
query + ", "
) +
"&viewbox=" +
encodeURIComponent(viewbox)
);
const data =
await response.json();
if (
!response.ok ||
data.error
) {
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
${escapeHtml(
data.error ||
"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
]).addTo(map);
marker.bindTooltip(
getName(place)
);
marker.on(
"click",
() => {
selectPlace(
place
);
resultsCard.style.display =
"none";
}
);
markers.push(marker);
bounds.push([
place.lat,
place.lon
]);
});
if (bounds.length === 1) {
map.flyTo(
bounds[0],
17,
{
duration: 0.7
}
);
}
else {
map.fitBounds(
bounds,
{
padding: [
80,
80
]
}
);
}
}
catch (error) {
console.error(error);
resultsCard.innerHTML = `
<div class="result">
<div class="result-text">
Search failed
</div>
</div>
`;
}
}
/* =========================================================
SELECT PLACE
========================================================= */
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);
map.flyTo(
[
place.lat,
place.lon
],
17,
{
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
========================================================= */
document
.getElementById(
"location-button"
)
.addEventListener(
"click",
() => {
if (
!navigator.geolocation
) {
alert(
"Geolocation is not supported."
);
return;
}
navigator.geolocation
.getCurrentPosition(
position => {
map.flyTo(
[
position.coords.latitude,
position.coords.longitude
],
16
);
},
() => {
alert(
"Could not determine your location."
);
}
);
}
);
/* =========================================================
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 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>