"use strict";
/* Här samlas funktioner för sevärdigheter och stopp */
/**
* Räknar ut ungefärligt avstånd i kilometer mellan två koordinater.
* Använder Haversine-beräkning för sampling längs rutt.
*
* @param {number[]} pointA [lon, lat]
* @param {number[]} pointB [lon, lat]
* @returns {number}
*/
function getDistanceKm(pointA, pointB) {
/* Plockar ut longitud och latitud från punkt A och punkt B */
const [lon1, lat1] = pointA;
const [lon2, lat2] = pointB;
/* Hjälpfunktion: trigonometriska funktioner i JS använder radianer, inte grader */
const toRadians = (value) => (value * Math.PI) / 180;
/* Jordens ungefärliga radie i kilometer */
const earthRadiusKm = 6371;
/* Räknar ut skillnaden mellan punkterna i latitud och longitud */
const dLat = toRadians(lat2 - lat1);
const dLon = toRadians(lon2 - lon1);
/* Första delen av formeln används för att ta hänsyn till att jorden är rund */
const a =
Math.sin(dLat / 2) ** 2 +
Math.cos(toRadians(lat1)) *
Math.cos(toRadians(lat2)) *
Math.sin(dLon / 2) ** 2;
/* Andra delen av formeln omvandlar mellanvärdet till ett vinkelavstånd på jordytan */
const c = 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
/* Slutligt avstånd i kilometer */
return earthRadiusKm * c;
}
/**
* Räknar ut minsta avstånd i kilometer mellan ett POI och en rutt.
* Jämför POI:t med alla koordinatpunkter i rutten och tar det minsta värdet.
*
* @param {object} poi
* @param {number[][]} routeCoords
* @returns {number}
*/
export function getDistanceFromRouteKm(poi, routeCoords) {
/* Om POI eller rutt saknas går det inte att räkna */
if (!poi || !routeCoords?.length) {
return Infinity;
}
/* Startvärde: väldigt stort tal så att första riktiga avståndet blir mindre */
let shortestDistanceKm = Infinity;
/* Gå igenom varje punkt i rutten */
routeCoords.forEach((routePoint) => {
const distanceKm = getDistanceKm([poi.lon, poi.lat], routePoint);
/* Spara bara det kortaste avståndet */
if (distanceKm < shortestDistanceKm) {
shortestDistanceKm = distanceKm;
}
});
return shortestDistanceKm;
}
/**
* Väljer ut stopp-punkter med jämnt avstånd längs rutten.
*
* @param {number[][]} routeCoords
* @param {number} sampleDistanceKm
* @returns {number[][]}
*/
function getSamplePointsByDistance(routeCoords, sampleDistanceKm) {
/* Om rutten saknar koordinater finns inga sample-punkter att välja */
if (!routeCoords?.length) {
return [];
}
/* Börja med ruttens första punkt */
const samplePoints = [routeCoords[0]];
/* Håller koll på hur långt vi rört oss sedan senaste valda stopp-punkt */
let distanceSinceLastSample = 0;
/* Gå igenom rutten punkt för punkt */
for (let index = 1; index < routeCoords.length; index++) {
const previousPoint = routeCoords[index - 1];
const currentPoint = routeCoords[index];
/* Lägg till avståndet mellan föregående och nuvarande punkt */
distanceSinceLastSample += getDistanceKm(previousPoint, currentPoint);
/* När vi nått önskat avstånd sparas punkten som ny stopp-punkt och räknaren börjar om */
if (distanceSinceLastSample >= sampleDistanceKm) {
samplePoints.push(currentPoint);
distanceSinceLastSample = 0;
}
}
const lastPoint = routeCoords[routeCoords.length - 1];
const lastSample = samplePoints[samplePoints.length - 1];
/* Säkerställ att även ruttens slut kommer med som stopp-punkt */
if (lastSample !== lastPoint) {
samplePoints.push(lastPoint);
}
return samplePoints;
}
/**
* Räknar ut ungefärlig total längd i kilometer för en rutt.
*
* @param {number[][]} routeCoords
* @returns {number}
*/
function getRouteLengthKm(routeCoords) {
if (!routeCoords?.length) {
return 0;
}
let totalDistanceKm = 0;
for (let index = 1; index < routeCoords.length; index++) {
totalDistanceKm += getDistanceKm(routeCoords[index - 1], routeCoords[index]);
}
return totalDistanceKm;
}
/**
* Hämtar JSON från Overpass och provar flera endpoints om en instans faller bort.
*
* @param {string} query
* @returns {Promise<object>}
*/
async function fetchOverpassWithFallback(query) {
const endpoints = [
"https://overpass-api.de/api/interpreter",
"https://overpass.kumi.systems/api/interpreter"
];
let lastError = null;
for (const endpoint of endpoints) {
try {
const response = await fetch(endpoint, {
method: "POST",
headers: {
"Content-Type": "application/x-www-form-urlencoded;charset=UTF-8"
},
body: `data=${encodeURIComponent(query)}`
});
if (!response.ok) {
throw new Error(`Overpass svarade med status ${response.status}`);
}
return await response.json();
} catch (error) {
lastError = error;
}
}
throw lastError || new Error("Kunde inte hämta stopp.");
}
/**
* Hämtar POI nära en lista av koordinater.
* Använder dynamisk sampling för att undvika onödigt stor fråga på långa rutter.
*
* @param {number[][]} routeCoords
* @returns {Promise<object[]>}
*/
export async function fetchPOIs(routeCoords) {
if (!routeCoords?.length) {
return [];
}
/* Anpassa sampling efter ruttens längd men håll kvar samma grundidé */
const totalRouteDistanceKm = getRouteLengthKm(routeCoords);
const minSampleDistanceKm = 8;
const maxSamplePoints = 25;
const sampleDistanceKm = Math.max(
minSampleDistanceKm,
totalRouteDistanceKm / maxSamplePoints
);
const samplePoints = getSamplePointsByDistance(routeCoords, sampleDistanceKm);
/* Hitta noder/ways inom visst avstånd från koordinater längs rutten */
const queries = samplePoints.map(([lon, lat]) => `
node(around:500,${lat},${lon})["amenity"~"toilets|restaurant|fuel|cafe|fast_food"];
way(around:500,${lat},${lon})["amenity"~"toilets|restaurant|fuel|cafe|fast_food"];
node(around:1000,${lat},${lon})["tourism"~"camp_site|caravan_site|viewpoint"];
way(around:1000,${lat},${lon})["tourism"~"camp_site|caravan_site|viewpoint"];
`);
/* Gör en fråga av alla del-frågor */
const overpassQuery = `
[out:json][timeout:25];
(
${queries.join("\n")}
);
out center;
`;
const data = await fetchOverpassWithFallback(overpassQuery);
/* Ta bort dubletter eftersom samma stopp kan hittas från flera stopp-punkter */
const uniquePOIs = (data.elements || []).filter((element, index, array) => {
return index === array.findIndex((item) => item.id === element.id && item.type === element.type);
});
return uniquePOIs;
}
/**
* Avgör vilken typ av POI ett objekt är baserat på tags.
* @param {object} tags
* @returns {string}
*/
function detectPoiType(tags = {}) {
if (tags.amenity === "toilets") return "toilets";
if (tags.amenity === "restaurant") return "restaurant";
if (tags.amenity === "cafe") return "cafe";
if (tags.amenity === "fast_food") return "fast_food";
if (tags.amenity === "fuel") return "fuel";
if (tags.tourism === "camp_site") return "camp_site";
if (tags.tourism === "caravan_site") return "caravan_site";
if (tags.tourism === "viewpoint") return "viewpoint";
return "other";
}
/**
* Översätter teknisk POI-typ till ett användarvänligt kategorinamn.
* @param {string} type
* @returns {string}
*/
function getPoiCategory(type) {
const categoryMap = {
toilets: "Toaletter",
restaurant: "Mat & restauranger",
cafe: "Mat & restauranger",
fast_food: "Mat & restauranger",
fuel: "Tankstationer",
camp_site: "Ställplatser / camping",
caravan_site: "Ställplatser / camping",
viewpoint: "Utsiktsplatser",
other: "Övrigt"
};
return categoryMap[type] || "Övrigt";
}
/**
* Hämtar platsnamn för ett POI från OSM-taggar.
* Försöker hitta ort eller kommun i en enkel prioriterad ordning.
*
* @param {object} tags
* @returns {string}
*/
function getPoiPlaceName(tags = {}) {
return (
tags["addr:city"] ||
tags["addr:town"] ||
tags["addr:village"] ||
tags["addr:municipality"] ||
tags["is_in:city"] ||
tags["is_in:town"] ||
tags["is_in:village"] ||
tags["is_in"] ||
""
);
}
/**
* Normaliserar rå POI-data från Overpass till ett enhetligt format.
* @param {object[]} pois
* @returns {object[]}
*/
export function normalizePOIs(pois) {
return pois
.map((poi) => {
const type = detectPoiType(poi.tags);
return {
id: `${poi.type}-${poi.id}`,
name: poi.tags?.name || getPoiCategory(type),
type,
category: getPoiCategory(type),
placeName: getPoiPlaceName(poi.tags),
// Node har lat/lon direkt medan way brukar ha center.lat / center.lon
lat: poi.lat ?? poi.center?.lat ?? null,
lon: poi.lon ?? poi.center?.lon ?? null,
tags: poi.tags || {}
};
})
// Filtrera bort objekt utan användbara koordinater
.filter((poi) => poi.lat !== null && poi.lon !== null);
}
/**
* Sorterar POI i den ordning de ligger längs rutten.
* Närmaste punkt i routeCoords används som ungefärlig position.
*
* @param {object[]} pois
* @param {number[][]} routeCoords
* @returns {object[]}
*/
export function sortPOIsAlongRoute(pois, routeCoords) {
if (!pois?.length || !routeCoords?.length) {
return pois;
}
return [...pois].sort((a, b) => {
const aIndex = getClosestRouteIndex(a, routeCoords);
const bIndex = getClosestRouteIndex(b, routeCoords);
return aIndex - bIndex;
});
}
/**
* Hittar index för den punkt i rutten som ligger närmast ett POI.
*
* @param {object} poi
* @param {number[][]} routeCoords
* @returns {number}
*/
function getClosestRouteIndex(poi, routeCoords) {
let closestIndex = 0;
let closestDistance = Infinity;
routeCoords.forEach(([lon, lat], index) => {
const distance =
Math.abs(lat - poi.lat) + Math.abs(lon - poi.lon);
if (distance < closestDistance) {
closestDistance = distance;
closestIndex = index;
}
});
return closestIndex;
}
/**
* Väljer ut POI jämnt utspridda över en redan sorterad lista.
*
* @param {object[]} pois
* @param {number} maxPerCategory
* @returns {object[]}
*/
function pickEvenlyDistributedPOIs(pois, maxPerCategory) {
if (!pois?.length || maxPerCategory <= 0) {
return [];
}
if (pois.length <= maxPerCategory) {
return pois;
}
if (maxPerCategory === 1) {
return [pois[0]];
}
const selectedPOIs = [];
const lastIndex = pois.length - 1;
for (let index = 0; index < maxPerCategory; index++) {
const targetIndex = Math.round((index * lastIndex) / (maxPerCategory - 1));
const candidate = pois[targetIndex];
if (candidate && !selectedPOIs.some((poi) => poi.id === candidate.id)) {
selectedPOIs.push(candidate);
}
}
/* Fyll på om avrundning råkar ge dubletter */
pois.forEach((poi) => {
const alreadySelected = selectedPOIs.some((selected) => selected.id === poi.id);
if (!alreadySelected && selectedPOIs.length < maxPerCategory) {
selectedPOIs.push(poi);
}
});
return selectedPOIs.slice(0, maxPerCategory);
}
/**
* Grupperar normaliserade POI efter kategori.
* @param {object[]} pois
* @returns {Object<string, object[]>}
*/
export function groupPOIsByCategory(pois) {
return pois.reduce((groups, poi) => {
const category = poi.category || "Övrigt";
if (!groups[category]) {
groups[category] = [];
}
groups[category].push(poi);
return groups;
}, {});
}
/**
* Begränsar antal POI per kategori.
* Förutsätter att listan redan är sorterad i ruttens ordning.
*
* @param {Object<string, object[]>} groupedPOIs
* @param {number} maxPerCategory
* @returns {Object<string, object[]>}
*/
export function limitPOIsPerCategory(groupedPOIs, maxPerCategory) {
return Object.fromEntries(
Object.entries(groupedPOIs).map(([category, pois]) => [
category,
pickEvenlyDistributedPOIs(pois, maxPerCategory)
])
);
}