feat(supporters): 오시는 길 지도·길찾기(VisitMap) 템플릿 반영, 워커 tourism 단계가 factSheet.geo 좌표 기록

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-11 11:38:53 +09:00
parent aa5b05227f
commit ed8f65030d
8 changed files with 261 additions and 11 deletions

View File

@ -6,7 +6,15 @@
"areaLabel": "", "areaLabel": "",
"founded": "", "founded": "",
"representative": "", "representative": "",
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" }, "address": {
"full": "",
"street": "",
"locality": "",
"region": "",
"postalNote": "",
"navigation": "",
"source": ""
},
"phone": "", "phone": "",
"fax": "", "fax": "",
"kakao": "", "kakao": "",
@ -18,15 +26,28 @@
"transitShort": "", "transitShort": "",
"transitNote": "", "transitNote": "",
"parking": "", "parking": "",
"hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] }, "hours": {
"building": { "summary": "", "floors": [], "source": "" }, "source": "",
"sourceLabel": "",
"rows": [],
"schema": []
},
"building": {
"summary": "",
"floors": [],
"source": ""
},
"departments": [], "departments": [],
"specialties": [], "specialties": [],
"safety": { "items": [], "source": "" }, "safety": {
"items": [],
"source": ""
},
"recognitions": [], "recognitions": [],
"surfaces": {}, "surfaces": {},
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.", "sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
"businessNo": "", "businessNo": "",
"checkedAt": "", "checkedAt": "",
"nextCheck": "" "nextCheck": "",
"geo": null
} }

View File

