--- // 주변 안내(한국어). /en/stay 의 한국어 짝이다. haewon 결정(2026-09-11): 주변 안내(/stay)와 회복 일정 플래너(/plan)는 별개 페이지다. 이 페이지는 병원 주변 장소·축제·동선이고, 시술 후 관리 안내는 접이식으로 둔다. 데이터는 국문 서비스(KorService2)라 // 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건). // 구조는 /en/stay 와 같다. 세 부분이다. // 1. 병원 주변 : 도보·차 소요 시간으로 거른 주변 장소 // 2. Festivals : 계절로 묶은 행사 // 3. Suggested days : 시간표와 지도가 붙은 동선 // + 시술 후 관리 안내(#recovery-quote): 점프 칩 아래 접이식 블록. 워커 recovery 단계가 병원 시술 페이지 원문에서 고른 문장 // // 데이터는 한국관광공사 TourAPI 국문(KorService2)에서 scripts/fetch_medical_tourism.py 가 // 미리 수집한 src/data/medicalTourismKo.json 이다. 설명문은 detailCommon2 의 overview 다. // 기준 좌표는 워커(tourism 단계)가 팩트 시트 주소로 정한다. 병원 이름·주소는 factSheet, 기준점은 // medicalTourismKo.meta.origin 에서만 읽는다. 이 파일에 특정 병원을 적지 않는다. // 데이터가 비어 있으면(템플릿 기본값, origin lat 0) 목록 대신 "확인 대기"를 그리고 빌드는 그대로 된다. // // 만들지 않는 것 // - 평점·리뷰·영업시간: TourAPI 미제공. 싣지 않는다. // - 링크: 공식 홈페이지가 있을 때만 걸고, 없으면 검색으로 보낸다. 주소를 지어내지 않는다. // - 식이 적합성·회복 일수: 의학 판단이라 병원 확정값만 쓴다. // - 일정의 시각: 진료 종료 시각을 가정으로 못박아 화면에 밝힌다. 병원 일정이 아니다. import Base from '../layouts/Base.astro'; import { fact, site as S, SITE_NAME, clinicSchema, has, v, reservationUrl } from '../lib'; import data from '../data/medicalTourismKo.json'; import recovery from '../data/recoveryNotes.json'; const f = fact as Record; const Sx = S as Record; const clinic = f.shortName || '병원'; const site = Astro.site!.toString().replace(/\/$/, ''); const T = data as any; // 기준점. 좌표가 없으면(템플릿 기본값) 아직 수집 전이다. 지어내지 않고 "확인 대기"로 그린다. const origin = (T.meta?.origin ?? {}) as { lat?: number; lng?: number; label?: string; address?: string }; const ready = Boolean(origin.lat && origin.lng); const total: number = T.filters?.total ?? 0; const festivals: any[] = T.festivals ?? []; const updated: string = (T.meta?.fetchedAt || '').slice(0, 10); // 거리의 기준 주소. 수집 때 쓴 주소가 있으면 그것, 없으면 팩트 시트 주소. const originAddress = origin.address || f.address?.full || ''; const festKm: number = T.meta?.festivalRadiusKm || 0; type Place = { id: string; title: string; address: string; image: string | null; overview: string; homepage: string; lat: number | null; lng: number | null; distanceM: number | null; travel: { mode: string; minutes: number; label: string } | null; }; const GROUPS: Array<{ key: string; label: string; note: string }> = [ { key: 'restaurant', label: '식사', note: '음식점과 카페' }, { key: 'stay', label: '숙박', note: '묵을 곳' }, { key: 'wellness', label: '휴식', note: '스파와 찜질방' }, { key: 'attraction', label: '관광', note: '거리, 공원, 명소' }, { key: 'culture', label: '문화', note: '미술관, 공연장' }, { key: 'shopping', label: '쇼핑', note: '백화점과 시장' }, ]; const SEASONS = [ { key: 'spring', label: '봄' }, { key: 'summer', label: '여름' }, { key: 'autumn', label: '가을' }, { key: 'winter', label: '겨울' }, ]; const MONTHS = ['', '1월', '2월', '3월', '4월', '5월', '6월', '7월', '8월', '9월', '10월', '11월', '12월']; const nowSeasonKey = ['winter', 'winter', 'spring', 'spring', 'spring', 'summer', 'summer', 'summer', 'autumn', 'autumn', 'autumn', 'winter'][new Date().getMonth()]; const nowSeason = ({ spring: '봄', summer: '여름', autumn: '가을', winter: '겨울' } as Record)[nowSeasonKey]; // 검색으로 보낸다. 없는 URL 을 만들지 않는다. // 없는 URL 을 만들지 않는다. 공식 홈페이지가 없으면 검색으로 보낸다. const searchUrl = (q: string) => `https://search.naver.com/search.naver?query=${encodeURIComponent(q)}`; // 칩은 누적 임계값으로 거른다. 카드에 이동수단과 도보 분을 실어 두면 스크립트가 판단한다. const dt = (d: string | null) => (d && d.length === 8 ? `${+d.slice(4, 6)}.${+d.slice(6, 8)}` : ''); // 사진이 없을 때 이름을 대신 넣는다. 빈 회색칸보다 낫고, 없는 사진을 지어내지도 않는다. const shortName = (t: string) => { const m = t.match(/\(([^)]+)\)\s*$/); return (m ? m[1] : t).replace(/\s*\[.*\]\s*/g, '').trim(); }; // travel.label 은 데이터에 영문으로 들어 있다. 한국어 화면에서는 여기서 만든다. const travelLabel = (t: { mode: string; minutes: number } | null) => !t ? '' : t.mode === 'walk' ? `도보 ${t.minutes}분` : `차로 ${t.minutes}분`; const firstSentence = (s: string, n = 150) => { if (!s) return ''; const cut = s.slice(0, n); const stop = cut.lastIndexOf('. '); return (stop > 60 ? cut.slice(0, stop + 1) : cut) + (s.length > n ? '…' : ''); }; /* ── 동선. 실제 데이터에서 만들고, 시각은 가정임을 화면에 밝힌다. ── */ const oLat = origin.lat ?? 0, oLng = origin.lng ?? 0; const pick = (key: string, maxMin: number, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]) .filter((p) => p.travel && p.travel.mode === 'walk' && p.travel.minutes <= maxMin && p.lat) .slice(0, n); const pickAny = (key: string, n: number): Place[] => (((T.places ?? {})[key] ?? []) as Place[]).filter((p) => p.lat).slice(0, n); const hhmm = (m: number) => `${String(Math.floor(m / 60) % 24).padStart(2, '0')}:${String(m % 60).padStart(2, '0')}`; function buildRoute(startMin: number, stops: Array<{ p: Place | null; label: string; stay: number; note: string }>) { let t = startMin; const out: any[] = []; stops.forEach((s, i) => { const move = i === 0 ? 0 : (s.p?.travel?.minutes ?? 5); t += move; const from = t; t += s.stay; out.push({ ...s, moveMin: move, from: hhmm(from), to: hhmm(t), lat: s.p?.lat ?? oLat, lng: s.p?.lng ?? oLng }); }); return { stops: out, total: t - startMin, from: hhmm(startMin), to: hhmm(t) }; } const clinicStop = (label: string, stay: number, note: string) => ({ p: null, label, stay, note }); const ROUTES = [ { id: 'near', title: '진료 다녀오는 날', who: '병원에 자주 오는 동안', blurb: '전부 걸어서 갈 수 있는 곳입니다. 택시가 필요 없습니다.', route: buildRoute(15 * 60, [ clinicStop(clinic, 40, '진료가 끝나는 곳입니다.'), ...pick('restaurant', 10, 1).map((p) => ({ p, label: p.title, stay: 50, note: firstSentence(p.overview, 90) })), ...pick('wellness', 12, 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), ]), }, { id: 'short', title: '가까운 곳 나들이', who: '조금 더 걸을 수 있을 때', blurb: '가깝지만 한 블록 밖입니다.', route: buildRoute(13 * 60, [ clinicStop(clinic, 20, '병원에서 출발합니다.'), ...pickAny('shopping', 1).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), ...pickAny('restaurant', 2).slice(1, 2).map((p) => ({ p, label: p.title, stay: 60, note: firstSentence(p.overview, 90) })), ]), }, { id: 'city', title: '멀리 나갈 수 있을 때', who: '체류 후반에', blurb: '시내 곳곳입니다. 택시나 지하철을 탑니다.', route: buildRoute(10 * 60, [ clinicStop(clinic, 10, '병원에서 나섭니다.'), ...pickAny('attraction', 2).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), ...pickAny('culture', 1).map((p) => ({ p, label: p.title, stay: 70, note: firstSentence(p.overview, 90) })), ]), }, ].filter((r) => r.route.stops.length > 1); /* OpenStreetMap 타일을 좌표로 직접 깔아 지도를 만든다. 지도 라이브러리를 부르지 않는다. 빈 회색 칸에 점만 찍으면 어디인지 알 수 없어서, 실제 거리·블록이 보이게 한다. 타일은 OSM 정책상 출처를 반드시 표기한다. */ const TILE = 256, MAPW = 680, MAPH = 340; const lngToPx = (lng: number, z: number) => ((lng + 180) / 360) * TILE * 2 ** z; const latToPx = (lat: number, z: number) => { const r = (lat * Math.PI) / 180; return ((1 - Math.log(Math.tan(r) + 1 / Math.cos(r)) / Math.PI) / 2) * TILE * 2 ** z; }; function miniMap(stops: any[]) { const lats = stops.map((s) => s.lat), lngs = stops.map((s) => s.lng); const [la0, la1] = [Math.min(...lats), Math.max(...lats)]; const [ln0, ln1] = [Math.min(...lngs), Math.max(...lngs)]; // 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다. let z = 17; for (; z > 10; z--) { const w = lngToPx(ln1, z) - lngToPx(ln0, z); const h = latToPx(la0, z) - latToPx(la1, z); if (w <= MAPW - 90 && h <= MAPH - 90) break; } const cx = (lngToPx(ln0, z) + lngToPx(ln1, z)) / 2; const cy = (latToPx(la0, z) + latToPx(la1, z)) / 2; const left = cx - MAPW / 2, top = cy - MAPH / 2; const tiles: Array<{ src: string; x: number; y: number }> = []; for (let tx = Math.floor(left / TILE); tx <= Math.floor((left + MAPW) / TILE); tx++) { for (let ty = Math.floor(top / TILE); ty <= Math.floor((top + MAPH) / TILE); ty++) { const n = 2 ** z; if (tx < 0 || ty < 0 || tx >= n || ty >= n) continue; tiles.push({ src: `https://tile.openstreetmap.org/${z}/${tx}/${ty}.png`, x: tx * TILE - left, y: ty * TILE - top }); } } const pins = stops.map((s, i) => ({ x: +(lngToPx(s.lng, z) - left).toFixed(1), y: +(latToPx(s.lat, z) - top).toFixed(1), n: i + 1, label: s.label, })); return { tiles, pins, z }; } // 시술 후 관리 안내. 워커 recovery 단계(scripts/collect_recovery.mjs)가 병원 시술 페이지 원문에서 고른 문장이다. // 병원 확정값(site.recoveryStagesKo)과 역할이 다르다. 확정값은 "병원 안내" 문단, 이것은 원문 인용이라 출처 링크와 확인일을 붙인다. // items 가 비어 있으면(템플릿 기본값) 블록을 아예 그리지 않는다. type RecoveryItem = { procedure: string; area?: string; url: string; fetchedAt?: string; ko: string[]; en: string[]; ocr?: boolean }; const RN = recovery as { generatedAt?: string; items?: RecoveryItem[] }; const recoveryItems: RecoveryItem[] = (RN.items ?? []).filter((it) => it.ko?.length); const recoveryDate = RN.generatedAt || ''; const allPlaces: Array = GROUPS.flatMap((g) => (((T.places ?? {})[g.key] ?? []) as Place[]).map((p) => ({ ...p, cat: g.key }))); const ld = [ { '@type': 'WebPage', '@id': `${site}/stay#page`, name: `${clinic} 주변 안내`, url: `${site}/stay`, inLanguage: 'ko', isPartOf: { '@id': `${site}/#website` }, about: { '@id': `${f.url}/#clinic` }, publisher: { '@type': 'Organization', name: SITE_NAME, url: site } }, clinicSchema(site), ]; ---
Nearby

