에디터에서 본 화면과 발행된 화면이 달랐다. 렌더러를 두 벌 들고 있었기 때문이다 —
캔버스는 `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>
241 lines
10 KiB
TypeScript
241 lines
10 KiB
TypeScript
/**
|
||
* 정거장 지도 — 타일 <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>
|
||
);
|
||
}
|