@ -0,0 +1,99 @@
---
// 오시는 길: 지도 + 주소 복사 + 길찾기 버튼. 방문 안내(/visit) 3. 찾아가기와 영어 홈 Getting Here 에서 쓴다.
// 좌표는 factSheet.geo {lat,lng,source} 를 먼저 보고(source 는 운영 기록이라 화면에 내지 않는다), 없으면 관광공사 수집 메타(medicalTourismKo.json meta.origin)를 쓴다. 둘 다 없으면 지도 없이 주소·버튼만 낸다.
// 지도는 OpenStreetMap 타일 + Leaflet(cdnjs). 길찾기는 네이버·카카오 지도의 공개 URL 형식(벤치마크 2026-09-11 확인)이고, 영어 화면에는 구글 지도 길찾기를 더한다.
import { fact, has, v } from '../lib';
import tour from '../data/medicalTourismKo.json';
interface Props { lang?: 'ko' | 'en'; nameEn?: string; addressEn?: string; title?: string; sub?: string; compact?: boolean; hideHead?: boolean }
const { lang = 'ko', nameEn, addressEn, title, sub, compact = false, hideHead = false } = Astro.props;
const f = fact as Record<string, any>;
const origin = (tour as any)?.meta?.origin ?? {};
const geo = f.geo && f.geo.lat && f.geo.lng ? f.geo : (origin.lat && origin.lng ? { lat: origin.lat, lng: origin.lng, source: origin.coordSource || '관광공사 수집 기준 좌표' } : null);
const nameKo = v(f.shortName, '병원');
const name = lang === 'en' ? (nameEn || f.shortNameEn || f.nameEn || nameKo) : nameKo;
const address = lang === 'en' ? (addressEn || f.address?.fullEn || f.address?.full || '') : (f.address?.full || '');
const phone: string = f.phone || '';
const enc = encodeURIComponent;
const naverUrl = geo ? `https://map.naver.com/p/directions/-/${geo.lng},${geo.lat},${enc(nameKo)}/-/transit` : (has(f.address?.full) ? `https://map.naver.com/p/search/${enc(f.address.full)}` : '');
const kakaoUrl = geo ? `https://map.kakao.com/link/to/${enc(nameKo)},${geo.lat},${geo.lng}` : (has(f.address?.full) ? `https://map.kakao.com/link/search/${enc(f.address.full)}` : '');
const googleUrl = geo ? `https://www.google.com/maps/dir/?api=1&destination=${geo.lat},${geo.lng}&destination_place_id=&travelmode=transit` : (address ? `https://www.google.com/maps/search/?api=1&query=${enc(address)}` : '');
const T = lang === 'en'
? { title: title ?? 'Getting Here', sub: sub ?? 'Address, and directions from where you are.', address: 'Address', copy: 'Copy', copied: 'Copied', phone: 'Phone', naver: 'Naver Map directions', kakao: 'Kakao Map directions', google: 'Google Maps directions', noMap: 'Map is added once the clinic confirms its location.', attribution: 'Map data © OpenStreetMap contributors' }
: { title: title ?? '오시는 길', sub: sub ?? '주소와 현재 위치에서 오는 길을 안내합니다.', address: '주소', copy: '복사', copied: '복사했습니다', phone: '문의', naver: '네이버 길찾기', kakao: '카카오 길찾기', google: '구글 지도 길찾기', noMap: '지도는 병원이 위치를 확인한 뒤 싣습니다.', attribution: '지도 데이터 © OpenStreetMap 기여자' };
const mapId = `visit-map-${Math.random().toString(36).slice(2, 8)}`;
---
{geo && <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="" />}
<section class={`visit-map ${compact ? 'compact' : ''}`}>
{!compact && !hideHead && <div class="vm-head"><div class="eyebrow">Directions</div><h2 class="serif">{T.title}</h2><p class="sub">{T.sub}</p></div>}
<div class="vm-grid">
<div class="vm-map-wrap">
{geo
? <div class="vm-map" id={mapId} data-lat={geo.lat} data-lng={geo.lng} data-name={name} role="img" aria-label={`${name} ${T.title}`}></div>
: <div class="vm-map vm-empty"><p>{T.noMap}</p></div>}
{geo && <p class="vm-attr">{T.attribution}</p>}
</div>
<div class="vm-card">
<div class="vm-label">{T.address}</div>
<div class="vm-address"><span>{address || v(null)}</span>{address && <button type="button" class="vm-copy" data-copy={address} data-done={T.copied}><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z"/></svg><span>{T.copy}</span></button>}</div>
{phone && <p class="vm-phone">{T.phone}: <a href={`tel:${phone.replace(/-/g, '')}`}>{phone}</a></p>}
{lang === 'ko' && has(f.transit) && <p class="vm-transit">{f.transit}</p>}
<div class="vm-actions">
{lang === 'en' && googleUrl && <a class="vm-btn google" href={googleUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.google}</a>}
{naverUrl && <a class="vm-btn naver" href={naverUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.naver}</a>}
{kakaoUrl && <a class="vm-btn kakao" href={kakaoUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.kakao}</a>}
</div>
</div>
</div>
</section>
<style>
.visit-map { margin: 1.6rem 0 2.2rem; }
.vm-head { margin-bottom: 1.2rem; }
.vm-head h2 { margin: 0.2rem 0 0.2rem; font-size: 1.6rem; }
.vm-head .sub { margin: 0; color: var(--slate-600); }
.vm-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.9fr); gap: 1.25rem; align-items: start; }
.vm-map { width: 100%; aspect-ratio: 16 / 10; min-height: 260px; border-radius: 16px; overflow: hidden; border: 1px solid var(--slate-100); box-shadow: var(--shadow); background: var(--primary-50); z-index: 0; }
.vm-empty { display: grid; place-items: center; color: var(--slate-500); font-size: 0.92rem; padding: 1rem; text-align: center; }
.vm-empty p { margin: 0; }
.vm-attr { font-size: 0.74rem; color: var(--slate-500); margin: 0.45rem 0 0; }
.vm-card { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.4rem; box-shadow: var(--shadow); display: grid; gap: 0.55rem; align-content: start; }
.vm-label { font-size: 0.78rem; color: var(--slate-500); font-weight: 600; letter-spacing: 0.02em; }
.vm-address { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.7rem; font-size: 1.08rem; font-weight: 700; color: var(--primary-900); line-height: 1.45; }
.vm-copy { display: inline-flex; align-items: center; gap: 0.3rem; border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 8px; padding: 0.25rem 0.55rem; font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; }
.vm-copy:hover { color: var(--primary-900); border-color: var(--primary-900); }
.vm-phone, .vm-transit { margin: 0; font-size: 0.92rem; color: var(--slate-600); }
.vm-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; }
.vm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; border-radius: 12px; padding: 0.75rem 0.9rem; font-size: 0.92rem; font-weight: 700; text-decoration: none; color: #fff; box-shadow: var(--shadow); }
.vm-btn:hover { text-decoration: none; box-shadow: var(--shadow-hover); }
.vm-btn.naver { background: #03C75A; }
.vm-btn.kakao { background: #FEE500; color: #191919; }
.vm-btn.google { background: var(--primary-900); }
.visit-map.compact .vm-grid { grid-template-columns: 1fr; }
@media (max-width: 760px) { .vm-grid { grid-template-columns: 1fr; } .vm-map { aspect-ratio: 4 / 3; } }
@media print { .vm-map { break-inside: avoid; } .vm-actions { display: none; } }
</style>
<script>
// 주소 복사
document.querySelectorAll<HTMLButtonElement>('.vm-copy').forEach((b) => b.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(b.dataset.copy || ''); const s = b.querySelector('span'); const orig = s?.textContent ?? ''; if (s) s.textContent = b.dataset.done || orig; setTimeout(() => { if (s) s.textContent = orig; }, 1400); } catch { /* 클립보드 권한 없음 */ }
}));
// 지도: Leaflet 을 필요할 때만 불러온다(지도가 있는 페이지에서만).
const maps = Array.from(document.querySelectorAll<HTMLElement>('.vm-map[data-lat]'));
if (maps.length) {
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js';
s.crossOrigin = '';
s.onload = () => {
const L = (window as any).L;
for (const el of maps) {
const lat = Number(el.dataset.lat), lng = Number(el.dataset.lng);
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 16);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" rel="noopener">OpenStreetMap</a> contributors' }).addTo(map);
const icon = L.divIcon({ className: 'vm-pin', html: '<svg width="34" height="44" viewBox="0 0 34 44" aria-hidden="true"><path d="M17 0C7.6 0 0 7.6 0 17c0 12.5 17 27 17 27s17-14.5 17-27C34 7.6 26.4 0 17 0Z" fill="#4F1DA1"/><circle cx="17" cy="17" r="6.5" fill="#fff"/></svg>', iconSize: [34, 44], iconAnchor: [17, 44], popupAnchor: [0, -40] });
L.marker([lat, lng], { icon }).addTo(map).bindPopup(`<b>${el.dataset.name ?? ''}</b>`);
}
};
document.head.appendChild(s);
}
</script>

