feat(supporters): 한국어 /stay 신설 — 언어 스위치의 짝을 맞춤
언어 스위치의 KO 가 /visit 으로 가고 있었다. 같은 내용의 한국어판이 아니라 짝이 아니었다. /stay 를 만들어 /stay ↔ /en/stay 로 잇는다. 수집 (scripts/fetch_medical_tourism.py --lang ko) - 국문은 ContentTypeId 가 다르다(관광지 12·문화시설 14·숙박 32·쇼핑 38·음식점 39). 다국어 코드로 부르면 엉뚱한 것이 온다. - 국문 서비스는 KorService2 로 붙고, 국문 사진 폴백은 건너뛴다(국문이 원본이다). - 출력은 medicalTourismKo.json. 국문이 훨씬 두껍다 전체 123곳(영문 101) · 도보 5분 이내 13곳(영문 2) · 걸어갈 수 있는 곳 66곳(영문 25) 설명문 146/147 · 사진 147/147 국문 관광지에는 의료관광 등록업소가 섞이지 않아 A02020500 필터가 걸리지 않는다. 영문에서 반경 5km 100건 중 92건이 병원·에이전시였던 것과 대비된다. 화면 (supporters/src/pages/stay.astro) /en/stay 와 같은 구조에 한국어 문구를 넣었다. 이동 시간 라벨은 데이터에 영문으로 들어 있어 화면에서 만든다(도보 N분 / 차로 N분). 날씨 상태도 한국어로 옮겼고, 검색 링크는 네이버로 보낸다. 식이·회복 안내는 site.json 의 dietGuideKo / recoveryStagesKo 를 쓰며, 없으면 병원 확인 대기로 둔다. 라우팅 - Base.astro PAIRS 에 '/stay': '/en/stay' 등록. 스위치가 서로를 가리킨다. - 한국어 네비게이션에 "주변 안내" 추가. 스위치는 EN/KO 만 오가므로 다른 페이지에서 /stay 로 갈 길이 없었다. 이 항목 추가로 LAYOUT_CHANGED 경고 2건이 남아 있으며 haewon 승인 전까지 기준선을 갱신하지 않는다. - 사이트맵에 /stay 추가. 템플릿은 같은 코드에 빈 데이터 스캐폴드를 둔다. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
b92f29c958
commit
67f1cb8295
@ -36,13 +36,22 @@ CAT_WELLNESS = ("A02020300", "A02020400") # Hot Springs & Spa, Jjimjilbang
|
||||
SEASON = {3: "spring", 4: "spring", 5: "spring", 6: "summer", 7: "summer", 8: "summer",
|
||||
9: "autumn", 10: "autumn", 11: "autumn", 12: "winter", 1: "winter", 2: "winter"}
|
||||
|
||||
TYPES = {
|
||||
# ContentTypeId 는 다국어와 국문이 다르다. 같은 코드로 부르면 엉뚱한 것이 온다.
|
||||
TYPES_EN = {
|
||||
"76": ("attraction", "10000"),
|
||||
"78": ("culture", "10000"),
|
||||
"79": ("shopping", "10000"),
|
||||
"80": ("stay", "5000"),
|
||||
"82": ("restaurant", "5000"),
|
||||
}
|
||||
TYPES_KO = {
|
||||
"12": ("attraction", "10000"),
|
||||
"14": ("culture", "10000"),
|
||||
"38": ("shopping", "10000"),
|
||||
"32": ("stay", "5000"),
|
||||
"39": ("restaurant", "5000"),
|
||||
}
|
||||
TYPES = TYPES_EN # main() 에서 --lang 에 맞게 바꾼다
|
||||
|
||||
|
||||
def ssl_ctx():
|
||||
@ -300,10 +309,19 @@ def main():
|
||||
ap = argparse.ArgumentParser()
|
||||
ap.add_argument("--festival-km", type=float, default=60.0, help="이 거리 안의 축제만 담는다")
|
||||
ap.add_argument("--per-type", type=int, default=24, help="타입별 최대 건수")
|
||||
ap.add_argument("--out", default="supporters/src/data/medicalTourism.json")
|
||||
ap.add_argument("--lang", choices=["en", "ko"], default="en",
|
||||
help="en 은 EngService2(외국인 환자용 /en/stay), ko 는 KorService2(/stay)")
|
||||
ap.add_argument("--out", default="")
|
||||
a = ap.parse_args()
|
||||
|
||||
global TYPES
|
||||
env = load_env(os.path.join(ROOT, ".env"))
|
||||
if a.lang == "ko":
|
||||
TYPES = TYPES_KO
|
||||
env = dict(env, TOUR_API_SERVICE="KorService2")
|
||||
if not a.out:
|
||||
a.out = ("supporters/src/data/medicalTourism.json" if a.lang == "en"
|
||||
else "supporters/src/data/medicalTourismKo.json")
|
||||
for k in ("TOUR_API_KEY", "TOUR_API_BASE", "TOUR_API_SERVICE", "TOUR_API_ORIGIN_LAT", "TOUR_API_ORIGIN_LNG"):
|
||||
if not env.get(k): sys.exit(f".env 에 {k} 가 비어 있습니다")
|
||||
lat, lng = float(env["TOUR_API_ORIGIN_LAT"]), float(env["TOUR_API_ORIGIN_LNG"])
|
||||
@ -366,7 +384,7 @@ def main():
|
||||
src = ""
|
||||
img = fetch_image(env, p["id"], cache)
|
||||
if img: src = "tourapi"
|
||||
if not img:
|
||||
if not img and a.lang == "en":
|
||||
img = fetch_image_kor(env, p); src = "tourapi-kor" if img else src
|
||||
if not img:
|
||||
cand = fetch_image_og(p.get("homepage") or "")
|
||||
@ -396,6 +414,7 @@ def main():
|
||||
}
|
||||
|
||||
out["meta"] = {
|
||||
"lang": a.lang,
|
||||
"source": "한국관광공사 TourAPI " + env["TOUR_API_SERVICE"],
|
||||
"fetchedAt": time.strftime("%Y-%m-%dT%H:%M:%S"),
|
||||
"origin": {"lat": lat, "lng": lng, "label": "뷰성형외과의원 (봉은사로 107)",
|
||||
|
||||
3311
supporters/src/data/medicalTourismKo.json
Normal file
3311
supporters/src/data/medicalTourismKo.json
Normal file
File diff suppressed because it is too large
Load Diff
@ -19,11 +19,11 @@ const isEn = lang === 'en';
|
||||
// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조.
|
||||
const T = isEn
|
||||
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/stay', '주변 안내'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
|
||||
// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다.
|
||||
// 영문 페이지가 늘어나면 이 표에 줄만 추가한다.
|
||||
const PAIRS: Record<string, string> = { '/visit': '/en/stay' };
|
||||
const PAIRS: Record<string, string> = { '/stay': '/en/stay', '/visit': '/en/stay' };
|
||||
const KO_ENTRY = '/';
|
||||
const EN_ENTRY = '/en/stay';
|
||||
const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/');
|
||||
|
||||
@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
{ loc: `${base}/posts`, lastmod: today },
|
||||
{ loc: `${base}/clinic`, lastmod: today },
|
||||
{ loc: `${base}/visit`, lastmod: today },
|
||||
{ loc: `${base}/stay`, lastmod: today },
|
||||
{ loc: `${base}/en/stay`, lastmod: today },
|
||||
{ loc: `${base}/newsroom`, lastmod: today },
|
||||
{ loc: `${base}/videos`, lastmod: today },
|
||||
|
||||
445
supporters/src/pages/stay.astro
Normal file
445
supporters/src/pages/stay.astro
Normal file
@ -0,0 +1,445 @@
|
||||
---
|
||||
// 병원 주변 안내(한국어). /en/stay 의 한국어 짝이다. 데이터는 국문 서비스(KorService2)라
|
||||
// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건).
|
||||
// 구조는 /en/stay 와 같다. 세 부분이다.
|
||||
// 1. 병원 주변 — 도보·차 소요 시간으로 거른 주변 장소
|
||||
// 2. Festivals — 계절로 묶은 행사
|
||||
// 3. Suggested days — 시간표와 지도가 붙은 동선
|
||||
//
|
||||
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
|
||||
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
|
||||
//
|
||||
// 만들지 않는 것
|
||||
// - 평점·리뷰·영업시간: 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';
|
||||
|
||||
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;
|
||||
|
||||
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 = T.meta.origin.lat, oLng = T.meta.origin.lng;
|
||||
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: 'The day of a check-up', who: 'When you come back to the clinic often',
|
||||
blurb: 'Everything within a short walk. You do not need a taxi.',
|
||||
route: buildRoute(15 * 60, [
|
||||
clinicStop(clinic, 40, 'Your appointment ends here.'),
|
||||
...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: 'A short outing', who: 'When you can walk a little further',
|
||||
blurb: 'Still close, but out of the block.',
|
||||
route: buildRoute(13 * 60, [
|
||||
clinicStop(clinic, 20, 'Start from the clinic.'),
|
||||
...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: 'When you can travel', who: 'Later in your stay',
|
||||
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
|
||||
route: buildRoute(10 * 60, [
|
||||
clinicStop(clinic, 10, 'Set off from the clinic.'),
|
||||
...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 };
|
||||
}
|
||||
|
||||
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">병원 주변</div>
|
||||
<h1 class="serif">병원 주변 안내</h1>
|
||||
<p class="lede">
|
||||
<strong>{clinic} 주변입니다.</strong> 아래 거리와 시간은 모두 {v(f.address?.full)} 기준입니다.
|
||||
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에 있습니다.
|
||||
</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="섹션으로 이동">
|
||||
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
|
||||
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
|
||||
))}
|
||||
<a href="#festivals">축제<b>{T.festivals.length}</b></a>
|
||||
<a href="#days">동선<b>{ROUTES.length}</b></a>
|
||||
</nav>
|
||||
|
||||
<!-- ── 1. 주변 ── -->
|
||||
<section id="around">
|
||||
<div class="sec-head">
|
||||
<h2>병원 주변</h2>
|
||||
<span class="upd">{T.meta.fetchedAt.slice(0, 10)} 갱신</span>
|
||||
</div>
|
||||
<p class="sub">{T.filters.total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.</p>
|
||||
|
||||
<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} — ${g.label.toLowerCase()} near ${clinic}`} 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">{T.meta.festivalRadiusKm}km 안의 행사 {T.festivals.length}건을 계절로 묶었습니다. 지금은 <strong>{nowSeason}</strong>입니다.</p>
|
||||
<div class="chips" role="group" aria-label="계절로 거르기">
|
||||
<button class="chip on" data-season="all">전체 <b>{T.festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = T.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">
|
||||
{T.festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title} — festival near ${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)}h {r.route.total % 60}m</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} min</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>도보 5분 안에는 {T.filters.walk5}곳뿐이고, {T.filters.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: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
|
||||
// 이동 시간 필터. 임계값은 누적이라 "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; });
|
||||
});
|
||||
});
|
||||
});
|
||||
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
|
||||
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;
|
||||
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
|
||||
'¤t=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>
|
||||
34
templates/supporters-astro/src/data/medicalTourismKo.json
Normal file
34
templates/supporters-astro/src/data/medicalTourismKo.json
Normal file
@ -0,0 +1,34 @@
|
||||
{
|
||||
"_comment": "국문 의료관광 연결 데이터. scripts/fetch_medical_tourism.py --lang ko 가 채운다. 비어 있으면 /stay 는 목록 없음으로 표시된다.",
|
||||
"places": {
|
||||
"stay": [],
|
||||
"restaurant": [],
|
||||
"wellness": [],
|
||||
"attraction": [],
|
||||
"culture": [],
|
||||
"shopping": []
|
||||
},
|
||||
"festivals": [],
|
||||
"filters": {
|
||||
"total": 0,
|
||||
"walk5": 0,
|
||||
"walk15": 0,
|
||||
"walkAll": 0,
|
||||
"drive": 0
|
||||
},
|
||||
"meta": {
|
||||
"lang": "ko",
|
||||
"source": "",
|
||||
"fetchedAt": "",
|
||||
"origin": {
|
||||
"lat": 0,
|
||||
"lng": 0,
|
||||
"label": "",
|
||||
"coordSource": ""
|
||||
},
|
||||
"radiusByType": {},
|
||||
"festivalRadiusKm": 0,
|
||||
"counts": {},
|
||||
"limits": []
|
||||
}
|
||||
}
|
||||
@ -19,11 +19,11 @@ const isEn = lang === 'en';
|
||||
// 머리말·꼬리말 문구. 법적 고지(지원 관계·부작용)는 번역하지 않는다. 아래 주석 참조.
|
||||
const T = isEn
|
||||
? { nav: [['/posts', 'Questions'], ['/videos', 'Doctors'], ['/newsroom', 'Newsroom'], ['/en/stay', 'Your Stay'], ['/about', 'About']], cta: 'Book a Consultation', supporters: 'SUPPORTERS', back: 'Back', top: 'Back to top', sample: 'This is a sample site, not yet public.' }
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
: { nav: [['/posts', '상담 전 질문'], ['/videos', '의료진 영상'], ['/newsroom', '뉴스룸'], ['/stay', '주변 안내'], ['/visit', '방문 안내'], ['/about', '매체 소개']], cta: '상담 예약', supporters: '서포터즈', back: '이전 페이지', top: '맨 위로', sample: '샘플 사이트입니다. 정식 공개 전입니다.' };
|
||||
|
||||
// 언어 전환. 같은 내용의 짝이 있는 페이지만 서로 잇고, 없으면 그 언어의 입구로 보낸다.
|
||||
// 영문 페이지가 늘어나면 이 표에 줄만 추가한다.
|
||||
const PAIRS: Record<string, string> = { '/visit': '/en/stay' };
|
||||
const PAIRS: Record<string, string> = { '/stay': '/en/stay', '/visit': '/en/stay' };
|
||||
const KO_ENTRY = '/';
|
||||
const EN_ENTRY = '/en/stay';
|
||||
const path = (Astro.url.pathname.replace(/\.html$/, '').replace(/\/index$/, '/').replace(/\/$/, '') || '/');
|
||||
|
||||
@ -11,6 +11,7 @@ export const GET: APIRoute = async ({ site }) => {
|
||||
{ loc: `${base}/posts`, lastmod: today },
|
||||
{ loc: `${base}/clinic`, lastmod: today },
|
||||
{ loc: `${base}/visit`, lastmod: today },
|
||||
{ loc: `${base}/stay`, lastmod: today },
|
||||
{ loc: `${base}/en/stay`, lastmod: today },
|
||||
{ loc: `${base}/newsroom`, lastmod: today },
|
||||
{ loc: `${base}/videos`, lastmod: today },
|
||||
|
||||
445
templates/supporters-astro/src/pages/stay.astro
Normal file
445
templates/supporters-astro/src/pages/stay.astro
Normal file
@ -0,0 +1,445 @@
|
||||
---
|
||||
// 병원 주변 안내(한국어). /en/stay 의 한국어 짝이다. 데이터는 국문 서비스(KorService2)라
|
||||
// 영문보다 훨씬 많다(음식점 338건·숙박 43건 대 18건·4건).
|
||||
// 구조는 /en/stay 와 같다. 세 부분이다.
|
||||
// 1. 병원 주변 — 도보·차 소요 시간으로 거른 주변 장소
|
||||
// 2. Festivals — 계절로 묶은 행사
|
||||
// 3. Suggested days — 시간표와 지도가 붙은 동선
|
||||
//
|
||||
// 데이터는 한국관광공사 TourAPI 영문(EngService2)에서 scripts/fetch_medical_tourism.py 가
|
||||
// 미리 수집한 src/data/medicalTourism.json 이다. 설명문은 detailCommon2 의 overview 다.
|
||||
//
|
||||
// 만들지 않는 것
|
||||
// - 평점·리뷰·영업시간: 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';
|
||||
|
||||
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;
|
||||
|
||||
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 = T.meta.origin.lat, oLng = T.meta.origin.lng;
|
||||
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: 'The day of a check-up', who: 'When you come back to the clinic often',
|
||||
blurb: 'Everything within a short walk. You do not need a taxi.',
|
||||
route: buildRoute(15 * 60, [
|
||||
clinicStop(clinic, 40, 'Your appointment ends here.'),
|
||||
...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: 'A short outing', who: 'When you can walk a little further',
|
||||
blurb: 'Still close, but out of the block.',
|
||||
route: buildRoute(13 * 60, [
|
||||
clinicStop(clinic, 20, 'Start from the clinic.'),
|
||||
...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: 'When you can travel', who: 'Later in your stay',
|
||||
blurb: 'Sights across the city. Expect to take a taxi or the subway.',
|
||||
route: buildRoute(10 * 60, [
|
||||
clinicStop(clinic, 10, 'Set off from the clinic.'),
|
||||
...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 };
|
||||
}
|
||||
|
||||
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">병원 주변</div>
|
||||
<h1 class="serif">병원 주변 안내</h1>
|
||||
<p class="lede">
|
||||
<strong>{clinic} 주변입니다.</strong> 아래 거리와 시간은 모두 {v(f.address?.full)} 기준입니다.
|
||||
상호와 사진, 설명은 한국관광공사 자료입니다. 병원의 진료시간과 전화는 <a href="/clinic">병원 정보</a>에 있습니다.
|
||||
</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="섹션으로 이동">
|
||||
{GROUPS.filter((g) => (T.places[g.key] ?? []).length).map((g) => (
|
||||
<a href={`#g-${g.key}`}>{g.label}<b>{(T.places[g.key] ?? []).length}</b></a>
|
||||
))}
|
||||
<a href="#festivals">축제<b>{T.festivals.length}</b></a>
|
||||
<a href="#days">동선<b>{ROUTES.length}</b></a>
|
||||
</nav>
|
||||
|
||||
<!-- ── 1. 주변 ── -->
|
||||
<section id="around">
|
||||
<div class="sec-head">
|
||||
<h2>병원 주변</h2>
|
||||
<span class="upd">{T.meta.fetchedAt.slice(0, 10)} 갱신</span>
|
||||
</div>
|
||||
<p class="sub">{T.filters.total}곳입니다. 병원에서 걸어가는 시간 순으로 놓았습니다.</p>
|
||||
|
||||
<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} — ${g.label.toLowerCase()} near ${clinic}`} 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">{T.meta.festivalRadiusKm}km 안의 행사 {T.festivals.length}건을 계절로 묶었습니다. 지금은 <strong>{nowSeason}</strong>입니다.</p>
|
||||
<div class="chips" role="group" aria-label="계절로 거르기">
|
||||
<button class="chip on" data-season="all">전체 <b>{T.festivals.length}</b></button>
|
||||
{SEASONS.map((s) => {
|
||||
const n = T.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">
|
||||
{T.festivals.map((e: any) => (
|
||||
<article class="card" data-season={e.season}>
|
||||
<div class="thumb">
|
||||
{e.image ? <img src={e.image} alt={`${e.title} — festival near ${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)}h {r.route.total % 60}m</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} min</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>도보 5분 안에는 {T.filters.walk5}곳뿐이고, {T.filters.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: T.meta.origin.lat, WX_LNG: T.meta.origin.lng }}>
|
||||
// 이동 시간 필터. 임계값은 누적이라 "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; });
|
||||
});
|
||||
});
|
||||
});
|
||||
// 지금 화면에 있는 섹션의 버튼을 켠다. 섹션이 많아 어디쯤인지 알기 어렵다.
|
||||
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;
|
||||
fetch('https://api.open-meteo.com/v1/forecast?latitude=' + LAT + '&longitude=' + LNG +
|
||||
'¤t=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>
|
||||
Loading…
Reference in New Issue
Block a user