에히메현 양조장 지도
2026-08-12 ~ 2026-08-15
설명
2026년 10월 2일 마쓰야마 조자야마 공원에서 열리는 「ほろよいフェスタ 2026」 참가 19개 브랜드를 지도에 올린 페이지입니다. 양조장 위치, 맛 프로필, 우선 시도할 술, 그리고 부스에서 그대로 읽을 일본어 한 문장까지 한 화면에 넣었습니다.
행사가 저녁 7시 건배로 시작하는 야간 행사라, 현장에서 폰으로 꺼내 보는 걸 전제로 만들었습니다. 다크 전용인 이유도 그것.
시작점: Claude Design 시안
디자인은 Claude Design에서 먼저 잡았습니다. 결과물은 .dc.html이라는 자체 포맷으로,
템플릿에 {{ }} 바인딩과 sc-for / sc-if가 들어가고 로직은 DCLogic 클래스에 담긴, React 런타임 위에서 도는 형태입니다.
이걸 그대로 가져다 쓸 수는 없어서 — 런타임 의존이 생기니까 — 템플릿 문법만 버리고 데이터와 로직은 그대로 옮기는 방향으로 이식했습니다. Astro 7 minimal 템플릿에서 시작해 Leaflet만 npm으로 붙였습니다.
작업 내용
- Claude Design
.dc.html→ Astro 7 정적 사이트 이식 - 19개 브랜드 데이터를 타입 붙여 단일 소스로 정리 (
src/data/breweries.ts) - Leaflet 지도 + 겹치는 마커 밀어내기
- 지역 필터, 목록 ↔ 지도 ↔ 상세 패널 연동
- shadcn 다크 팔레트로 리디자인 (Tailwind v4, React 없이)
- Pretendard / Pretendard JP 분기
- Netlify 배포
상세 패널 19개를 빌드 시점에 다 그려두기
가장 크게 바꾼 판단입니다. 원본 시안은 선택된 양조장 하나의 상세를 클라이언트에서 템플릿으로 그렸는데,
Astro로 옮기면서 19개 패널을 전부 빌드 시점에 렌더링하고 JS는 hidden만 토글하게 했습니다.
{ BREWERIES.map((b) => ( <article data-id={b.id} hidden> {/* 이름, 맛 프로필, 스펙, 일본어 문장 ... */} </article> ))}클라이언트 템플릿 코드가 0줄이 되고, JS가 죽어도 내용은 남습니다. DOM은 19배로 늘지만 정적 페이지 한 장이라 체감이 없습니다.
목록도 같은 방식입니다. 행을 미리 그려두고 활성 상태만 aria-current로 바꿉니다.
덕분에 호버 처리에서 흔한 문제 — 재렌더로 DOM을 갈아끼우면 mouseleave가 안 와서 호버가 남는 것 — 이 아예 생기지 않습니다.
겹치는 마커 밀어내기
에히메 동부(사이조 · 니이하마)에 양조장이 몰려 있어서 줌 아웃하면 마커가 서로 겹칩니다. 화면 좌표로 투영한 뒤 최소 간격이 나올 때까지 서로 밀어내는 방식으로 처리했습니다.
export function spreadPoints<T extends SpreadPoint>(points: T[], minDistance: number, maxIterations = 60): T[] { for (let pass = 0; pass < maxIterations; pass++) { let overlapping = false;
for (let i = 0; i < points.length; i++) { for (let j = i + 1; j < points.length; j++) { const a = points[i]!; const b = points[j]!;
let dx = b.x - a.x; let dy = b.y - a.y; let distance = Math.hypot(dx, dy); if (distance >= minDistance) continue;
// 정확히 겹친 두 점은 밀어낼 방향이 없다. 인덱스로 결정되는 방향을 준다. if (distance < 0.01) { dx = Math.cos(i * 2.1); dy = Math.sin(i * 2.1); distance = 1; }
const ux = dx / distance; const uy = dy / distance; const push = (minDistance - distance) / 2 + 0.5;
// 상대가 고정점이면 이쪽이 두 배로 물러난다. if (!a.fixed) { a.x -= ux * push * (b.fixed ? 2 : 1); a.y -= uy * push * (b.fixed ? 2 : 1); } if (!b.fixed) { b.x += ux * push * (a.fixed ? 2 : 1); b.y += uy * push * (a.fixed ? 2 : 1); } overlapping = true; } }
if (!overlapping) break; }
return points;}행사장(祭 마커)은 fixed로 두고 주변 마커만 물러나게 했습니다. 회장 위치가 밀려나면 안 되니까.
줌이 바뀔 때마다 원래 위경도에서 다시 계산합니다. 직전 결과를 이어 쓰면 줌을 반복할수록 오차가 쌓여 마커가 실제 위치에서 떠내려갑니다.
Leaflet도 DOM도 필요 없는 순수 함수라, 자체 점검도 여기에 걸어뒀습니다.
const points = [{ x: 100, y: 100 }, { x: 100, y: 100 }];spreadPoints(points, 34);const gap = Math.hypot(points[0]!.x - points[1]!.x, points[0]!.y - points[1]!.y);assert.ok(Number.isFinite(gap), '완전히 겹친 점에서 NaN이 나옴');assert.ok(gap >= 34, `완전히 겹친 두 점이 최소 간격 미달 (${gap})`);Node가 .ts를 그대로 실행하니 테스트 프레임워크 없이 node src/check.ts 한 줄로 끝납니다.
데이터 쪽도 같이 봅니다 — 19곳인지, id가 겹치지 않는지, 좌표가 에히메 경계 안인지, 맛 프로필 값이 1~5 범위인지.
다크로 갈아엎기
처음엔 크림색 배경에 명조체를 쓴 일본 에디토리얼 느낌이었습니다. 이걸 shadcn 다크로 바꿨습니다.
Tailwind v4 + shadcn 토큰만 도입하고 React는 붙이지 않았습니다. 이 페이지에서 Radix가 실제로 필요한 건 Select 하나뿐인데, 그거 하나 때문에 정적 페이지에 React 런타임을 얹을 이유가 없었습니다. Card · Badge · Progress · Button은 전부 순수 마크업으로 충분합니다.
@theme { --font-sans: 'Pretendard Variable', Pretendard, system-ui, sans-serif; --font-jp: 'Pretendard JP Variable', 'Pretendard Variable', system-ui, sans-serif;
--color-background: oklch(0.145 0 0); --color-card: oklch(0.205 0 0); --color-border: oklch(1 0 0 / 10%); --color-muted-foreground: oklch(0.708 0 0);
--color-match-1: oklch(0.75 0.16 60); --color-match-2: oklch(0.7 0.13 235); --color-match-3: oklch(0.72 0.03 90);}색만 바꾼다고 끝나지 않았던 것들:
지도 타일. CARTO light_all → dark_all. 이걸 빼먹으면 검은 UI 안에 흰 지도만 덩그러니 떠서 제일 먼저 눈에 띕니다.
Leaflet이 만드는 줌 컨트롤 · 툴팁 · attribution도 Astro/Tailwind 바깥이라 따로 다크로 맞춰야 했습니다.
마커 글자색. 우선순위 3색을 다크 대비에 맞춰 명도 0.72~0.75로 올렸더니, 그 위의 흰 글자가 대비 2.6:1밖에 안 나왔습니다. 밝은 원에는 어두운 글자가 맞습니다. 흰색 → 배경색(거의 검정)으로 바꿔서 7:1 이상 확보.
색의 출처. 마커 색은 JS가 만드는 문자열이라 CSS와 이중 정의되기 쉽습니다. CSS 토큰을 단일 소스로 두고 JS가 읽어 쓰게 했습니다.
const theme = getComputedStyle(document.documentElement);const token = (name: string) => theme.getPropertyValue(name).trim();
const MATCH_COLOR: Record<MatchRank, string> = { 1: token('--color-match-1'), 2: token('--color-match-2'), 3: token('--color-match-3'),};폰트. Pretendard와 Pretendard JP를 같이 받고, 일본어가 확실한 필드(브랜드 일본어명, 주소, 일본어 문장)에만 lang="ja"를 붙여 분기했습니다.
가나 · 한자 글리프가 달라지고, 스크린리더 발음도 같이 정리됩니다.
:lang(ja) { font-family: var(--font-jp);}
목록이 조용히 죽었던 일
리디자인 중에 제일 뼈아팠던 것.
스타일을 갈아엎으면서 목록 행의 .row 클래스가 사라졌는데, JS는 여전히 .row로 쿼리하고 있었습니다.
querySelectorAll은 빈 배열을 돌려줄 뿐 아무 에러도 내지 않아서 — 목록 카운트가 0 / 19가 되고 지도가 반응을 안 하는데
콘솔은 깨끗했습니다.
원인은 스타일 클래스를 JS 훅으로 쓴 것이었습니다. 스타일은 리디자인마다 갈리고, 셀렉터는 같이 갈리지 않습니다.
// 스타일 클래스가 아니라 구조로 잡는다. 클래스는 리디자인 때 갈리고, 셀렉터는 조용히 죽는다.const rowEls = Array.from(document.querySelectorAll<HTMLButtonElement>('#list button[data-id]'));const detailEls = Array.from(document.querySelectorAll<HTMLElement>('#details article[data-id]'));
if (rowEls.length === 0 || detailEls.length === 0) { throw new Error(`목록/상세 엘리먼트를 못 찾음 (rows=${rowEls.length}, details=${detailEls.length})`);}구조 기반 셀렉터로 바꾸고, 못 찾으면 즉시 던지게 했습니다. 다음에 또 죽으면 조용히가 아니라 콘솔에 터집니다.
같은 결로 원본 시안에서 하나 더 고쳤습니다. 맛 프로필을 id로 조회해 쓰는데 값이 없으면 상세 패널을 그리는 도중에 터지는 구조였습니다. 데이터를 합치는 시점으로 앞당겨서, 문제가 있으면 페이지가 뜨기 전에 알 수 있게 했습니다.
export const BREWERIES: Brewery[] = PROFILES.map((profile) => { const taste = TASTES[profile.id]; // 원본 디자인은 맛 프로필이 비면 상세 패널 렌더링 중에 터졌다. 빌드 때 잡히도록 앞당긴다. if (!taste) throw new Error(`맛 프로필 누락: brewery id=${profile.id} (${profile.ko})`); return { ...profile, ...taste };});모바일
현장에서 쓸 물건이라 폰이 본체입니다. 768px 이하에서는 목록과 지도가 각각 한 줄을 차지하도록 했습니다. 아이패드 세로가 정확히 768이라 그 경계를 포함시키려고 브레이크포인트를 769px로 따로 뒀습니다.
@theme { /* 768px 이하에서는 목록과 지도를 각각 한 줄씩 쌓는다 (아이패드 세로 768 포함). */ --breakpoint-split: 769px;}
선택 취소도 뒤늦게 넣었습니다. 한 번 고르면 풀 방법이 없었거든요.
버튼 · Esc · 같은 항목 재탭 세 경로를 뒀는데, 터치에는 호버가 없어서 재탭이 사실상 유일한 취소 수단입니다.
여기서 하나 걸렸던 것 — 취소할 때 pinId만 비우면 안 됩니다.
터치에서는 mouseover가 한 번 발생하고 mouseout이 영영 안 오는 경우가 있어서, 남아 있는 호버 상태 때문에 취소를 눌러도 패널이 안 닫힙니다. 둘 다 비워야 합니다.
function pin(id: number) { const alreadyPinned = pinId === id; pinId = alreadyPinned ? null : id; // 터치에서 한 번 발생하고 끝나는 mouseover 가 남아 있으면 취소가 먹지 않는다. hoverId = null; // ...}정리
- 시안의 데이터와 로직은 살리고 템플릿 문법만 버리는 이식이 깔끔했습니다. 런타임 의존이 안 남습니다.
- 정적으로 그릴 수 있는 건 다 정적으로. 클라이언트 템플릿을 안 쓰니 호버 · 포커스 같은 상태 처리가 알아서 정리됩니다.
- 다크 전환은 색 교체가 아니라 대비 재설계입니다. 밝은 면 위의 글자색까지 다시 봐야 합니다.
- 스타일 클래스를 JS 훅으로 쓰지 말 것. 조용히 죽는 종류의 버그를 만듭니다.