/** * 정거장 지도 — 타일 를 격자로 깔고 그 위에 번호 핀과 경로선을 얹는다. * * ★ 왜 지도 라이브러리를 안 쓰나 * ① 발행물은 프리렌더로 굽는 정적 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 (
{tiles.map((tile) => ( ))} {pins.map((pin, index) => { const p = xy[index]; return ( {pin.label} ); })} {/* 타일 출처 표기는 OSM 이용 조건이다. 지우지 않는다. */} © OpenStreetMap
); }