o2o-site-AEO/solution/site/src/sections/items/RouteMap.tsx
Mina Choi b1386dd3ce [refactor] solution,nginx: 에디터와 발행본을 한 렌더러로 — 보는 그대로 나간다
에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `builder/canvas/variants/*` 25종, 발행본은 `site/src/sections/*`.
Playwright 로 재 보니 아예 다른 물건이었다(2026-09-09, 1024px):

  발행본 15섹션 · 에디터 12섹션 · 겹치는 건 4개뿐, 이름도 달랐다
  (gallery↔photos · location↔map · guide↔local)
  겹치는 4개조차 높이가 달랐다(info 488↔535 · booking 242↔487 · itinerary 881↔383)

소스를 하나로 모은다. 편집·미리보기 둘 다 발행본 렌더러가 그린다.

**데이터도 한 벌** — `GET /v1/place/{id}/site/preview` 가 발행이 굽는 것과 **같은 함수**
(`build_snapshot` → `to_site_payload`)로 payload 를 만든다. DB 도 파일도 건드리지 않는다.

**왜 iframe 인가** — 컴포넌트만 같게 해서는 안 됐다. 미디어 쿼리는 창 폭을 보는데 실제
사이트 폭은 그 안의 프레임이라, 그리드 컬럼 수가 어긋나 섹션이 두 배씩 길어졌다
(festival 2560→6027 · guide 1168→2168). iframe 은 자체 뷰포트를 가져 발행본과 같은 폭을 본다.
폭만이 아니라 **높이도** 준다 — 히어로가 `clamp(24rem, 62vh, 36rem)` 이라 낮은 iframe 에서는
하한에 걸렸다(384 ↔ 발행본 576). 자리에 안 들어가면 transform 으로 줄인다: 크기는 그대로,
그림만 줄여야 미디어 쿼리가 안 흔들린다.

**색·서체도 한 벌** — `themeVars(payload)` · `fontHref(payload)`. 셸에는 발행본 `<head>` 의
폰트 링크가 없어 글자만 기본 산세리프로 떨어졌다(지오메트리는 같은데 픽셀 차이 92%).

**에디터가 저장된 템플릿을 안 읽던 것** — `applyTheme` 이 섹션·색팔레트는 되살리는데
templateId 를 빠뜨렸다. templateId 는 theme JSON 이 아니라 `sites.template_id` **컬럼**이라
저장 경로가 다른데 읽는 쪽이 theme 만 봤다. 사장님이 '옛 항구' 를 골라 발행해도 다시
들어오면 편집 화면만 흰 바탕·고딕이었다.

**고르기는 iframe 안에서** — 같은 오리진이라 안쪽 문서에 직접 리스너를 건다. 어느 섹션인지는
`data-editor-id` 로 안다(화면 id `gallery` ↔ 설정 id `photos`; `display:contents` 라 레이아웃
무영향). 표시는 outline 이다 — 상자 크기를 바꾸지 않아 발행본과 픽셀이 그대로다.

곁들여 정리한 것
- 켤 수 없는 섹션 둘(`pricing`·`planner`)을 뗐다 — 기본표에도 [+섹션 추가]에도 없고 DB 참조 0건.
- 반대로 `event`(소식)는 기본표가 켜서 **발행되는데** 채울 UI 가 없었다. 명세를 넣는다.
  이 아이템만 프롬프트가 "찾아라" 가 아니라 **"옮겨 적어라"** 다 — 이 가게에서 지금 하는
  일이라 모델이 알 수 없고, 지어내면 손님이 없는 행사를 보고 찾아온다.
- 예약 버튼이 "네이버 예약 예약" 이었다. `{bookingLabel} 예약` 을 13개 파일에서 각자 이어
  붙이고 있었다 — `bookingActionLabel()` 하나로 모은다.
- `solution/site` 의 별칭을 `@` → `@site` 로 옮겼다(60파일 195건). 두 앱이 '@' 를 각자 자기
  src 로 두면 발행본 컴포넌트를 빌더에서 부를 때 **조용히 다른 파일을 잡는다.**

검증(Playwright, 같은 사업장·1024px):
  섹션 15 = 15 · 순서 일치 · **한쪽에만 있는 섹션 0개**
  15개 전부 높이·글자 수·제목이 정확히 같다
  편집·미리보기·발행본 셋 다 --tpl-bg #e4dac0 · Gugi
  `/preview` ↔ 발행본 문서 높이 9029 = 9029, 픽셀 차이 2.88%(축제 카드 지연 로딩 타이밍)
tsc -b 통과 · eslint 통과.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-09 17:09:28 +09:00

241 lines
10 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 정거장 지도 — 타일 <img> 를 격자로 깔고 그 위에 번호 핀과 경로선을 얹는다.
*
* ★ 왜 지도 라이브러리를 안 쓰나
* ① 발행물은 프리렌더로 굽는 정적 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 (
<div
className="relative mt-3.5 overflow-hidden border-y"
style={{height: HEIGHT, borderColor: ITEM_BORDER, backgroundColor: '#e8e5df'}}
>
{tiles.map((tile) => (
<img
key={tile.key}
src={tile.url}
alt=""
width={TILE}
height={TILE}
loading="lazy"
decoding="async"
className="pointer-events-none absolute max-w-none"
style={{left: tile.left, top: tile.top, filter: 'grayscale(1) contrast(0.95) opacity(0.55)'}}
/>
))}
<svg
className="pointer-events-none absolute inset-0"
width={WIDTH}
height={HEIGHT}
viewBox={`0 0 ${WIDTH} ${HEIGHT}`}
>
<polyline
points={line}
fill="none"
stroke={ITEM_ACCENT}
strokeWidth="2"
strokeDasharray="5 4"
strokeLinecap="round"
strokeLinejoin="round"
opacity="0.85"
/>
</svg>
{pins.map((pin, index) => {
const p = xy[index];
return (
<a
key={pin.label}
href={pinUrl(pin)}
target="_blank"
rel="noopener noreferrer"
title={pin.from ? `${pin.from.name} → ${pin.stop.name} 길찾기` : `${pin.stop.name} — 네이버 지도`}
className="absolute grid h-[22px] min-w-[22px] -translate-x-1/2 -translate-y-1/2 place-items-center rounded-full px-1.5 text-[11px] font-bold whitespace-nowrap shadow-sm transition-transform hover:scale-110"
style={{
left: p.x - originX,
top: p.y - originY,
backgroundColor: ITEM_ACCENT,
color: ITEM_INVERSE_INK,
border: `1.5px solid ${ITEM_INVERSE_INK}`,
}}
>
{pin.label}
</a>
);
})}
{/* 타일 출처 표기는 OSM 이용 조건이다. 지우지 않는다. */}
<span className="absolute right-1 bottom-0.5 bg-white/70 px-1 text-[9px] leading-tight text-stone-600">
© OpenStreetMap
</span>
</div>
);
}