엄태정 443b85880c 라우팅 끄고 포인트 이동 시 고도값을 주변 앵커에서 보간하도록 개선
기존엔 직선 구간의 고도를 매번 gpx.studio 서버(CORS로 막혀있음)에서 조회해서,
실패하면 0으로 떨어지고 포인트 이동 자체가 실패하기도 했음.

양쪽 앵커에 이미 실제 고도값이 있는데 버려지고 있던 것을 발견하고,
거리 비례로 보간하도록 변경:
- 양쪽 다 알면 보간, 한쪽만 알면 그 값으로 확장, 둘 다 모르면 기존처럼 네트워크 조회로 폴백
- 대부분의 실사용 시나리오(기존 트랙 위 포인트 이동)에서 네트워크 요청 자체가 불필요해짐

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-03 09:19:10 +09:00

378 lines
12 KiB
TypeScript

import type { Coordinates } from 'gpx';
import { TrackPoint, distance } from 'gpx';
import { settings } from '$lib/logic/settings';
import { getElevation } from '$lib/utils';
import { get } from 'svelte/store';
const { routing, routingProfile, privateRoads } = settings;
export type RoutingProfile = {
engine: 'graphhopper' | 'brouter';
profile: string;
};
export const routingProfiles: { [key: string]: RoutingProfile } = {
bike: { engine: 'graphhopper', profile: 'bike' },
racing_bike: { engine: 'graphhopper', profile: 'racingbike' },
gravel_bike: { engine: 'graphhopper', profile: 'gravelbike' },
mountain_bike: { engine: 'graphhopper', profile: 'mtb' },
foot: { engine: 'graphhopper', profile: 'foot' },
motorcycle: { engine: 'graphhopper', profile: 'motorbike' },
car: { engine: 'brouter', profile: 'car-fast' },
water: { engine: 'brouter', profile: 'river' },
railway: { engine: 'brouter', profile: 'rail' },
};
export function route(
points: Coordinates[],
elevations?: (number | undefined)[]
): Promise<TrackPoint[]> {
if (get(routing)) {
const profile = routingProfiles[get(routingProfile)];
if (profile.engine === 'graphhopper') {
return getGraphHopperRoute(points, profile.profile, get(privateRoads));
} else {
return getBRouterRoute(points, profile.profile);
}
} else {
return getIntermediatePoints(points, elevations);
}
}
const graphhopperDetails = ['road_class', 'surface', 'hike_rating', 'mtb_rating'];
const hikeRatingToSACScale: { [key: string]: string } = {
'1': 'hiking',
'2': 'mountain_hiking',
'3': 'demanding_mountain_hiking',
'4': 'alpine_hiking',
'5': 'demanding_alpine_hiking',
'6': 'difficult_alpine_hiking',
};
const mtbRatingToScale: { [key: string]: string } = {
'1': '0',
'2': '1',
'3': '2',
'4': '3',
'5': '4',
'6': '5',
'7': '6',
};
const graphhopperBlockPrivateCustomModels: { [key: string]: any } = {
bike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
racingbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
gravelbike: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
mtb: {
priority: [
{
if: 'bike_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
foot: {
priority: [
{
if: 'foot_road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
motorcycle: {
priority: [
{
if: 'road_access == PRIVATE',
multiply_by: '0.0',
},
],
},
};
async function getGraphHopperRoute(
points: Coordinates[],
graphHopperProfile: string,
privateRoads: boolean
): Promise<TrackPoint[]> {
let response = await fetch('https://graphhopper.gpx.studio/route', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
points: points.map((point) => [point.lon, point.lat]),
profile: graphHopperProfile,
elevation: true,
points_encoded: false,
details: graphhopperDetails,
custom_model: privateRoads
? {}
: graphhopperBlockPrivateCustomModels[graphHopperProfile] || {},
}),
});
if (!response.ok) {
const error = await response.json();
if (error.message.includes('Cannot find point 0')) {
throw new Error('toolbar.routing.error.from');
} else if (error.message.includes('Cannot find point 1')) {
if (points.length == 3) {
throw new Error('toolbar.routing.error.via');
} else {
throw new Error('toolbar.routing.error.to');
}
} else if (error.hints[0].details.includes('PointDistanceExceededException')) {
throw new Error('toolbar.routing.error.distance');
} else if (error.hints[0].details.includes('ConnectionNotFoundException')) {
throw new Error('toolbar.routing.error.connection');
} else {
throw new Error(error.message);
}
}
let json = await response.json();
let route: TrackPoint[] = [];
let coordinates = json.paths[0].points.coordinates;
let details = json.paths[0].details;
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
extensions: {},
})
);
}
for (let key of graphhopperDetails) {
let detail = details[key];
for (let i = 0; i < detail.length; i++) {
for (let j = detail[i][0]; j < detail[i][1] + (i == detail.length - 1); j++) {
if (detail[i][2] !== undefined && detail[i][2] !== 'missing') {
if (key === 'road_class') {
route[j].setExtension('highway', detail[i][2]);
} else if (key === 'hike_rating') {
const sacScale = hikeRatingToSACScale[detail[i][2]];
if (sacScale) {
route[j].setExtension('sac_scale', sacScale);
}
} else if (key === 'mtb_rating') {
const mtbScale = mtbRatingToScale[detail[i][2]];
if (mtbScale) {
route[j].setExtension('mtb_scale', mtbScale);
}
} else if (key === 'surface' && detail[i][2] !== 'other') {
route[j].setExtension('surface', detail[i][2]);
}
}
}
}
}
return route;
}
async function getBRouterRoute(
points: Coordinates[],
brouterProfile: string
): Promise<TrackPoint[]> {
let url = `https://brouter.de/brouter?lonlats=${points.map((point) => `${point.lon.toFixed(8)},${point.lat.toFixed(8)}`).join('|')}&profile=${brouterProfile}&format=geojson&alternativeidx=0`;
let response = await fetch(url);
if (!response.ok) {
const error = await response.text();
if (error.includes('from-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.from');
} else if (error.includes('via1-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.via');
} else if (error.includes('to-position not mapped in existing datafile')) {
throw new Error('toolbar.routing.error.to');
} else if (error.includes('Time-out')) {
throw new Error('toolbar.routing.error.timeout');
} else {
throw new Error(error);
}
}
let geojson = await response.json();
let route: TrackPoint[] = [];
let coordinates = geojson.features[0].geometry.coordinates;
let messages = geojson.features[0].properties.messages;
const lngIdx = messages[0].indexOf('Longitude');
const latIdx = messages[0].indexOf('Latitude');
const tagIdx = messages[0].indexOf('WayTags');
let messageIdx = 1;
let tags = messageIdx < messages.length ? getTags(messages[messageIdx][tagIdx]) : {};
for (let i = 0; i < coordinates.length; i++) {
route.push(
new TrackPoint({
attributes: {
lat: coordinates[i][1],
lon: coordinates[i][0],
},
ele: coordinates[i][2] ?? (i > 0 ? route[i - 1].ele : 0),
})
);
if (
messageIdx < messages.length &&
coordinates[i][0] == Number(messages[messageIdx][lngIdx]) / 1000000 &&
coordinates[i][1] == Number(messages[messageIdx][latIdx]) / 1000000
) {
messageIdx++;
if (messageIdx == messages.length) tags = {};
else tags = getTags(messages[messageIdx][tagIdx]);
}
route[route.length - 1].setExtensions(tags);
}
return route;
}
function getTags(message: string): { [key: string]: string } {
const fields = message.split(' ');
let tags: { [key: string]: string } = {};
for (let i = 0; i < fields.length; i++) {
let [key, value] = fields[i].split('=');
key = key.replace(/:/g, '_');
tags[key] = value;
}
return tags;
}
// Fills in anchors with unknown elevation (e.g. a point just dragged to a new location) by
// interpolating between the nearest anchors on either side that do have a known elevation,
// weighted by distance along the path. Falls back to extrapolating the nearest known value
// when there is no known anchor on one side. Leaves entries as undefined only when none of the
// anchors have a known elevation at all.
function fillMissingElevations(
points: Coordinates[],
elevations?: (number | undefined)[]
): (number | undefined)[] {
if (!elevations) {
return points.map(() => undefined);
}
const known = elevations
.map((ele, index) => ({ ele, index }))
.filter((item): item is { ele: number; index: number } => item.ele !== undefined);
if (known.length === 0) {
return elevations;
}
const cumulativeDistance = [0];
for (let i = 1; i < points.length; i++) {
cumulativeDistance.push(cumulativeDistance[i - 1] + distance(points[i - 1], points[i]));
}
return elevations.map((ele, index) => {
if (ele !== undefined) {
return ele;
}
const before = known.filter((item) => item.index < index).at(-1);
const after = known.find((item) => item.index > index);
if (before && after) {
const span = cumulativeDistance[after.index] - cumulativeDistance[before.index];
const ratio =
span === 0
? 0
: (cumulativeDistance[index] - cumulativeDistance[before.index]) / span;
return before.ele + ratio * (after.ele - before.ele);
}
return (before ?? after)!.ele;
});
}
function getIntermediatePoints(
points: Coordinates[],
elevations?: (number | undefined)[]
): Promise<TrackPoint[]> {
elevations = fillMissingElevations(points, elevations);
let route: TrackPoint[] = [];
let step = 0.05;
for (let i = 0; i < points.length - 1; i++) {
// Add intermediate points between each pair of points
let dist = distance(points[i], points[i + 1]) / 1000;
let eleStart = elevations?.[i];
let eleEnd = elevations?.[i + 1];
for (let d = 0; d < dist; d += step) {
let lat = points[i].lat + (d / dist) * (points[i + 1].lat - points[i].lat);
let lon = points[i].lon + (d / dist) * (points[i + 1].lon - points[i].lon);
// Interpolate between the known elevations of the surrounding anchor points,
// rather than requesting elevation data for every intermediate point
let ele =
eleStart !== undefined && eleEnd !== undefined
? eleStart + (d / dist) * (eleEnd - eleStart)
: undefined;
route.push(
new TrackPoint({
attributes: {
lat: lat,
lon: lon,
},
ele,
})
);
}
}
route.push(
new TrackPoint({
attributes: {
lat: points[points.length - 1].lat,
lon: points[points.length - 1].lon,
},
ele: elevations?.[points.length - 1],
})
);
if (route.every((point) => point.ele !== undefined)) {
return Promise.resolve(route);
}
return getElevation(route).then((elevations) => {
route.forEach((point, i) => {
if (point.ele === undefined) {
point.ele = elevations[i];
}
});
return route;
});
}