Around The Clinic

{clinic} 주변입니다. {ready ? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.` : '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'} 상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 병원 정보에, 수술일 기준 입국·출국 계산은 회복 일정에 있습니다.

{!ready &&

주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.

}
{recoveryItems.length > 0 && (
시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개시술 후 관리 안내 접기

시술 후 관리 안내

{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개

{recoveryDate &&
{recoveryDate} 확인
}

아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.

    {recoveryItems.map((it, i) => (
  1. {i + 1} {it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기

    {it.procedure}

      {it.ko.map((s) =>
    • {s}
    • )}

    병원 페이지 보기 ↗{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}

  2. ))}
)}

병원 주변

{updated ? `${updated} 갱신` : '확인 대기'}

{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}

{total > 0 &&
} {GROUPS.map((g) => { const items = allPlaces.filter((p) => p.cat === g.key); if (!items.length) return null; return (

{g.label} {g.note}

{items.map((p) => (
{p.image ? {`${p.title}, : {shortName(p.title)}} {p.travel && {travelLabel(p.travel)} {p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}}

{p.title}

{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}

{p.address}

{p.homepage ? 공식 홈페이지 ↗ : 검색으로 열기 ↗}
))}
); })}

출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.

계절별 축제

{festivals.length > 0 ? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 ` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}{nowSeason}입니다.

{festivals.length > 0 &&
{SEASONS.map((s) => { const n = festivals.filter((e: any) => e.season === s.key).length; return n ? : null; })}
}
{festivals.map((e: any) => (
{e.image ? {`${e.title}, : {shortName(e.title)}} {MONTHS[e.month] ?? ''}

{e.title}

{dt(e.startDate)} – {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}

{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}

{e.address}

검색으로 열기 ↗
))}

출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.

하루 동선

위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다.

{Sx.recoveryStagesKo ?

{clinic} 안내. {Sx.recoveryStagesKo}

:

어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.

} {ROUTES.map((r) => { const pins = miniMap(r.route.stops); return (

{r.title}

{r.who}

{r.route.from}–{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분

{r.blurb}

); })}

이 페이지가 답하지 못하는 것

  • 평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.
  • 어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.
  • {total > 0 ? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.` : '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}
  • 여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.

{has(reservationUrl) && {clinic} 상담 예약}

{f.sideEffectNoticeEn || f.sideEffectNotice}