View File

@ -219,5 +219,10 @@
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.", "sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
"businessNo": "220-08-86777", "businessNo": "220-08-86777",
"checkedAt": "2026-09-04", "checkedAt": "2026-09-04",
"nextCheck": "2026-10-04" "nextCheck": "2026-10-04",
"geo": {
"lat": 37.5049823,
"lng": 127.0254182,
"source": "네이버 지역검색 실측 2026-09-09"
}
} }

View File

@ -2,6 +2,7 @@
// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다. // 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다.
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import Gallery from '../components/Gallery.astro'; import Gallery from '../components/Gallery.astro';
import VisitMap from '../components/VisitMap.astro';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib'; import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib';
const f = fact as Record<string, any>; const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원'); const clinic = v(f.shortName, '병원');
@ -31,6 +32,7 @@ const ld = [
<p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p> <p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p>
<p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p> <p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p>
<p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p> <p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p>
<VisitMap hideHead />
{S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />} {S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />}
<h2>4. 도착하면</h2> <h2>4. 도착하면</h2>

View File

@ -0,0 +1,99 @@
---
// 오시는 길: 지도 + 주소 복사 + 길찾기 버튼. 방문 안내(/visit) 3. 찾아가기와 영어 홈 Getting Here 에서 쓴다.
// 좌표는 factSheet.geo {lat,lng,source} 를 먼저 보고(source 는 운영 기록이라 화면에 내지 않는다), 없으면 관광공사 수집 메타(medicalTourismKo.json meta.origin)를 쓴다. 둘 다 없으면 지도 없이 주소·버튼만 낸다.
// 지도는 OpenStreetMap 타일 + Leaflet(cdnjs). 길찾기는 네이버·카카오 지도의 공개 URL 형식(벤치마크 2026-09-11 확인)이고, 영어 화면에는 구글 지도 길찾기를 더한다.
import { fact, has, v } from '../lib';
import tour from '../data/medicalTourismKo.json';
interface Props { lang?: 'ko' | 'en'; nameEn?: string; addressEn?: string; title?: string; sub?: string; compact?: boolean; hideHead?: boolean }
const { lang = 'ko', nameEn, addressEn, title, sub, compact = false, hideHead = false } = Astro.props;
const f = fact as Record<string, any>;
const origin = (tour as any)?.meta?.origin ?? {};
const geo = f.geo && f.geo.lat && f.geo.lng ? f.geo : (origin.lat && origin.lng ? { lat: origin.lat, lng: origin.lng, source: origin.coordSource || '관광공사 수집 기준 좌표' } : null);
const nameKo = v(f.shortName, '병원');
const name = lang === 'en' ? (nameEn || f.shortNameEn || f.nameEn || nameKo) : nameKo;
const address = lang === 'en' ? (addressEn || f.address?.fullEn || f.address?.full || '') : (f.address?.full || '');
const phone: string = f.phone || '';
const enc = encodeURIComponent;
const naverUrl = geo ? `https://map.naver.com/p/directions/-/${geo.lng},${geo.lat},${enc(nameKo)}/-/transit` : (has(f.address?.full) ? `https://map.naver.com/p/search/${enc(f.address.full)}` : '');
const kakaoUrl = geo ? `https://map.kakao.com/link/to/${enc(nameKo)},${geo.lat},${geo.lng}` : (has(f.address?.full) ? `https://map.kakao.com/link/search/${enc(f.address.full)}` : '');
const googleUrl = geo ? `https://www.google.com/maps/dir/?api=1&destination=${geo.lat},${geo.lng}&destination_place_id=&travelmode=transit` : (address ? `https://www.google.com/maps/search/?api=1&query=${enc(address)}` : '');
const T = lang === 'en'
? { title: title ?? 'Getting Here', sub: sub ?? 'Address, and directions from where you are.', address: 'Address', copy: 'Copy', copied: 'Copied', phone: 'Phone', naver: 'Naver Map directions', kakao: 'Kakao Map directions', google: 'Google Maps directions', noMap: 'Map is added once the clinic confirms its location.', attribution: 'Map data © OpenStreetMap contributors' }
: { title: title ?? '오시는 길', sub: sub ?? '주소와 현재 위치에서 오는 길을 안내합니다.', address: '주소', copy: '복사', copied: '복사했습니다', phone: '문의', naver: '네이버 길찾기', kakao: '카카오 길찾기', google: '구글 지도 길찾기', noMap: '지도는 병원이 위치를 확인한 뒤 싣습니다.', attribution: '지도 데이터 © OpenStreetMap 기여자' };
const mapId = `visit-map-${Math.random().toString(36).slice(2, 8)}`;
---
{geo && <link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.css" crossorigin="" />}
<section class={`visit-map ${compact ? 'compact' : ''}`}>
{!compact && !hideHead && <div class="vm-head"><div class="eyebrow">Directions</div><h2 class="serif">{T.title}</h2><p class="sub">{T.sub}</p></div>}
<div class="vm-grid">
<div class="vm-map-wrap">
{geo
? <div class="vm-map" id={mapId} data-lat={geo.lat} data-lng={geo.lng} data-name={name} role="img" aria-label={`${name} ${T.title}`}></div>
: <div class="vm-map vm-empty"><p>{T.noMap}</p></div>}
{geo && <p class="vm-attr">{T.attribution}</p>}
</div>
<div class="vm-card">
<div class="vm-label">{T.address}</div>
<div class="vm-address"><span>{address || v(null)}</span>{address && <button type="button" class="vm-copy" data-copy={address} data-done={T.copied}><svg width="14" height="14" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M16 1H4a2 2 0 0 0-2 2v14h2V3h12V1Zm3 4H8a2 2 0 0 0-2 2v14a2 2 0 0 0 2 2h11a2 2 0 0 0 2-2V7a2 2 0 0 0-2-2Zm0 16H8V7h11v14Z"/></svg><span>{T.copy}</span></button>}</div>
{phone && <p class="vm-phone">{T.phone}: <a href={`tel:${phone.replace(/-/g, '')}`}>{phone}</a></p>}
{lang === 'ko' && has(f.transit) && <p class="vm-transit">{f.transit}</p>}
<div class="vm-actions">
{lang === 'en' && googleUrl && <a class="vm-btn google" href={googleUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.google}</a>}
{naverUrl && <a class="vm-btn naver" href={naverUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.naver}</a>}
{kakaoUrl && <a class="vm-btn kakao" href={kakaoUrl} rel="noopener" target="_blank"><svg width="16" height="16" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true"><path d="M21.7 2.3 2.6 10.5c-.9.4-.8 1.7.1 2l7.4 2.4 2.4 7.4c.3.9 1.6 1 2 .1l8.2-19.1c.4-.9-.5-1.8-1-1Z"/></svg>{T.kakao}</a>}
</div>
</div>
</div>
</section>
<style>
.visit-map { margin: 1.6rem 0 2.2rem; }
.vm-head { margin-bottom: 1.2rem; }
.vm-head h2 { margin: 0.2rem 0 0.2rem; font-size: 1.6rem; }
.vm-head .sub { margin: 0; color: var(--slate-600); }
.vm-grid { display: grid; grid-template-columns: minmax(0, 1.25fr) minmax(280px, 0.9fr); gap: 1.25rem; align-items: start; }
.vm-map { width: 100%; aspect-ratio: 16 / 10; min-height: 260px; border-radius: 16px; overflow: hidden; border: 1px solid var(--slate-100); box-shadow: var(--shadow); background: var(--primary-50); z-index: 0; }
.vm-empty { display: grid; place-items: center; color: var(--slate-500); font-size: 0.92rem; padding: 1rem; text-align: center; }
.vm-empty p { margin: 0; }
.vm-attr { font-size: 0.74rem; color: var(--slate-500); margin: 0.45rem 0 0; }
.vm-card { background: #fff; border: 1px solid var(--slate-100); border-radius: 16px; padding: 1.3rem 1.4rem; box-shadow: var(--shadow); display: grid; gap: 0.55rem; align-content: start; }
.vm-label { font-size: 0.78rem; color: var(--slate-500); font-weight: 600; letter-spacing: 0.02em; }
.vm-address { display: flex; flex-wrap: wrap; align-items: center; gap: 0.5rem 0.7rem; font-size: 1.08rem; font-weight: 700; color: var(--primary-900); line-height: 1.45; }
.vm-copy { display: inline-flex; align-items: center; gap: 0.3rem; border: 1px solid var(--slate-200); background: #fff; color: var(--slate-600); border-radius: 8px; padding: 0.25rem 0.55rem; font: inherit; font-size: 0.78rem; font-weight: 600; cursor: pointer; }
.vm-copy:hover { color: var(--primary-900); border-color: var(--primary-900); }
.vm-phone, .vm-transit { margin: 0; font-size: 0.92rem; color: var(--slate-600); }
.vm-actions { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 0.6rem; margin-top: 0.5rem; }
.vm-btn { display: inline-flex; align-items: center; justify-content: center; gap: 0.45rem; border-radius: 12px; padding: 0.75rem 0.9rem; font-size: 0.92rem; font-weight: 700; text-decoration: none; color: #fff; box-shadow: var(--shadow); }
.vm-btn:hover { text-decoration: none; box-shadow: var(--shadow-hover); }
.vm-btn.naver { background: #03C75A; }
.vm-btn.kakao { background: #FEE500; color: #191919; }
.vm-btn.google { background: var(--primary-900); }
.visit-map.compact .vm-grid { grid-template-columns: 1fr; }
@media (max-width: 760px) { .vm-grid { grid-template-columns: 1fr; } .vm-map { aspect-ratio: 4 / 3; } }
@media print { .vm-map { break-inside: avoid; } .vm-actions { display: none; } }
</style>
<script>
// 주소 복사
document.querySelectorAll<HTMLButtonElement>('.vm-copy').forEach((b) => b.addEventListener('click', async () => {
try { await navigator.clipboard.writeText(b.dataset.copy || ''); const s = b.querySelector('span'); const orig = s?.textContent ?? ''; if (s) s.textContent = b.dataset.done || orig; setTimeout(() => { if (s) s.textContent = orig; }, 1400); } catch { /* 클립보드 권한 없음 */ }
}));
// 지도: Leaflet 을 필요할 때만 불러온다(지도가 있는 페이지에서만).
const maps = Array.from(document.querySelectorAll<HTMLElement>('.vm-map[data-lat]'));
if (maps.length) {
const s = document.createElement('script');
s.src = 'https://cdnjs.cloudflare.com/ajax/libs/leaflet/1.9.4/leaflet.min.js';
s.crossOrigin = '';
s.onload = () => {
const L = (window as any).L;
for (const el of maps) {
const lat = Number(el.dataset.lat), lng = Number(el.dataset.lng);
const map = L.map(el, { scrollWheelZoom: false }).setView([lat, lng], 16);
L.tileLayer('https://tile.openstreetmap.org/{z}/{x}/{y}.png', { maxZoom: 19, attribution: '&copy; <a href="https://www.openstreetmap.org/copyright" rel="noopener">OpenStreetMap</a> contributors' }).addTo(map);
const icon = L.divIcon({ className: 'vm-pin', html: '<svg width="34" height="44" viewBox="0 0 34 44" aria-hidden="true"><path d="M17 0C7.6 0 0 7.6 0 17c0 12.5 17 27 17 27s17-14.5 17-27C34 7.6 26.4 0 17 0Z" fill="#4F1DA1"/><circle cx="17" cy="17" r="6.5" fill="#fff"/></svg>', iconSize: [34, 44], iconAnchor: [17, 44], popupAnchor: [0, -40] });
L.marker([lat, lng], { icon }).addTo(map).bindPopup(`<b>${el.dataset.name ?? ''}</b>`);
}
};
document.head.appendChild(s);
}
</script>

View File

@ -6,7 +6,15 @@
"areaLabel": "", "areaLabel": "",
"founded": "", "founded": "",
"representative": "", "representative": "",
"address": { "full": "", "street": "", "locality": "", "region": "", "postalNote": "", "navigation": "", "source": "" }, "address": {
"full": "",
"street": "",
"locality": "",
"region": "",
"postalNote": "",
"navigation": "",
"source": ""
},
"phone": "", "phone": "",
"fax": "", "fax": "",
"kakao": "", "kakao": "",
@ -18,15 +26,28 @@
"transitShort": "", "transitShort": "",
"transitNote": "", "transitNote": "",
"parking": "", "parking": "",
"hours": { "source": "", "sourceLabel": "", "rows": [], "schema": [] }, "hours": {
"building": { "summary": "", "floors": [], "source": "" }, "source": "",
"sourceLabel": "",
"rows": [],
"schema": []
},
"building": {
"summary": "",
"floors": [],
"source": ""
},
"departments": [], "departments": [],
"specialties": [], "specialties": [],
"safety": { "items": [], "source": "" }, "safety": {
"items": [],
"source": ""
},
"recognitions": [], "recognitions": [],
"surfaces": {}, "surfaces": {},
"sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.", "sideEffectNotice": "수술 후 개인에 따라 염증, 출혈, 신경손상 등의 부작용이 있을 수 있습니다.",
"businessNo": "", "businessNo": "",
"checkedAt": "", "checkedAt": "",
"nextCheck": "" "nextCheck": "",
"geo": null
} }

View File

@ -2,6 +2,7 @@
// 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다. // 방문 안내. 병원 정보(/clinic)가 사실 표라면 이 페이지는 "예약하고, 찾아가고, 도착해서 뭘 하는지"의 행동 순서다. 값은 모두 factSheet.json에서 가져온다.
import Base from '../layouts/Base.astro'; import Base from '../layouts/Base.astro';
import Gallery from '../components/Gallery.astro'; import Gallery from '../components/Gallery.astro';
import VisitMap from '../components/VisitMap.astro';
import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib'; import { fact, site as S, SITE_NAME, clinicSchema, reservationUrl, v, has } from '../lib';
const f = fact as Record<string, any>; const f = fact as Record<string, any>;
const clinic = v(f.shortName, '병원'); const clinic = v(f.shortName, '병원');
@ -31,6 +32,7 @@ const ld = [
<p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p> <p><strong>주소</strong> {v(f.address?.full)}{has(f.address?.postalNote) && <> (지번 {f.address.postalNote})</>}</p>
<p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p> <p><strong>지하철</strong> {v(f.transit)}.{has(f.transitNote) && <> {f.transitNote}</>}</p>
<p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p> <p><strong>차로 올 때</strong> {v(f.parking, `주차 안내 ${v(null)}`)}.</p>
<VisitMap hideHead />
{S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />} {S.visitMaps?.length > 0 && <Gallery cols={2} items={S.visitMaps} />}
<h2>4. 도착하면</h2> <h2>4. 도착하면</h2>

View File

@ -285,6 +285,7 @@ await phase('tourism', () => {
return { warn: true, summary: `주변 안내 수집 실패 (페이지는 확인 대기로 발행)\n${tail(r.out, 3)}` }; return { warn: true, summary: `주변 안내 수집 실패 (페이지는 확인 대기로 발행)\n${tail(r.out, 3)}` };
} }
const en = readJson(outEn), ko = readJson(outKo); const en = readJson(outEn), ko = readJson(outKo);
if (en.meta?.origin?.lat) { fact.geo = { lat: en.meta.origin.lat, lng: en.meta.origin.lng, source: en.meta.origin.coordSource || '관광공사 수집 기준 좌표' }; writeJson(join(DATA, 'factSheet.json'), fact); } // 오시는 길 지도(VisitMap)가 쓴다
status.report = { ...(status.report ?? {}), tourism: { origin: en.meta.origin, en: { places: en.filters.total, festivals: en.festivals.length }, ko: { places: ko.filters.total, festivals: ko.festivals.length }, fetchedAt: en.meta.fetchedAt } }; status.report = { ...(status.report ?? {}), tourism: { origin: en.meta.origin, en: { places: en.filters.total, festivals: en.festivals.length }, ko: { places: ko.filters.total, festivals: ko.festivals.length }, fetchedAt: en.meta.fetchedAt } };
return `기준 ${en.meta.origin.lat}, ${en.meta.origin.lng} (${en.meta.origin.coordSource}) · 영문 장소 ${en.filters.total}·축제 ${en.festivals.length} · 국문 장소 ${ko.filters.total}·축제 ${ko.festivals.length}`; return `기준 ${en.meta.origin.lat}, ${en.meta.origin.lng} (${en.meta.origin.coordSource}) · 영문 장소 ${en.filters.total}·축제 ${en.festivals.length} · 국문 장소 ${ko.filters.total}·축제 ${ko.festivals.length}`;
}); });