/**
* 정거장 지도 — 타일 를 격자로 깔고 그 위에 번호 핀과 경로선을 얹는다.
*
* ★ 왜 지도 라이브러리를 안 쓰나
* ① 발행물은 프리렌더로 굽는 정적 HTML 이다. Leaflet·네이버 SDK 는 브라우저에서만 도니
* 구운 문서에는 지도가 없다 — 이 사이트의 존재 이유가 인용인데 인용될 것이 안 남는다.
* ② 네이버·카카오 JS SDK 는 **키에 웹 도메인을 등록**해야 뜬다. 사장님마다 도메인이
* 붙는 순간 사이트 수만큼 등록해야 해서 무너진다. 타일 이미지는 키도 등록도 없다.
*
* ★ 경로선은 **직선**이다. 도로를 따라가지 않는다 — 실제 길은 핀을 눌러 네이버 지도에서 본다.
* 도로 경로를 그리려면 길찾기 API 를 발행 때 서버에서 불러 좌표열을 payload 에 구워야 한다.
* ★ 타일은 회색으로 눕힌다. 읽어야 하는 건 지도가 아니라 **번호와 순서**다.
*/
import type {PlannerStop} from '@o2o/shared';
import {useSite} from '@site/lib/site-context';
import {naverDirectionsUrl, naverRouteUrl} from '@site/lib/format';
import {ITEM_ACCENT, ITEM_BORDER, ITEM_INVERSE_INK} from './common';
const TILE = 256;
/** 승차권 카드 안쪽 폭(320 − 좌우 테두리). 카드가 고정 폭이라 지도도 고정 폭이다 —
* 폭이 정해져야 타일 격자를 서버에서 미리 계산해 HTML 에 박을 수 있다. */
const WIDTH = 318;
const HEIGHT = 168;
/** 가장자리 여백. 핀이 타일 밖으로 반쯤 잘려 나가는 걸 막는다. */
const PAD = 34;
const MAX_ZOOM = 16;
const MIN_ZOOM = 4;
type XY = {x: number; y: number};
/** 위경도 → 그 배율에서의 세계 픽셀 좌표(웹 메르카토르). */
function project(lat: number, lng: number, zoom: number): XY {
const n = TILE * 2 ** zoom;
const rad = (lat * Math.PI) / 180;
return {
x: ((lng + 180) / 360) * n,
y: ((1 - Math.log(Math.tan(rad) + 1 / Math.cos(rad)) / Math.PI) / 2) * n,
};
}
/** 모든 점이 한 화면에 들어가는 가장 가까운 배율. 한 점뿐이면 15(골목이 보이는 배율). */
function fitZoom(points: {lat: number; lng: number}[]): number {
if (points.length < 2) return 15;
for (let zoom = MAX_ZOOM; zoom > MIN_ZOOM; zoom -= 1) {
const xy = points.map((p) => project(p.lat, p.lng, zoom));
const w = Math.max(...xy.map((p) => p.x)) - Math.min(...xy.map((p) => p.x));
const h = Math.max(...xy.map((p) => p.y)) - Math.min(...xy.map((p) => p.y));
if (w <= WIDTH - PAD * 2 && h <= HEIGHT - PAD * 2) return zoom;
}
return MIN_ZOOM;
}
type Place = {name: string; lat: number; lng: number};
type Pin = {label: string; lat: number; lng: number; stop: PlannerStop; from?: Place};
/**
* 좌표가 있는 칸만 핀으로. 번호는 **시간표의 칸 번호**다 — 좌표가 없는 칸이 있으면
* 번호가 중간에 비는데, 그게 맞다(핀을 못 찍는 곳을 찍은 척하지 않는다).
*
* ★ 핀마다 `from` 을 들려 보낸다. 손님이 핀을 누르는 이유는 "여기가 어디냐"가 아니라
* **"앞 칸에서 여기까지 어떻게 가냐"** 다. 시간표가 이미 "↓ 45분 이동"이라고 말해 놓고
* 정작 그 45분의 길은 어디에도 없었다. 첫 칸의 앞 지점은 업소다.
* ★ 업소는 **핀으로 세우지 않는다.** 지도에 서는 것은 시간표의 칸뿐이다 — 번호가 없는 점이
* 끼면 그게 몇 번째인지 셀 수 없고, 업소는 첫 칸의 출발지로만 쓰이면 충분하다.
*/
function pinsOf(stops: PlannerStop[], origin?: Place): Pin[] {
// 길찾기 화면에 박히는 이름은 지도 검색어를 먼저 쓴다 — 시간표의 "저녁 · 빈해원" 은
// 손님에게 하는 말이지 지도에 넣을 상호가 아니다.
const at = (stop: PlannerStop): Place | undefined =>
stop.latitude != null && stop.longitude != null
? {name: stop.searchQuery ?? stop.name, lat: stop.latitude, lng: stop.longitude}
: undefined;
const pins: Pin[] = [];
let previous = origin;
stops.forEach((stop, index) => {
const here = at(stop);
if (!here) return;
// 앞 칸이 같은 자리면(체크인 칸이 업소인 날) 구간 길찾기가 제자리걸음이 된다 — 안 건다.
pins.push({
label: String(index + 1),
...here,
stop,
from: previous && !same(previous, here) ? previous : undefined,
});
previous = here;
});
return pins;
}
function same(a: Place, b: Place): boolean {
return Math.abs(a.lat - b.lat) < 1e-5 && Math.abs(a.lng - b.lng) < 1e-5;
}
/**
* 겹치는 핀을 떼어놓는다.
*
* ★ 왜 필요한가 — 하루의 폭이 곧 배율이다. 업소에서 선유도까지 27km 인 날은 배율이 내려가고,
* 그러면 2km 떨어진 선유도와 장자도가 한 점으로 겹쳐 뒤 핀이 앞 핀을 덮는다.
* 그 배율에서 27px 는 실제로 수백 m 라, 밀어내도 지도가 거짓말을 하지는 않는다.
* ★ 같은 자리에 서는 칸(체크인·귀가)은 여기 오기 전에 이미 핀 하나로 합쳐졌다 —
* 그건 진짜 같은 곳이라 떼어놓으면 안 된다.
*/
function separate(points: XY[]): XY[] {
// 핀 지름이 22px 이라 중심 간격이 그보다 좁으면 뒤 핀이 앞 핀을 덮는다.
const GAP = 27;
const out = points.map((p) => ({...p}));
for (let i = 1; i < out.length; i += 1) {
for (let pass = 0; pass < 6; pass += 1) {
const hit = out
.slice(0, i)
.find((q) => Math.hypot(out[i].x - q.x, out[i].y - q.y) < GAP);
if (!hit) break;
const dx = out[i].x - hit.x;
const dy = out[i].y - hit.y;
// 완전히 같은 점이면 방향이 없다 — 오른쪽으로 민다.
const d = Math.hypot(dx, dy) || 1;
const push = (GAP - d) / d;
out[i].x += (dx || 1) * push;
out[i].y += dy * push;
}
}
return out;
}
/** 핀을 누르면 **앞 칸에서 여기까지의 길찾기**가 열린다. 앞 칸이 없으면(업소 핀) 오시는 길이다. */
function pinUrl(pin: Pin): string {
if (pin.from) return naverRouteUrl(pin.from, {name: pin.stop.name, lat: pin.lat, lng: pin.lng});
return naverDirectionsUrl(pin.stop.searchQuery ?? pin.stop.name, pin.lat, pin.lng);
}
export function RouteMap({stops}: {stops: PlannerStop[]}) {
const {place} = useSite();
const origin =
place.latitude != null && place.longitude != null
? {name: place.name, lat: place.latitude, lng: place.longitude}
: undefined;
const pinned = stops.filter((s) => s.latitude != null && s.longitude != null);
// ★ 좌표가 있는 칸이 하나뿐인데 칸은 여럿이면 지도를 그리지 않는다. 하루를 한 곳으로
// 보여 주는 그림이 되어, 좌표를 못 채운 것이 '거기밖에 안 간다'로 읽힌다.
// 칸이 하나뿐인 날(업소에서 한 곳만 다녀오는 날)은 예외다.
if (pinned.length === 0 || (pinned.length === 1 && stops.length > 1)) return null;
const pins = pinsOf(stops, origin);
const zoom = fitZoom(pins);
const xy = separate(pins.map((p) => project(p.lat, p.lng, zoom)));
const cx = (Math.min(...xy.map((p) => p.x)) + Math.max(...xy.map((p) => p.x))) / 2;
const cy = (Math.min(...xy.map((p) => p.y)) + Math.max(...xy.map((p) => p.y))) / 2;
const originX = cx - WIDTH / 2;
const originY = cy - HEIGHT / 2;
const span = 2 ** zoom;
const tiles: {key: string; url: string; left: number; top: number}[] = [];
for (let tx = Math.floor(originX / TILE); tx <= Math.floor((originX + WIDTH) / TILE); tx += 1) {
for (let ty = Math.floor(originY / TILE); ty <= Math.floor((originY + HEIGHT) / TILE); ty += 1) {
if (ty < 0 || ty >= span) continue;
// 날짜변경선을 넘는 격자는 x 를 감아 준다. 국내에서는 안 걸리지만 계산은 맞춰 둔다.
const wrapped = ((tx % span) + span) % span;
tiles.push({
key: `${tx}-${ty}`,
url: `https://tile.openstreetmap.org/${zoom}/${wrapped}/${ty}.png`,
left: tx * TILE - originX,
top: ty * TILE - originY,
});
}
}
// 경로선은 핀을 순서대로 잇는다 — 떼어놓은 자리(xy)를 쓴다. 선이 핀 밑에서 어긋나면
// 어느 핀이 어느 다리인지 눈으로 못 따라간다.
const line = xy.map((p) => `${(p.x - originX).toFixed(1)},${(p.y - originY).toFixed(1)}`).join(' ');
return (