165 lines
4.8 KiB
TypeScript
165 lines
4.8 KiB
TypeScript
import { lineString, pointToLineDistance } from "@turf/turf";
|
|
import { FSSource, hasPMTiles } from "./OfflineTiles";
|
|
import { PMTiles } from "pmtiles";
|
|
import { VectorTile } from "@mapbox/vector-tile";
|
|
import Protobuf from "pbf";
|
|
import { location } from "$lib/components/lnv/location.svelte";
|
|
|
|
function getFeatureDistance(f: GeoJSON.Feature, point: [number, number]) {
|
|
if (f.geometry.type === "LineString") {
|
|
return pointToLineDistance(point, lineString(f.geometry.coordinates), { units: "meters" });
|
|
} else if (f.geometry.type === "MultiLineString") {
|
|
// Compute the min distance across all parts
|
|
return Math.min(
|
|
...f.geometry.coordinates.map((coords) =>
|
|
pointToLineDistance(point, lineString(coords), { units: "meters" }),
|
|
),
|
|
);
|
|
} else {
|
|
return Infinity;
|
|
}
|
|
}
|
|
|
|
interface GetFeatureOptions {
|
|
lastId?: string;
|
|
filter?: (feature: GeoJSON.Feature) => boolean;
|
|
}
|
|
|
|
function getBias() {
|
|
if(!location.speed) return 5;
|
|
return Math.max(5, Math.min(15, location.speed * 0.5)); // Bias increases with speed, min 5, max 15, 0.5 per km/h
|
|
}
|
|
|
|
export async function getFeature(coord: WorldLocation, layer: string, { lastId, filter }: GetFeatureOptions = {}) {
|
|
const zxy = coordToTile(coord, 14);
|
|
const tile = await fetchTile(zxy.z, zxy.x, zxy.y);
|
|
const layerData = tile.layers[layer];
|
|
if (!layerData) return null;
|
|
|
|
const features: GeoJSON.Feature[] = [];
|
|
for (let i = 0; i < layerData.length; i++) {
|
|
const feature = layerData.feature(i);
|
|
features.push(feature.toGeoJSON(zxy.x, zxy.y, zxy.z));
|
|
}
|
|
const filtered = features.filter(
|
|
(f) =>
|
|
f.geometry.type === "LineString" || f.geometry.type == "MultiLineString",
|
|
).filter((f) => (filter ? filter(f) : true));
|
|
if (filtered.length === 0) return null;
|
|
const nearest = filtered.reduce((a, b) => {
|
|
let distA = getFeatureDistance(a, [coord.lon, coord.lat]);
|
|
let distB = getFeatureDistance(b, [coord.lon, coord.lat]);
|
|
|
|
console.log("lastId:", lastId, "a.id:", a.id, "b.id:", b.id);
|
|
const STAY_BIAS = getBias();
|
|
|
|
if (lastId && String(a.id) == lastId) {
|
|
console.log("Applying stay bias to B");
|
|
distB += STAY_BIAS;
|
|
}
|
|
if (lastId && String(b.id) == lastId) {
|
|
console.log("Applying stay bias to A");
|
|
distA += STAY_BIAS;
|
|
}
|
|
|
|
console.log("Distances:", distA, distB);
|
|
|
|
return distA < distB ? a : b;
|
|
});
|
|
console.log("ID: ", nearest.id);
|
|
return nearest;
|
|
}
|
|
|
|
export async function getMeta(coord: WorldLocation, layer: string, options?: GetFeatureOptions) {
|
|
const nearest = await getFeature(coord, layer, options);
|
|
return nearest ? nearest.properties : null;
|
|
}
|
|
|
|
const IMPLICIT_SPEEDS: Record<string, number> = {
|
|
"DE:urban": 50,
|
|
"DE:rural": 100, // TODO: 80 (hgv weight > 3.5t, or trailer), 60 (weight > 7.5t)
|
|
"DE:living_street": 7,
|
|
"DE:bicycle_road": 30,
|
|
};
|
|
|
|
export function getSpeed(maxspeed: string): number | null {
|
|
if (!isNaN(parseInt(maxspeed))) return parseInt(maxspeed);
|
|
if (maxspeed.endsWith(" mph")) {
|
|
const val = parseInt(maxspeed.replace(" mph", ""));
|
|
if (!isNaN(val)) return Math.round(val * 1.60934); // Convert to km/h
|
|
}
|
|
if (maxspeed === "walk") return 7; // https://wiki.openstreetmap.org/wiki/Proposed_features/maxspeed_walk
|
|
return IMPLICIT_SPEEDS[maxspeed as keyof typeof IMPLICIT_SPEEDS] || null;
|
|
}
|
|
|
|
function coordToTile(coord: WorldLocation, zoom: number) {
|
|
const z = zoom;
|
|
const x = Math.floor(((coord.lon + 180) / 360) * Math.pow(2, z));
|
|
const y = Math.floor(
|
|
((1 -
|
|
Math.log(
|
|
Math.tan((coord.lat * Math.PI) / 180) +
|
|
1 / Math.cos((coord.lat * Math.PI) / 180),
|
|
) /
|
|
Math.PI) /
|
|
2) *
|
|
Math.pow(2, z),
|
|
);
|
|
return { z, x, y };
|
|
}
|
|
|
|
class Cache<T> {
|
|
data: Map<string, T>;
|
|
size: number;
|
|
|
|
constructor(size: number) {
|
|
this.data = new Map<string, T>();
|
|
this.size = size;
|
|
}
|
|
|
|
get(key: string): T | undefined {
|
|
if (this.data.has(key)) {
|
|
const value = this.data.get(key)!;
|
|
this.data.delete(key);
|
|
this.data.set(key, value);
|
|
return value;
|
|
}
|
|
return this.data.get(key);
|
|
}
|
|
|
|
has(key: string): boolean {
|
|
return this.data.has(key);
|
|
}
|
|
|
|
set(key: string, value: T): void {
|
|
this.data.set(key, value);
|
|
if (this.data.size > this.size) {
|
|
const firstKey = this.data.keys().next().value;
|
|
if (firstKey) {
|
|
this.data.delete(firstKey);
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
const tileCache = new Cache<VectorTile>(5);
|
|
|
|
export async function fetchTile(z: number, x: number, y: number) {
|
|
const cacheKey = `${z}/${x}/${y}`;
|
|
if (tileCache.has(cacheKey)) {
|
|
return tileCache.get(cacheKey)!;
|
|
}
|
|
const pmtiles = (await hasPMTiles("tiles"))
|
|
? new PMTiles(new FSSource("tiles"))
|
|
: new PMTiles("https://trafficcue-tiles.picoscratch.de/germany.pmtiles");
|
|
const tile = await pmtiles.getZxy(z, x, y);
|
|
if (!tile) {
|
|
console.log(tile);
|
|
throw new Error(`Tile not found: z${z} x${x} y${y}`);
|
|
}
|
|
const data = tile.data;
|
|
const vectorTile = new VectorTile(new Protobuf(data));
|
|
tileCache.set(cacheKey, vectorTile);
|
|
return vectorTile;
|
|
}
|