o2o-infinith-demo/templates/supporters-astro/src/pages/stay.astro
Haewon Kam d66e6279ed feat(supporters): 주변 안내(/stay·/en/stay) 복원 · 회복 일정 플래너(/plan)와 별개 페이지로
- 오라클 폴더에 남아 있던 최신본(장소·축제·동선 지도·날씨·시술 후 관리 접이식)을 /stay 로 복원. 제목 Around The Clinic, 회복 일정 플래너 링크
- 내비 '주변 안내'·'Nearby', 언어 짝 /stay↔/en/stay, 사이트맵, /stay 리다이렉트 제거(/recovery→/plan 은 유지)
- 구조 기준선 갱신(내비 항목 추가, haewon 지시 2026-09-11)

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
2026-09-11 18:28:12 +09:00

516 lines
30 KiB
Plaintext
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.

---
// 주변 안내(한국어). /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<string, any>;
const Sx = S as Record<string, any>;
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<string, string>)[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<Place & { cat: string }> = 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),
];
---
<Base
title={`${clinic} 주변 안내 · 식사, 숙박, 휴식, 관광, 축제, 하루 동선`}
description={`${clinic} 주변에서 어디서 먹고 묵고 쉴 수 있는지, 무엇을 볼 수 있는지, 어떤 축제가 열리는지를 병원에서의 거리와 도보 시간으로 정리했습니다. 출처는 한국관광공사입니다.`}
jsonLd={ld}
>
<article class="wrap-wide stay">
<header class="article-head">
<div class="eyebrow">Nearby</div>
<h1 class="serif">Around The Clinic</h1>
<p class="lede">
<strong>{clinic} 주변입니다.</strong> {ready
? `아래 거리와 시간은 모두 ${v(originAddress)} 기준입니다.`
: '병원 위치가 확인되면 거리와 도보 시간이 채워집니다.'}
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에, 수술일 기준 입국·출국 계산은 <a href="/plan">회복 일정</a>에 있습니다.
</p>
{!ready && <p class="pending-note">주변 안내는 확인 대기입니다. 병원 주소를 확인한 뒤 식사·숙박·휴식·관광·축제 목록이 채워집니다.</p>}
</header>
<!-- 오늘 날씨. 빌드 시점이 아니라 브라우저에서 받는다. 정적 빌드에 넣으면 배포 시각의
날씨가 굳는다. 값을 못 받으면 이 블록은 통째로 사라진다(틀린 날씨를 보이지 않는다).
Open-Meteo 는 키가 필요 없고 CORS 를 허용한다. -->
<aside id="weather" class="wx" hidden>
<div class="wx-now">
<span class="wx-temp"><span id="wx-t"></span><span class="wx-unit">°C</span></span>
<div>
<div class="wx-cond" id="wx-c">불러오는 중</div>
<div class="wx-meta" id="wx-m"></div>
</div>
</div>
<p class="wx-note" id="wx-n"></p>
</aside>
<!-- 섹션이 길어 스크롤이 부담스럽다. 머리말 아래 붙여 어디서든 건너뛰게 한다. -->
<nav class="jump" aria-label="섹션으로 이동">
{recoveryItems.length > 0 && <a href="#recovery-quote">시술 후 관리<b>{recoveryItems.length}</b></a>}
{GROUPS.filter((g) => allPlaces.some((p) => p.cat === g.key)).map((g) => (
<a href={`#g-${g.key}`}>{g.label}<b>{allPlaces.filter((p) => p.cat === g.key).length}</b></a>
))}
<a href="#festivals">축제<b>{festivals.length}</b></a>
<a href="#days">동선<b>{ROUTES.length}</b></a>
</nav>
<!-- ── 0. 시술 후 관리 안내 ── 병원 시술 페이지 원문 인용. 기본은 접힘, summary 가 버튼이다(haewon 2026-09-11).
동선 카드(.route)·정차(.stop) 룩을 그대로 쓴다. id="recovery-quote" 는 발행 게이트(check.mjs)가 홈페이지 40자 일치 검사에서 이 인용 블록만 빼는 표식이다. -->
{recoveryItems.length > 0 && (
<details class="route recovery" id="recovery-quote">
<summary class="btn primary"><span class="show">시술 후 관리 안내 보기 · 시술 {recoveryItems.length}개</span><span class="hide">시술 후 관리 안내 접기</span></summary>
<div class="route-head">
<div>
<h3>시술 후 관리 안내</h3>
<p class="who">{clinic} 홈페이지 시술 페이지 기준 · 시술 {recoveryItems.length}개</p>
</div>
{recoveryDate && <div class="clock">{recoveryDate} 확인</div>}
</div>
<p class="blurb">아래 문장은 {clinic} 홈페이지 시술 페이지에 적힌 안내를 그대로 옮긴 것입니다. 시술마다 다르고 개인차가 있으니 상담에서 확인하세요.</p>
<ol class="timeline">
{recoveryItems.map((it, i) => (
<li>
<details open={i < 3}>
<summary>
<span class="stop">
<span class="n">{i + 1}</span>
<span>
<span class="stop-t">{it.area ? `${it.area} · ` : ''}{it.ko.length}문장 · 눌러서 펼치기</span>
<h4>{it.procedure}</h4>
</span>
</span>
</summary>
<ul>
{it.ko.map((s) => <li>{s}</li>)}
</ul>
<p><a class="go" href={it.url} rel="noopener nofollow">병원 페이지 보기 ↗</a>{it.fetchedAt ? ` · ${it.fetchedAt} 확인` : ''}</p>
</details>
</li>
))}
</ol>
</details>
)}
<!-- ── 1. 주변 ── -->
<section id="around">
<div class="sec-head">
<h2>병원 주변</h2>
<span class="upd">{updated ? `${updated} 갱신` : '확인 대기'}</span>
</div>
<p class="sub">{total > 0 ? `${total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.` : '병원 위치를 확인한 뒤에 채워집니다. 확인 대기.'}</p>
{total > 0 && <div class="chips" id="walkchips" role="group" aria-label="이동 시간으로 거르기">
<button class="chip on" data-max="all">전체 <b>{T.filters.total}</b></button>
<button class="chip" data-max="5">도보 5분 이내 <b>{T.filters.walk5}</b></button>
<button class="chip" data-max="15">도보 15분 이내 <b>{T.filters.walk15}</b></button>
<button class="chip" data-max="walk">걸어갈 수 있는 곳 <b>{T.filters.walkAll}</b></button>
<button class="chip" data-max="drive">차로 가는 곳 <b>{T.filters.drive}</b></button>
</div>}
{GROUPS.map((g) => {
const items = allPlaces.filter((p) => p.cat === g.key);
if (!items.length) return null;
return (
<div class="grp" id={`g-${g.key}`} data-group={g.key}>
<h3>{g.label} <small>{g.note}</small></h3>
<div class="cards">
{items.map((p) => (
<article class="card" data-mode={p.travel?.mode ?? 'drive'} data-walk={p.travel?.mode === 'walk' ? p.travel.minutes : ''}>
<div class="thumb">
{p.image ? <img src={p.image} alt={`${p.title}, ${clinic} 주변 ${g.label}`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(p.title)}</span>}
{p.travel && <span class="badge">{travelLabel(p.travel)} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
<h4>{p.title}</h4>
<p class="desc">{firstSentence(p.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{p.address}</p>
{p.homepage
? <a class="go" href={p.homepage} rel="noopener nofollow">공식 홈페이지 ↗</a>
: <a class="go" href={searchUrl(p.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>}
</div>
</article>
))}
</div>
</div>
);
})}
<p class="src">출처 · 한국관광공사 TourAPI. 도보 시간은 분당 80m로 어림한 값이며 실제 경로 거리가 아닙니다.</p>
</section>
<!-- ── 2. 축제 ── -->
<section id="festivals">
<div class="sec-head"><h2>계절별 축제</h2></div>
<p class="sub">{festivals.length > 0
? `${festKm ? `${festKm}km 안의 ` : ''}행사 ${festivals.length}건을 계절로 묶었습니다. 지금은 `
: '병원 위치를 확인한 뒤에 채워집니다. 확인 대기. 지금은 '}<strong>{nowSeason}</strong>입니다.</p>
{festivals.length > 0 && <div class="chips" role="group" aria-label="계절로 거르기">
<button class="chip on" data-season="all">전체 <b>{festivals.length}</b></button>
{SEASONS.map((s) => {
const n = festivals.filter((e: any) => e.season === s.key).length;
return n ? <button class="chip" data-season={s.key}>{s.label} <b>{n}</b></button> : null;
})}
</div>}
<div class="cards fest">
{festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt={`${e.title}, ${clinic} 주변 축제`} loading="lazy" referrerpolicy="no-referrer" /> : <span class="nophoto">{shortName(e.title)}</span>}
<span class="badge mono">{MONTHS[e.month] ?? ''}</span>
</div>
<div class="body">
<h4>{e.title}</h4>
<p class="when">{dt(e.startDate)} {dt(e.endDate)} · {e.travel ? travelLabel(e.travel) : `${e.distanceKm}km`}</p>
<p class="desc">{firstSentence(e.overview) || '관광 자료에 설명이 없습니다.'}</p>
<p class="addr">{e.address}</p>
<a class="go" href={searchUrl(e.title.replace(/\s*\(.*\)\s*$/, ''))} rel="noopener nofollow">검색으로 열기 ↗</a>
</div>
</article>
))}
</div>
<p class="src">출처 · 한국관광공사 TourAPI. 일정은 주최 측 사정으로 바뀔 수 있습니다.</p>
</section>
<!-- ── 3. 동선 ── -->
<section id="days">
<div class="sec-head"><h2>하루 동선</h2></div>
<p class="sub">
위 장소들로 짠 동선 세 가지입니다. 시각은 진료가 그 시각에 끝난다고 가정한 예시이며 실제 일정이 아닙니다.
</p>
{Sx.recoveryStagesKo
? <p><strong>{clinic} 안내.</strong> {Sx.recoveryStagesKo}</p>
: <p class="pending-note">어느 날 무엇을 해도 되는지는 의학 판단이라 {clinic}이 정합니다. 아직 확인 대기입니다. 상담 때 확인하세요.</p>}
{ROUTES.map((r) => {
const pins = miniMap(r.route.stops);
return (
<div class="route">
<div class="route-head">
<div>
<h3>{r.title}</h3>
<p class="who">{r.who}</p>
</div>
<div class="clock">{r.route.from}{r.route.to} · {Math.floor(r.route.total / 60) ? `${Math.floor(r.route.total / 60)}시간 ` : ''}{r.route.total % 60}분</div>
</div>
<p class="blurb">{r.blurb}</p>
<div class="map" role="img" aria-label={`동선 지도, 정차 ${pins.pins.length}곳: ${pins.pins.map((p) => p.label).join(', ')}`}>
<div class="map-tiles">
{/* 타일은 장식이다. 지도의 뜻은 감싸는 요소의 aria-label 이 전한다.
img 대신 배경으로 두어 스크린리더가 256개 조각을 읽지 않게 한다. */}
{pins.tiles.map((t) => (
<div class="tile" style={`left:${t.x}px;top:${t.y}px;background-image:url('${t.src}')`} />
))}
</div>
<svg class="map-pins" viewBox={`0 0 ${MAPW} ${MAPH}`} aria-hidden="true">
<polyline points={pins.pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#0A1128" stroke-width="3" stroke-dasharray="6 5" opacity="0.65" />
{pins.pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="13" fill="#0A1128" stroke="#fff" stroke-width="2.5" /><text x={p.x} y={p.y + 4.5} text-anchor="middle" font-size="12" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<span class="map-credit">© OpenStreetMap contributors</span>
</div>
<ol class="timeline">
{r.route.stops.map((s: any, i: number) => (
<li>
{i > 0 && <div class="move">↓ {s.moveMin}분</div>}
<div class="stop">
<span class="n">{i + 1}</span>
<div>
<div class="stop-t">{s.from}{s.to}</div>
<h4>{s.label}</h4>
<p>{s.note}</p>
</div>
</div>
</li>
))}
</ol>
</div>
);
})}
</section>
<section id="limits">
<h2>이 페이지가 답하지 못하는 것</h2>
<ul>
<li>평점·리뷰 수·영업시간은 없습니다. 관광 자료가 주지 않습니다.</li>
<li>어떤 곳이 회복에 맞는지는 말하지 않습니다. {clinic}이 정할 일입니다.</li>
<li>{total > 0
? `도보 5분 안에는 ${T.filters.walk5}곳뿐이고, ${total}곳 중 ${T.filters.drive}곳은 차로 가야 합니다.`
: '주변 장소 목록은 병원 위치를 확인한 뒤에 채워집니다.'}</li>
<li>여행사가 아닙니다. 예약을 받지 않고 수수료도 받지 않습니다.</li>
</ul>
<p class="botnote">
{has(reservationUrl) && <a class="btn primary" href={reservationUrl} rel="noopener">{clinic} 상담 예약</a>}
</p>
<p class="disclosure">{f.sideEffectNoticeEn || f.sideEffectNotice}</p>
</section>
</article>
<script is:inline define:vars={{ WX_LAT: origin.lat ?? 0, WX_LNG: origin.lng ?? 0 }}>
// 이동 시간 필터. 임계값은 누적이라 "15분 이내"가 5분 이내도 포함한다.
// 걸러져 비게 된 그룹은 제목만 남지 않도록 통째로 숨긴다.
(function () {
var chips = document.querySelectorAll('#walkchips .chip');
chips.forEach(function (c) {
c.addEventListener('click', function () {
chips.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.max;
document.querySelectorAll('#around .card').forEach(function (card) {
var mode = card.dataset.mode, w = parseInt(card.dataset.walk || '', 10);
var show = want === 'all' ? true
: want === 'walk' ? mode === 'walk'
: want === 'drive' ? mode !== 'walk'
: mode === 'walk' && w <= parseInt(want, 10);
card.hidden = !show;
});
document.querySelectorAll('#around .grp').forEach(function (g) {
g.hidden = ![].slice.call(g.querySelectorAll('.card')).some(function (x) { return !x.hidden; });
});
});
});
// 점프 칩 '시술 후 관리'는 접힌 블록으로 가므로 눌렀을 때 함께 연다.
document.querySelectorAll('a[href="#recovery-quote"]').forEach(function (a) { a.addEventListener('click', function () { var d = document.getElementById('recovery-quote'); if (d) d.open = true; }); });
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
var links = [].slice.call(document.querySelectorAll('.jump a'));
var targets = links.map(function (a) { return document.querySelector(a.getAttribute('href')); });
if ('IntersectionObserver' in window) {
var seen = {};
var io = new IntersectionObserver(function (entries) {
entries.forEach(function (e) { seen[e.target.id] = e.isIntersecting ? e.intersectionRatio : 0; });
var best = null, top = 0;
targets.forEach(function (t) {
if (t && (seen[t.id] || 0) > top) { top = seen[t.id]; best = t.id; }
});
links.forEach(function (a) { a.classList.toggle('on', best !== null && a.getAttribute('href') === '#' + best); });
}, { rootMargin: '-150px 0px -55% 0px', threshold: [0, 0.15, 0.4, 0.75, 1] });
targets.forEach(function (t) { if (t) io.observe(t); });
}
// 오늘 날씨. Open-Meteo, 키 없음. 실패하면 블록을 숨긴 채 둔다.
// 문구는 이 페이지의 목록에만 연결한다. 무엇을 해도 되는지는 의학 판단이라 말하지 않는다.
(function () {
var WMO = {
0: '맑음', 1: '대체로 맑음', 2: '구름 조금', 3: '흐림',
45: '안개', 48: '서리 안개', 51: '약한 이슬비', 53: '이슬비', 55: '강한 이슬비',
56: '언 이슬비', 57: '언 이슬비', 61: '약한 비', 63: '비', 65: '강한 비',
66: '언 비', 67: '언 비', 71: '약한 눈', 73: '눈', 75: '강한 눈',
77: '싸락눈', 80: '약한 소나기', 81: '소나기', 82: '강한 소나기',
85: '진눈깨비', 86: '진눈깨비', 95: '뇌우', 96: '우박을 동반한 뇌우', 99: '우박을 동반한 뇌우'
};
var WET = [51,53,55,56,57,61,63,65,66,67,71,73,75,77,80,81,82,85,86,95,96,99];
var LAT = WX_LAT, LNG = WX_LNG;
if (!LAT || !LNG) return; // 기준 좌표가 없으면(확인 대기) 날씨도 띄우지 않는다
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
'&current=temperature_2m,apparent_temperature,weather_code,wind_speed_10m&timezone=auto')
.then(function (r) { if (!r.ok) throw 0; return r.json(); })
.then(function (j) {
var c = j.current; if (!c || c.temperature_2m == null) throw 0;
var code = c.weather_code;
document.getElementById('wx-t').textContent = Math.round(c.temperature_2m);
document.getElementById('wx-c').textContent = WMO[code] || '현재 날씨';
document.getElementById('wx-m').textContent =
'체감 ' + Math.round(c.apparent_temperature) + '°C · 바람 ' +
Math.round(c.wind_speed_10m) + ' km/h · ' + String(c.time).replace('T', ' ') + ' 관측';
document.getElementById('wx-n').textContent = WET.indexOf(code) >= 0
? '비가 옵니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m >= 30
? '더운 날입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: (c.temperature_2m <= 0
? '영하입니다. 아래 문화·쇼핑 항목은 실내입니다.'
: '지금은 비가 오지 않습니다. 아래 시간은 병원에서 걸어가는 기준입니다.'));
document.getElementById('weather').hidden = false;
})
.catch(function () { /* 못 받으면 숨긴 채 둔다 */ });
})();
var sc = document.querySelectorAll('#festivals .chip');
sc.forEach(function (c) {
c.addEventListener('click', function () {
sc.forEach(function (x) { x.classList.toggle('on', x === c); });
var want = c.dataset.season;
document.querySelectorAll('#festivals .card').forEach(function (card) {
card.hidden = !(want === 'all' || card.dataset.season === want);
});
});
});
})();
</script>
</Base>