차량 목적지 경로 미리보기 추가
This commit is contained in:
12
README.md
12
README.md
@@ -181,8 +181,18 @@ $body = @{
|
||||
cargoWeight = "1.2t"
|
||||
origin = "서울 상차지"
|
||||
destination = "평택 물류센터"
|
||||
destinationLatitude = 36.9921
|
||||
destinationLongitude = 127.1128
|
||||
routePath = @(
|
||||
@{ latitude = 37.5665; longitude = 126.9780 },
|
||||
@{ latitude = 37.33; longitude = 127.05 },
|
||||
@{ latitude = 36.9921; longitude = 127.1128 }
|
||||
)
|
||||
remainingDistanceKm = 74.5
|
||||
etaMinutes = 68
|
||||
estimatedArrivalAt = (Get-Date).AddMinutes(68).ToUniversalTime().ToString("o")
|
||||
recordedAt = (Get-Date).ToUniversalTime().ToString("o")
|
||||
} | ConvertTo-Json
|
||||
} | ConvertTo-Json -Depth 4
|
||||
|
||||
Invoke-RestMethod `
|
||||
-Uri "http://localhost:8080/api/v1/locations" `
|
||||
|
||||
@@ -565,6 +565,14 @@ function publishSimulatorLocations() {
|
||||
function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
const routeState = interpolateRoute(vehicle.route, nowMs, vehicle.loopMs, hashOffset(vehicle.driverId));
|
||||
const speedWave = Math.sin((nowMs + hashOffset(vehicle.vehicleNo)) / 17000) * 8;
|
||||
const speed = Math.max(18, Math.round(vehicle.speedBase + speedWave));
|
||||
const routePath = createSimulatedRoutePath(
|
||||
{ latitude: routeState.latitude, longitude: routeState.longitude },
|
||||
routeState.destinationPoint,
|
||||
vehicle.driverId
|
||||
);
|
||||
const remainingDistanceKm = roundNumber(calculatePathDistanceKm(routePath) * 1.08, 1);
|
||||
const etaMinutes = Math.max(3, Math.round((remainingDistanceKm / Math.max(speed, 24)) * 60));
|
||||
|
||||
return {
|
||||
driverId: vehicle.driverId,
|
||||
@@ -573,13 +581,20 @@ function createSimulatorLocationBody(vehicle, nowMs) {
|
||||
latitude: routeState.latitude,
|
||||
longitude: routeState.longitude,
|
||||
accuracy: 8,
|
||||
speed: Math.max(18, Math.round(vehicle.speedBase + speedWave)),
|
||||
speed,
|
||||
heading: routeState.heading,
|
||||
cargoName: vehicle.cargoName,
|
||||
cargoQuantity: vehicle.cargoQuantity,
|
||||
cargoWeight: vehicle.cargoWeight,
|
||||
origin: routeState.origin,
|
||||
destination: routeState.destination,
|
||||
destinationLatitude: routeState.destinationPoint.latitude,
|
||||
destinationLongitude: routeState.destinationPoint.longitude,
|
||||
routePath,
|
||||
routeSource: "simulated",
|
||||
remainingDistanceKm,
|
||||
etaMinutes,
|
||||
estimatedArrivalAt: new Date(nowMs + etaMinutes * 60 * 1000).toISOString(),
|
||||
provider: "simulator",
|
||||
simulated: true,
|
||||
recordedAt: new Date(nowMs).toISOString()
|
||||
@@ -599,7 +614,8 @@ function interpolateRoute(route, nowMs, loopMs, offsetMs) {
|
||||
longitude: interpolateNumber(from.longitude, to.longitude, localProgress),
|
||||
heading: Math.round(calculateBearing(from, to)),
|
||||
origin: from.name,
|
||||
destination: to.name
|
||||
destination: to.name,
|
||||
destinationPoint: to
|
||||
};
|
||||
}
|
||||
|
||||
@@ -751,6 +767,13 @@ function createLocationRecord(body, driver) {
|
||||
cargoWeight: optionalString(body.cargoWeight || body.weight, 40),
|
||||
origin: optionalString(body.origin || body.departure, 80),
|
||||
destination: optionalString(body.destination || body.arrival, 80),
|
||||
destinationLatitude: optionalNumber(body.destinationLatitude || body.destinationLat || body.arrivalLatitude || body.arrivalLat),
|
||||
destinationLongitude: optionalNumber(body.destinationLongitude || body.destinationLng || body.destinationLon || body.arrivalLongitude || body.arrivalLng || body.arrivalLon),
|
||||
routePath: normalizeRoutePath(body.routePath || body.path || body.polyline),
|
||||
routeSource: optionalString(body.routeSource || body.routingProvider, 40),
|
||||
remainingDistanceKm: optionalNumber(body.remainingDistanceKm || body.distanceKm || body.routeDistanceKm),
|
||||
etaMinutes: optionalNumber(body.etaMinutes || body.estimatedMinutes || body.remainingMinutes),
|
||||
estimatedArrivalAt: normalizeOptionalDate(body.estimatedArrivalAt || body.etaAt || body.arrivalAt),
|
||||
provider: body.provider ? String(body.provider) : "",
|
||||
simulated: Boolean(body.simulated || driver.simulated),
|
||||
recordedAt: normalizeDate(body.recordedAt),
|
||||
@@ -767,6 +790,81 @@ function point(name, latitude, longitude) {
|
||||
return { name, latitude, longitude };
|
||||
}
|
||||
|
||||
function createSimulatedRoutePath(from, to, seed) {
|
||||
const deltaLatitude = to.latitude - from.latitude;
|
||||
const deltaLongitude = to.longitude - from.longitude;
|
||||
const length = Math.sqrt(deltaLatitude ** 2 + deltaLongitude ** 2) || 1;
|
||||
const offsetDirection = hashOffset(seed) % 2 === 0 ? 1 : -1;
|
||||
const offsetSize = Math.min(0.32, Math.max(0.025, length * 0.14));
|
||||
const perpendicularLatitude = (-deltaLongitude / length) * offsetSize * offsetDirection;
|
||||
const perpendicularLongitude = (deltaLatitude / length) * offsetSize * offsetDirection;
|
||||
|
||||
return [
|
||||
normalizeRoutePoint(from),
|
||||
normalizeRoutePoint({
|
||||
latitude: interpolateNumber(from.latitude, to.latitude, 0.35) + perpendicularLatitude,
|
||||
longitude: interpolateNumber(from.longitude, to.longitude, 0.35) + perpendicularLongitude
|
||||
}),
|
||||
normalizeRoutePoint({
|
||||
latitude: interpolateNumber(from.latitude, to.latitude, 0.72) - perpendicularLatitude * 0.35,
|
||||
longitude: interpolateNumber(from.longitude, to.longitude, 0.72) - perpendicularLongitude * 0.35
|
||||
}),
|
||||
normalizeRoutePoint(to)
|
||||
];
|
||||
}
|
||||
|
||||
function normalizeRoutePath(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
|
||||
return value
|
||||
.map((pointValue) => normalizeRoutePoint(pointValue))
|
||||
.filter(Boolean)
|
||||
.slice(0, 48);
|
||||
}
|
||||
|
||||
function normalizeRoutePoint(value) {
|
||||
if (!value) return null;
|
||||
|
||||
const latitude = Array.isArray(value)
|
||||
? Number(value[0])
|
||||
: Number(value.latitude ?? value.lat);
|
||||
const longitude = Array.isArray(value)
|
||||
? Number(value[1])
|
||||
: Number(value.longitude ?? value.lng ?? value.lon);
|
||||
|
||||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
|
||||
|
||||
return {
|
||||
latitude: roundNumber(latitude, 6),
|
||||
longitude: roundNumber(longitude, 6)
|
||||
};
|
||||
}
|
||||
|
||||
function calculatePathDistanceKm(routePath) {
|
||||
let distance = 0;
|
||||
for (let index = 1; index < routePath.length; index += 1) {
|
||||
distance += calculateDistanceKm(routePath[index - 1], routePath[index]);
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
function calculateDistanceKm(from, to) {
|
||||
const earthRadiusKm = 6371;
|
||||
const deltaLatitude = toRadians(to.latitude - from.latitude);
|
||||
const deltaLongitude = toRadians(to.longitude - from.longitude);
|
||||
const fromLatitude = toRadians(from.latitude);
|
||||
const toLatitude = toRadians(to.latitude);
|
||||
const a = Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function roundNumber(value, decimals) {
|
||||
const multiplier = 10 ** decimals;
|
||||
return Math.round(value * multiplier) / multiplier;
|
||||
}
|
||||
|
||||
function smoothStep(value) {
|
||||
return value * value * (3 - 2 * value);
|
||||
}
|
||||
@@ -833,6 +931,12 @@ function normalizeDate(value) {
|
||||
return Number.isNaN(parsed.getTime()) ? new Date().toISOString() : parsed.toISOString();
|
||||
}
|
||||
|
||||
function normalizeOptionalDate(value) {
|
||||
if (!value) return null;
|
||||
const parsed = new Date(value);
|
||||
return Number.isNaN(parsed.getTime()) ? null : parsed.toISOString();
|
||||
}
|
||||
|
||||
function readHistory({ driverId, limit }) {
|
||||
if (!fs.existsSync(HISTORY_FILE)) return [];
|
||||
|
||||
|
||||
@@ -3,6 +3,8 @@ const state = {
|
||||
mapProvider: "",
|
||||
markers: new Map(),
|
||||
infoOverlay: null,
|
||||
routeOverlay: null,
|
||||
routeDestinationOverlay: null,
|
||||
drivers: new Map(),
|
||||
locations: new Map(),
|
||||
selectedDriverId: "",
|
||||
@@ -334,6 +336,16 @@ function replaceLocations(locations) {
|
||||
}
|
||||
render();
|
||||
updateMarkers({ fitBounds: locations.length > 0 });
|
||||
|
||||
if (state.selectedDriverId) {
|
||||
const selectedLocation = state.locations.get(state.selectedDriverId);
|
||||
if (selectedLocation) {
|
||||
renderRoutePreview(selectedLocation, { fit: false });
|
||||
showInfoOverlay(selectedLocation);
|
||||
} else {
|
||||
clearRouteOverlay();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function replaceDrivers(drivers) {
|
||||
@@ -351,10 +363,12 @@ function upsertLocation(location, options = {}) {
|
||||
|
||||
if (state.selectedDriverId === location.driverId && !elements.historyDrawer.classList.contains("is-hidden")) {
|
||||
loadLocationHistory(location);
|
||||
renderRoutePreview(location, { fit: false });
|
||||
showInfoOverlay(location);
|
||||
}
|
||||
|
||||
if (options.focus) {
|
||||
centerMapOnLocation(location);
|
||||
fitMapToRoute(location) || centerMapOnLocation(location);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -443,7 +457,13 @@ function createVehicleItem(location) {
|
||||
|
||||
const meta = document.createElement("div");
|
||||
meta.className = "vehicle-meta";
|
||||
meta.textContent = `${location.driverName || location.driverId} · ${getCargoSummary(location)} · ${getRouteSummary(location)} · ${formatDateTime(location.receivedAt)}`;
|
||||
meta.textContent = [
|
||||
location.driverName || location.driverId,
|
||||
getCargoSummary(location),
|
||||
getRouteSummary(location),
|
||||
getRouteProgressSummary(location),
|
||||
formatDateTime(location.receivedAt)
|
||||
].filter(Boolean).join(" · ");
|
||||
|
||||
const speed = document.createElement("div");
|
||||
speed.className = "speed";
|
||||
@@ -758,12 +778,132 @@ function centerMapOnLocation(location, options = {}) {
|
||||
}
|
||||
}
|
||||
|
||||
function renderRoutePreview(location, options = {}) {
|
||||
clearRouteOverlay();
|
||||
|
||||
const routePath = getRoutePath(location);
|
||||
if (routePath.length < 2) return false;
|
||||
|
||||
if (state.mapProvider === "kakao") {
|
||||
const path = routePath.map((point) => new kakao.maps.LatLng(point.latitude, point.longitude));
|
||||
state.routeOverlay = new kakao.maps.Polyline({
|
||||
path,
|
||||
strokeWeight: 5,
|
||||
strokeColor: "#1f6feb",
|
||||
strokeOpacity: 0.86,
|
||||
strokeStyle: "solid"
|
||||
});
|
||||
state.routeOverlay.setMap(state.map);
|
||||
|
||||
state.routeDestinationOverlay = new kakao.maps.CustomOverlay({
|
||||
position: path[path.length - 1],
|
||||
content: createDestinationMarkerElement(location),
|
||||
yAnchor: 1.1
|
||||
});
|
||||
state.routeDestinationOverlay.setMap(state.map);
|
||||
}
|
||||
|
||||
if (state.mapProvider === "leaflet") {
|
||||
const path = routePath.map((point) => [point.latitude, point.longitude]);
|
||||
state.routeOverlay = L.polyline(path, {
|
||||
color: "#1f6feb",
|
||||
weight: 5,
|
||||
opacity: 0.86,
|
||||
lineCap: "round",
|
||||
lineJoin: "round"
|
||||
}).addTo(state.map);
|
||||
state.routeDestinationOverlay = L.marker(path[path.length - 1], {
|
||||
icon: L.divIcon({
|
||||
className: "route-destination-leaflet",
|
||||
html: createDestinationMarkerHtml(location),
|
||||
iconSize: [116, 42],
|
||||
iconAnchor: [58, 42]
|
||||
})
|
||||
}).addTo(state.map);
|
||||
}
|
||||
|
||||
if (options.fit !== false) {
|
||||
fitMapToRoute(location);
|
||||
}
|
||||
|
||||
return true;
|
||||
}
|
||||
|
||||
function clearRouteOverlay() {
|
||||
if (state.routeOverlay) {
|
||||
if (typeof state.routeOverlay.setMap === "function") {
|
||||
state.routeOverlay.setMap(null);
|
||||
} else if (typeof state.routeOverlay.remove === "function") {
|
||||
state.routeOverlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
if (state.routeDestinationOverlay) {
|
||||
if (typeof state.routeDestinationOverlay.setMap === "function") {
|
||||
state.routeDestinationOverlay.setMap(null);
|
||||
} else if (typeof state.routeDestinationOverlay.remove === "function") {
|
||||
state.routeDestinationOverlay.remove();
|
||||
}
|
||||
}
|
||||
|
||||
state.routeOverlay = null;
|
||||
state.routeDestinationOverlay = null;
|
||||
}
|
||||
|
||||
function fitMapToRoute(location) {
|
||||
const routePath = getRoutePath(location);
|
||||
if (!state.map || !state.mapProvider || routePath.length < 2) return false;
|
||||
|
||||
if (state.mapProvider === "kakao") {
|
||||
const bounds = new kakao.maps.LatLngBounds();
|
||||
for (const point of routePath) {
|
||||
bounds.extend(new kakao.maps.LatLng(point.latitude, point.longitude));
|
||||
}
|
||||
state.map.setBounds(bounds);
|
||||
return true;
|
||||
}
|
||||
|
||||
if (state.mapProvider === "leaflet") {
|
||||
state.map.fitBounds(
|
||||
L.latLngBounds(routePath.map((point) => [point.latitude, point.longitude])),
|
||||
{
|
||||
maxZoom: 13,
|
||||
padding: [48, 48]
|
||||
}
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
return false;
|
||||
}
|
||||
|
||||
function createDestinationMarkerElement(location) {
|
||||
const marker = document.createElement("div");
|
||||
marker.className = "route-destination-marker";
|
||||
marker.innerHTML = createDestinationMarkerHtml(location);
|
||||
return marker;
|
||||
}
|
||||
|
||||
function createDestinationMarkerHtml(location) {
|
||||
const metrics = getRouteMetrics(location);
|
||||
const destinationName = getDestinationName(location) || "목적지";
|
||||
const etaText = metrics ? formatEtaTime(metrics.estimatedArrivalAt) : "ETA";
|
||||
|
||||
return `
|
||||
<span class="route-destination-pin">도착</span>
|
||||
<strong>${escapeHtml(destinationName)}</strong>
|
||||
<small>${escapeHtml(etaText)}</small>
|
||||
`;
|
||||
}
|
||||
|
||||
function focusLocation(location) {
|
||||
loadLocationHistory(location);
|
||||
|
||||
if (!state.map || !state.mapProvider) return;
|
||||
|
||||
if (!renderRoutePreview(location, { fit: true })) {
|
||||
centerMapOnLocation(location, { zoom: true });
|
||||
}
|
||||
showInfoOverlay(location);
|
||||
}
|
||||
|
||||
@@ -819,6 +959,7 @@ function renderHistory(location, records) {
|
||||
function closeHistoryDrawer() {
|
||||
state.selectedDriverId = "";
|
||||
elements.historyDrawer.classList.add("is-hidden");
|
||||
clearRouteOverlay();
|
||||
}
|
||||
|
||||
function showInfoOverlay(location) {
|
||||
@@ -871,16 +1012,48 @@ function createPopupElement(location, status) {
|
||||
const destination = document.createElement("div");
|
||||
destination.textContent = getRouteSummary(location);
|
||||
|
||||
const routeInfo = createRouteInfoElement(location);
|
||||
|
||||
const receivedAt = document.createElement("div");
|
||||
receivedAt.textContent = `수신: ${formatDateTime(location.receivedAt)}`;
|
||||
|
||||
const movement = document.createElement("div");
|
||||
movement.textContent = `속도: ${formatSpeed(location.speed)}`;
|
||||
|
||||
container.append(title, driver, cargo, quantity, weight, destination, movement, receivedAt);
|
||||
container.append(title, driver, cargo, quantity, weight, destination, routeInfo, movement, receivedAt);
|
||||
return container;
|
||||
}
|
||||
|
||||
function createRouteInfoElement(location) {
|
||||
const routeInfo = document.createElement("div");
|
||||
routeInfo.className = "popup-route";
|
||||
|
||||
const metrics = getRouteMetrics(location);
|
||||
if (!metrics) {
|
||||
routeInfo.textContent = "경로: 목적지 좌표 대기";
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
const source = firstString(location.routeSource, location.routingProvider) || "simulated";
|
||||
routeInfo.append(
|
||||
createPopupRouteLine("남은 거리", formatDistance(metrics.remainingDistanceKm)),
|
||||
createPopupRouteLine("예상 도착", formatEtaTime(metrics.estimatedArrivalAt)),
|
||||
createPopupRouteLine("소요 시간", formatDuration(metrics.etaMinutes)),
|
||||
createPopupRouteLine("경로", source === "simulated" ? "모의 경로" : source)
|
||||
);
|
||||
return routeInfo;
|
||||
}
|
||||
|
||||
function createPopupRouteLine(label, value) {
|
||||
const row = document.createElement("div");
|
||||
const labelElement = document.createElement("span");
|
||||
labelElement.textContent = label;
|
||||
const valueElement = document.createElement("strong");
|
||||
valueElement.textContent = value;
|
||||
row.append(labelElement, valueElement);
|
||||
return row;
|
||||
}
|
||||
|
||||
async function fetchJson(url, options) {
|
||||
const response = await fetch(url, options);
|
||||
const data = await response.json().catch(() => ({}));
|
||||
@@ -1008,6 +1181,195 @@ function getRouteSummary(location) {
|
||||
return "도착지: 미등록";
|
||||
}
|
||||
|
||||
function getRouteProgressSummary(location) {
|
||||
const metrics = getRouteMetrics(location);
|
||||
if (!metrics) return "";
|
||||
return `남은 ${formatDistance(metrics.remainingDistanceKm)} · ETA ${formatEtaTime(metrics.estimatedArrivalAt)}`;
|
||||
}
|
||||
|
||||
function getRouteMetrics(location) {
|
||||
const routePath = getRoutePath(location);
|
||||
if (routePath.length < 2) return null;
|
||||
|
||||
const providedDistance = parseFiniteNumber(location.remainingDistanceKm || location.distanceKm || location.routeDistanceKm);
|
||||
const remainingDistanceKm = providedDistance || calculatePathDistanceKm(routePath);
|
||||
const providedEta = parseFiniteNumber(location.etaMinutes || location.estimatedMinutes || location.remainingMinutes);
|
||||
const speed = Math.max(24, parseFiniteNumber(location.speed) || 52);
|
||||
const etaMinutes = Math.max(2, Math.round(providedEta || (remainingDistanceKm / speed) * 60));
|
||||
const providedArrival = parseOptionalDate(location.estimatedArrivalAt || location.etaAt || location.arrivalAt);
|
||||
const estimatedArrivalAt = providedArrival || new Date(Date.now() + etaMinutes * 60 * 1000);
|
||||
|
||||
return {
|
||||
remainingDistanceKm,
|
||||
etaMinutes,
|
||||
estimatedArrivalAt
|
||||
};
|
||||
}
|
||||
|
||||
function getRoutePath(location) {
|
||||
const current = normalizeDisplayRoutePoint({
|
||||
latitude: location.latitude,
|
||||
longitude: location.longitude
|
||||
});
|
||||
if (!current) return [];
|
||||
|
||||
const providedPath = normalizeDisplayRoutePath(location.routePath || location.path || location.polyline);
|
||||
const destination = getDestinationCoordinate(location) ||
|
||||
(providedPath.length ? providedPath[providedPath.length - 1] : null);
|
||||
|
||||
if (providedPath.length >= 2) {
|
||||
return ensureRouteEndpoints(providedPath, current, destination);
|
||||
}
|
||||
|
||||
if (!destination) return [];
|
||||
return createFallbackRoutePath(current, destination, location.driverId || location.vehicleNo || "");
|
||||
}
|
||||
|
||||
function ensureRouteEndpoints(routePath, current, destination) {
|
||||
const normalizedPath = [...routePath];
|
||||
|
||||
if (calculateDistanceKm(current, normalizedPath[0]) > 0.1) {
|
||||
normalizedPath.unshift(current);
|
||||
}
|
||||
|
||||
if (destination && calculateDistanceKm(destination, normalizedPath[normalizedPath.length - 1]) > 0.1) {
|
||||
normalizedPath.push(destination);
|
||||
}
|
||||
|
||||
return normalizedPath;
|
||||
}
|
||||
|
||||
function getDestinationCoordinate(location) {
|
||||
const latitude = parseFiniteNumber(
|
||||
location.destinationLatitude || location.destinationLat || location.arrivalLatitude || location.arrivalLat
|
||||
);
|
||||
const longitude = parseFiniteNumber(
|
||||
location.destinationLongitude || location.destinationLng || location.destinationLon ||
|
||||
location.arrivalLongitude || location.arrivalLng || location.arrivalLon
|
||||
);
|
||||
|
||||
if (!Number.isFinite(latitude) || !Number.isFinite(longitude)) return null;
|
||||
return normalizeDisplayRoutePoint({ latitude, longitude });
|
||||
}
|
||||
|
||||
function getDestinationName(location) {
|
||||
return firstString(location.destination, location.arrival);
|
||||
}
|
||||
|
||||
function normalizeDisplayRoutePath(value) {
|
||||
if (!Array.isArray(value)) return [];
|
||||
return value.map(normalizeDisplayRoutePoint).filter(Boolean);
|
||||
}
|
||||
|
||||
function normalizeDisplayRoutePoint(value) {
|
||||
if (!value) return null;
|
||||
const latitude = Array.isArray(value)
|
||||
? parseFiniteNumber(value[0])
|
||||
: parseFiniteNumber(value.latitude ?? value.lat);
|
||||
const longitude = Array.isArray(value)
|
||||
? parseFiniteNumber(value[1])
|
||||
: parseFiniteNumber(value.longitude ?? value.lng ?? value.lon);
|
||||
|
||||
if (!Number.isFinite(latitude) || latitude < -90 || latitude > 90) return null;
|
||||
if (!Number.isFinite(longitude) || longitude < -180 || longitude > 180) return null;
|
||||
return { latitude, longitude };
|
||||
}
|
||||
|
||||
function createFallbackRoutePath(from, to, seed) {
|
||||
const deltaLatitude = to.latitude - from.latitude;
|
||||
const deltaLongitude = to.longitude - from.longitude;
|
||||
const length = Math.sqrt(deltaLatitude ** 2 + deltaLongitude ** 2) || 1;
|
||||
const offsetDirection = hashText(seed) % 2 === 0 ? 1 : -1;
|
||||
const offsetSize = Math.min(0.28, Math.max(0.02, length * 0.12));
|
||||
const perpendicularLatitude = (-deltaLongitude / length) * offsetSize * offsetDirection;
|
||||
const perpendicularLongitude = (deltaLatitude / length) * offsetSize * offsetDirection;
|
||||
|
||||
return [
|
||||
from,
|
||||
{
|
||||
latitude: interpolateNumber(from.latitude, to.latitude, 0.36) + perpendicularLatitude,
|
||||
longitude: interpolateNumber(from.longitude, to.longitude, 0.36) + perpendicularLongitude
|
||||
},
|
||||
{
|
||||
latitude: interpolateNumber(from.latitude, to.latitude, 0.72) - perpendicularLatitude * 0.35,
|
||||
longitude: interpolateNumber(from.longitude, to.longitude, 0.72) - perpendicularLongitude * 0.35
|
||||
},
|
||||
to
|
||||
];
|
||||
}
|
||||
|
||||
function calculatePathDistanceKm(routePath) {
|
||||
let distance = 0;
|
||||
for (let index = 1; index < routePath.length; index += 1) {
|
||||
distance += calculateDistanceKm(routePath[index - 1], routePath[index]);
|
||||
}
|
||||
return distance;
|
||||
}
|
||||
|
||||
function calculateDistanceKm(from, to) {
|
||||
const earthRadiusKm = 6371;
|
||||
const deltaLatitude = toRadians(to.latitude - from.latitude);
|
||||
const deltaLongitude = toRadians(to.longitude - from.longitude);
|
||||
const fromLatitude = toRadians(from.latitude);
|
||||
const toLatitude = toRadians(to.latitude);
|
||||
const a = Math.sin(deltaLatitude / 2) ** 2 +
|
||||
Math.cos(fromLatitude) * Math.cos(toLatitude) * Math.sin(deltaLongitude / 2) ** 2;
|
||||
return earthRadiusKm * 2 * Math.atan2(Math.sqrt(a), Math.sqrt(1 - a));
|
||||
}
|
||||
|
||||
function interpolateNumber(from, to, progress) {
|
||||
return from + (to - from) * progress;
|
||||
}
|
||||
|
||||
function toRadians(value) {
|
||||
return value * Math.PI / 180;
|
||||
}
|
||||
|
||||
function hashText(value) {
|
||||
let hash = 0;
|
||||
for (const character of String(value)) {
|
||||
hash = (hash * 31 + character.charCodeAt(0)) >>> 0;
|
||||
}
|
||||
return hash;
|
||||
}
|
||||
|
||||
function parseFiniteNumber(value) {
|
||||
if (value === undefined || value === null || value === "") return null;
|
||||
const parsed = Number(value);
|
||||
return Number.isFinite(parsed) ? parsed : null;
|
||||
}
|
||||
|
||||
function parseOptionalDate(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
return Number.isNaN(date.getTime()) ? null : date;
|
||||
}
|
||||
|
||||
function formatDistance(value) {
|
||||
const distance = Number(value);
|
||||
if (!Number.isFinite(distance)) return "-";
|
||||
if (distance < 1) return `${Math.round(distance * 1000)} m`;
|
||||
if (distance < 10) return `${distance.toFixed(1)} km`;
|
||||
return `${Math.round(distance)} km`;
|
||||
}
|
||||
|
||||
function formatDuration(minutes) {
|
||||
const duration = Math.max(0, Math.round(Number(minutes) || 0));
|
||||
if (duration < 60) return `약 ${duration}분`;
|
||||
const hours = Math.floor(duration / 60);
|
||||
const remainingMinutes = duration % 60;
|
||||
return remainingMinutes ? `약 ${hours}시간 ${remainingMinutes}분` : `약 ${hours}시간`;
|
||||
}
|
||||
|
||||
function formatEtaTime(value) {
|
||||
const date = value instanceof Date ? value : new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return "-";
|
||||
return new Intl.DateTimeFormat("ko-KR", {
|
||||
hour: "2-digit",
|
||||
minute: "2-digit"
|
||||
}).format(date);
|
||||
}
|
||||
|
||||
function firstString(...values) {
|
||||
for (const value of values) {
|
||||
if (value === undefined || value === null) continue;
|
||||
|
||||
@@ -724,6 +724,87 @@ button:hover {
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.popup-route {
|
||||
display: grid;
|
||||
gap: 4px;
|
||||
margin: 8px 0;
|
||||
padding: 8px;
|
||||
border: 1px solid rgba(31, 111, 235, 0.18);
|
||||
border-radius: 6px;
|
||||
background: rgba(31, 111, 235, 0.07);
|
||||
}
|
||||
|
||||
.popup-route div {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.popup-route span {
|
||||
color: var(--muted);
|
||||
font-size: 12px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
.popup-route strong {
|
||||
color: #174ea6;
|
||||
font-size: 12px;
|
||||
text-align: right;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-destination-marker,
|
||||
.route-destination-leaflet {
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.route-destination-marker,
|
||||
.route-destination-leaflet,
|
||||
.route-destination-leaflet > div {
|
||||
display: grid;
|
||||
grid-template-columns: auto minmax(0, 1fr);
|
||||
grid-template-rows: auto auto;
|
||||
align-items: center;
|
||||
column-gap: 7px;
|
||||
min-width: 112px;
|
||||
padding: 7px 9px;
|
||||
border: 1px solid rgba(23, 33, 43, 0.16);
|
||||
border-radius: 8px;
|
||||
background: rgba(255, 255, 255, 0.96);
|
||||
box-shadow: 0 10px 24px rgba(20, 40, 70, 0.18);
|
||||
}
|
||||
|
||||
.route-destination-pin {
|
||||
grid-row: 1 / span 2;
|
||||
display: grid;
|
||||
place-items: center;
|
||||
min-width: 34px;
|
||||
height: 24px;
|
||||
border-radius: 999px;
|
||||
background: #1f6feb;
|
||||
color: #ffffff;
|
||||
font-size: 11px;
|
||||
font-weight: 900;
|
||||
}
|
||||
|
||||
.route-destination-marker strong,
|
||||
.route-destination-leaflet strong {
|
||||
min-width: 0;
|
||||
overflow: hidden;
|
||||
color: var(--text);
|
||||
font-size: 12px;
|
||||
line-height: 1.15;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.route-destination-marker small,
|
||||
.route-destination-leaflet small {
|
||||
color: var(--muted);
|
||||
font-size: 11px;
|
||||
font-weight: 800;
|
||||
}
|
||||
|
||||
@media (max-width: 860px) {
|
||||
.app-shell {
|
||||
grid-template-rows: auto minmax(0, 1fr);
|
||||
|
||||
Reference in New Issue
Block a user