도메인별 스키마(company·place·fact·local·site·job)를 걷어내고 public 한 벌로 폈다.
스키마 한정자가 붙은 순간부터 ORM·raw SQL·테스트 픽스처가 각자 그 이름을 들고 다녀야 했다.
- 공용 콘텐츠를 한 테이블로 되돌린다. spots·region_stories 를 따로 파 놓고 보니
같은 성격이 세 곳으로 갈라져 있었다 — `area_contents` 가 처음부터 content_type 으로
종류를 가르는 설계였고 그걸 쓰면 됐다. 관계(거리·숨김)만 `place_area_refs` 로 남긴다.
- migrations/ + scripts/migrate.py: `init.sql` 은 **DB 를 처음 만들 때만** 돈다. 파일에
컬럼을 더해도 이미 데이터가 든 DB 에는 반영되지 않는다 — 실제로 TourAPI 가 주변 정보를
받아 와도 저장할 곳이 없어 축제·맛집이 0건이었고, 화면에는 "그냥 안 나오는 것" 으로만 보였다.
DECISIONS.md 가 예고한 그대로다("운영 DB 가 생기는 순간 다시 필요해진다").
Alembic 을 쓰지 않는 이유는 스키마 정의가 이미 두 곳(ORM·init.sql)이라 세 번째를
더하면 어긋날 자리가 하나 더 생기기 때문이다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
136 lines
7.4 KiB
TypeScript
136 lines
7.4 KiB
TypeScript
/** "280000" → "280,000원". 숫자가 아니면 원문 그대로 돌려준다. */
|
|
export function formatWon(value: string | number | null | undefined): string {
|
|
if (value == null || value === '') return '';
|
|
const num = typeof value === 'string' ? Number(value.replace(/,/g, '')) : value;
|
|
if (!Number.isFinite(num)) return String(value);
|
|
return `${num.toLocaleString('ko-KR')}원`;
|
|
}
|
|
|
|
/** ISO → "2026년 8월 27일". 화면에 "언제 기준"을 찍는 데 쓴다. */
|
|
export function formatKoreanDate(iso: string | null | undefined): string {
|
|
if (!iso) return '';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
return `${date.getFullYear()}년 ${date.getMonth() + 1}월 ${date.getDate()}일`;
|
|
}
|
|
|
|
/**
|
|
* ISO → "2026-09-04". 화면에 찍는 날짜다.
|
|
*
|
|
* ★ 왜 한글 표기가 아닌가 (2026-09-04, AEO 진단 '본문 날짜 표기 없음')
|
|
* 답변 엔진은 본문에서 YYYY-MM-DD 를 찾아 최신성을 매긴다. "2026년 9월 4일" 은
|
|
* 사람은 읽지만 그쪽은 못 읽고, `<time datetime>` 속성에만 있으면 화면에 없는 값이 된다.
|
|
* ★ 시간대는 한국이다. toISOString() 은 UTC 라 한국 자정 뒤에 하루가 밀린다.
|
|
*/
|
|
export function isoDate(iso: string | null | undefined): string {
|
|
if (!iso) return '';
|
|
const date = new Date(iso);
|
|
if (Number.isNaN(date.getTime())) return '';
|
|
return date.toLocaleDateString('sv-SE', {timeZone: 'Asia/Seoul'});
|
|
}
|
|
|
|
/** 네이버 검색으로 보내는 링크. 우리가 지어낸 URL 을 링크하지 않기 위한 통로. */
|
|
export function naverSearchUrl(query: string): string {
|
|
return `https://search.naver.com/search.naver?query=${encodeURIComponent(query)}`;
|
|
}
|
|
|
|
/**
|
|
* 유튜브 **검색** 주소.
|
|
*
|
|
* ★ 영상 id 를 박지 않는다. 옛 가요는 올린 사람이 지우거나 저작권 신고로 내려가는 일이 잦고,
|
|
* 죽은 링크의 책임은 이 홈페이지가 진다(축제 링크를 검색으로만 보내는 것과 같은 이유).
|
|
* 검색은 곡이 어디로 옮겨 가도 찾아 준다.
|
|
*/
|
|
export function youtubeSearchUrl(query: string): string {
|
|
return `https://www.youtube.com/results?search_query=${encodeURIComponent(query)}`;
|
|
}
|
|
|
|
/**
|
|
* 카카오맵 **길찾기**. 좌표가 있으면 목적지가 찍힌 길찾기 화면으로 바로 열린다.
|
|
*
|
|
* ★ 좌표가 없으면 검색으로 떨어진다 — 없는 좌표를 지어내면 엉뚱한 곳으로 안내하게 되고,
|
|
* 그건 검색 결과를 한 번 더 누르게 하는 것보다 훨씬 나쁘다.
|
|
*/
|
|
export function kakaoDirectionsUrl(name: string, lat?: number, lng?: number): string {
|
|
if (lat == null || lng == null) {
|
|
return `https://map.kakao.com/link/search/${encodeURIComponent(name)}`;
|
|
}
|
|
return `https://map.kakao.com/link/to/${encodeURIComponent(name)},${lat},${lng}`;
|
|
}
|
|
|
|
/**
|
|
* 네이버지도 **길찾기**. v5 주소 형식은 `/p/directions/{출발}/{도착}/{경유}/{수단}` 이고,
|
|
* 각 지점은 `경도,위도,이름` 이다. 출발지를 `-` 로 비워 두면 네이버가 방문자의 현재 위치를
|
|
* 채운다 — 우리가 방문자 위치를 알 필요도, 물어볼 필요도 없다.
|
|
*
|
|
* ★ 수단을 대중교통(transit)으로 고정하는 이유: 차로 오는 사람은 화면에서 한 번만 바꾸면 되지만,
|
|
* 대중교통으로 오는 사람에게는 이 화면이 곧 답이다. 기본값은 덜 아는 쪽에 맞춘다.
|
|
*/
|
|
/**
|
|
* 네이버지도 길찾기 — **구간**. 앞 정거장에서 이 정거장까지의 한 다리를 연다.
|
|
*
|
|
* ★ `naverDirectionsUrl` 과 갈라 두는 이유: 저쪽은 "방문자 → 업소"라 출발지가 비어야 맞고
|
|
* (네이버가 현재 위치를 채운다), 여기는 **일정 안의 두 지점**이라 출발지를 우리가 안다.
|
|
* 출발지를 비우면 "지금 내 위치에서 장자도" 가 열리는데, 손님이 알고 싶은 건
|
|
* "선유도에서 장자도" 다.
|
|
*/
|
|
export function naverRouteUrl(
|
|
from: {name: string; lat: number; lng: number},
|
|
to: {name: string; lat: number; lng: number},
|
|
): string {
|
|
const point = (p: {name: string; lat: number; lng: number}) =>
|
|
`${p.lng},${p.lat},${encodeURIComponent(p.name)}`;
|
|
// ★ 수단은 차(car)다. 오시는 길(naverDirectionsUrl)이 대중교통인 것과 반대인데,
|
|
// 그건 "방문자 → 업소"라 대중교통을 아는 사람이 적어서였고, 여기는 일정 안의 구간이다 —
|
|
// 선유도·장자도처럼 버스로는 답이 안 나오는 다리가 섞여 있다.
|
|
return `https://map.naver.com/p/directions/${point(from)}/${point(to)}/-/car`;
|
|
}
|
|
|
|
export function naverDirectionsUrl(name: string, lat?: number, lng?: number): string {
|
|
if (lat == null || lng == null) {
|
|
return `https://map.naver.com/p/search/${encodeURIComponent(name)}`;
|
|
}
|
|
return `https://map.naver.com/p/directions/-/${lng},${lat},${encodeURIComponent(name)}/-/transit`;
|
|
}
|
|
|
|
/**
|
|
* 지도 그림(OpenStreetMap 임베드) 주소.
|
|
*
|
|
* ★ 왜 카카오·네이버 지도가 아닌가
|
|
* 둘 다 지도를 그리려면 키가 필요하다(카카오는 JS 키, 네이버 정적지도는 NCP 헤더 인증).
|
|
* 그런데 이 렌더러는 **런타임 환경변수를 쓰지 않는다**(site/.env.example) — payload 하나로
|
|
* 같은 HTML 이 재현되어야 하기 때문이다. 키를 읽는 순간 그 규칙이 깨진다.
|
|
* OSM 임베드는 키가 없고 좌표만으로 만들어져 payload 만으로 재현된다.
|
|
*
|
|
* ★ 지도는 사람이 보는 것이고, 기계가 읽을 위치 정보는 이미 따로 나간다
|
|
* (JSON-LD 의 geo·PostalAddress, 화면의 <address>). 그래서 지도가 iframe 이어도
|
|
* AEO 는 손해 보지 않는다.
|
|
*/
|
|
export function osmEmbedUrl(lat: number, lng: number, aspect = 16 / 9, halfLat = 0.0022): string {
|
|
// ★ bbox 의 종횡비를 iframe 과 맞춰야 한다. OSM 임베드는 bbox 모양대로 지도 캔버스를 잡고
|
|
// 남는 자리를 흰색으로 둔다 — 안 맞추면 넓은 프레임 왼쪽 3분의 1만 지도가 그려진다(실측).
|
|
// ★ 경도는 cos(위도) 만큼 좁다. 이 보정을 빼면 한국(위도 37도)에서 가로로 눌린 지도가 나온다.
|
|
const cos = Math.max(Math.cos((lat * Math.PI) / 180), 0.01);
|
|
const halfLng = (aspect * halfLat) / cos;
|
|
const bbox = [lng - halfLng, lat - halfLat, lng + halfLng, lat + halfLat]
|
|
.map((n) => n.toFixed(6))
|
|
.join(',');
|
|
return `https://www.openstreetmap.org/export/embed.html?bbox=${bbox}&layer=mapnik&marker=${lat},${lng}`;
|
|
}
|
|
|
|
/**
|
|
* 티맵 길찾기.
|
|
*
|
|
* ★ 티맵에는 **웹 길찾기 주소가 없다.** 공개된 건 앱 스킴(`tmap://route`) 하나뿐이고,
|
|
* 파라미터 이름조차 안드로이드(`goalname/goalx/goaly`)와 iOS(`rGoName/rGoX/rGoY`)가 다르다.
|
|
* 그래서 둘을 **한 주소에 같이 싣는다** — 모르는 파라미터는 무시되므로 어느 쪽에서도 열린다.
|
|
* ★ PC 에서는 이 주소가 아무 일도 일으키지 않는다. 그래서 버튼을 터치 기기에서만 보여준다
|
|
* (`.only-touch`, index.css). 눌러도 아무 데도 도착하지 않는 버튼은 없는 것보다 나쁘다.
|
|
* ★ 좌표가 없으면 만들지 않는다 — 티맵은 상호 검색으로 떨어뜨릴 경로가 없다.
|
|
*/
|
|
export function tmapDirectionsUrl(name: string, lat?: number, lng?: number): string | undefined {
|
|
if (lat == null || lng == null) return undefined;
|
|
const q = encodeURIComponent(name);
|
|
return `tmap://route?goalname=${q}&goalx=${lng}&goaly=${lat}&rGoName=${q}&rGoX=${lng}&rGoY=${lat}`;
|
|
}
|