fix(supporters): 동선 지도를 실제 OpenStreetMap 타일로 교체 + 핫링크 차단 대응

지도가 회색 바탕에 점만 찍혀 있어 어디인지 알 수 없었다. OSM 타일을 좌표로 직접
깔고 그 위에 핀을 얹었다. 지도 라이브러리는 부르지 않는다.
- Web Mercator 로 lat/lng → 월드 픽셀을 계산하고, 모든 지점이 여백 안에 들어오는
  가장 확대된 배율을 고른다(뷰성형외과 동선은 z=15).
- 타일은 img 가 아니라 CSS 배경으로 둔다. 장식 요소이고, 지도의 뜻은 감싸는 요소의
  aria-label 이 전한다. 스크린리더가 타일 조각을 하나씩 읽지 않는다.
- OSM 정책에 따라 "© OpenStreetMap contributors" 를 표기한다.

사진 핫링크
- 모든 사진에 referrerpolicy="no-referrer" 를 넣었다. 네이버 썸네일 CDN 이
  리퍼러를 보고 403 을 준다. 서버에서 검사할 때는 리퍼러가 없어 통과해 놓치기 쉽다.
- 사진 alt 를 채웠다. npm run build 의 접근성 게이트가 빈 alt 를 오류로 잡는다.
  로컬에서 astro build 만 돌려 게이트를 건너뛴 탓에 배포에서 처음 걸렸다.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Haewon Kam 2026-09-09 14:01:16 +09:00
parent e32633e9a6
commit 2fc3f93163
4 changed files with 134 additions and 30 deletions

View File

@ -113,15 +113,44 @@ const ROUTES = [
},
].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 W = 640, H = 200, PAD = 26;
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)];
const sx = (ln: number) => (ln1 - ln0 < 1e-6 ? W / 2 : PAD + ((ln - ln0) / (ln1 - ln0)) * (W - PAD * 2));
const sy = (la: number) => (la1 - la0 < 1e-6 ? H / 2 : H - PAD - ((la - la0) / (la1 - la0)) * (H - PAD * 2));
return stops.map((s, i) => ({ x: +sx(s.lng).toFixed(1), y: +sy(s.lat).toFixed(1), n: i + 1, label: s.label }));
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
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) =>
@ -198,7 +227,7 @@ const ld = [
{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="" loading="lazy" /> : <span class="nophoto">{shortName(p.title)}</span>}
{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">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
@ -233,7 +262,7 @@ const ld = [
{T.festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(e.title)}</span>}
{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">
@ -271,14 +300,22 @@ const ld = [
<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>
<svg class="map" viewBox="0 0 640 200" role="img" aria-label={`Route map with ${pins.length} stops`}>
<rect width="640" height="200" rx="12" fill="#F4F6FB" />
<polyline points={pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#C5CBF5" stroke-width="2" stroke-dasharray="5 4" />
{pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="11" fill="#0A1128" /><text x={p.x} y={p.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<p class="maphint">Positions are drawn from coordinates, to scale with each other. Not a street map.</p>
<div class="map" role="img" aria-label={`Map of ${pins.pins.length} stops: ${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>

View File

@ -463,3 +463,18 @@ td { color: var(--slate-700); }
.stay .wx-cond { font-weight: 700; color: var(--primary-900, #0A1128); }
.stay .wx-meta { font-size: 0.8rem; color: var(--slate-600, #475569); margin-top: 0.15rem; }
.stay .wx-note { margin: 0; font-size: 0.88rem; color: var(--slate-700, #334155); max-width: 30rem; }
/* 동선 지도. OSM 타일을 좌표로 깔고 그 위에 핀을 얹는다. 출처 표기는 OSM 정책상 필수다. */
.stay .route .map {
position: relative; width: 100%; max-width: 680px; height: 340px;
border-radius: 12px; overflow: hidden; background: #E8EDF3;
border: 1px solid var(--line, #E2E8F0);
}
.stay .route .map-tiles { position: absolute; inset: 0; }
.stay .route .map-tiles .tile { position: absolute; width: 256px; height: 256px; background-size: 256px 256px; background-repeat: no-repeat; }
.stay .route .map-pins { position: absolute; inset: 0; width: 100%; height: 100%; }
.stay .route .map-credit {
position: absolute; right: 0; bottom: 0;
background: rgba(255, 255, 255, 0.82); color: var(--slate-600, #475569);
font-size: 0.66rem; padding: 0.1rem 0.35rem; border-top-left-radius: 6px;
}

View File

@ -113,15 +113,44 @@ const ROUTES = [
},
].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 W = 640, H = 200, PAD = 26;
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)];
const sx = (ln: number) => (ln1 - ln0 < 1e-6 ? W / 2 : PAD + ((ln - ln0) / (ln1 - ln0)) * (W - PAD * 2));
const sy = (la: number) => (la1 - la0 < 1e-6 ? H / 2 : H - PAD - ((la - la0) / (la1 - la0)) * (H - PAD * 2));
return stops.map((s, i) => ({ x: +sx(s.lng).toFixed(1), y: +sy(s.lat).toFixed(1), n: i + 1, label: s.label }));
// 모든 지점이 여백 안에 들어오는 가장 확대된 배율을 고른다.
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) =>
@ -198,7 +227,7 @@ const ld = [
{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="" loading="lazy" /> : <span class="nophoto">{shortName(p.title)}</span>}
{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">{p.travel.label} <em>{p.distanceM! < 1000 ? `${p.distanceM} m` : `${(p.distanceM! / 1000).toFixed(1)} km`}</em></span>}
</div>
<div class="body">
@ -233,7 +262,7 @@ const ld = [
{T.festivals.map((e: any) => (
<article class="card" data-season={e.season}>
<div class="thumb">
{e.image ? <img src={e.image} alt="" loading="lazy" /> : <span class="nophoto">{shortName(e.title)}</span>}
{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">
@ -271,14 +300,22 @@ const ld = [
<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>
<svg class="map" viewBox="0 0 640 200" role="img" aria-label={`Route map with ${pins.length} stops`}>
<rect width="640" height="200" rx="12" fill="#F4F6FB" />
<polyline points={pins.map((p) => `${p.x},${p.y}`).join(' ')} fill="none" stroke="#C5CBF5" stroke-width="2" stroke-dasharray="5 4" />
{pins.map((p) => (
<g><circle cx={p.x} cy={p.y} r="11" fill="#0A1128" /><text x={p.x} y={p.y + 4} text-anchor="middle" font-size="11" font-weight="700" fill="#fff">{p.n}</text></g>
))}
</svg>
<p class="maphint">Positions are drawn from coordinates, to scale with each other. Not a street map.</p>
<div class="map" role="img" aria-label={`Map of ${pins.pins.length} stops: ${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>

View File

@ -463,3 +463,18 @@ td { color: var(--slate-700); }
.stay .wx-cond { font-weight: 700; color: var(--primary-900, #0A1128); }
.stay .wx-meta { font-size: 0.8rem; color: var(--slate-600, #475569); margin-top: 0.15rem; }
.stay .wx-note { margin: 0; font-size: 0.88rem; color: var(--slate-700, #334155); max-width: 30rem; }
/* 동선 지도. OSM 타일을 좌표로 깔고 그 위에 핀을 얹는다. 출처 표기는 OSM 정책상 필수다. */
.stay .route .map {
position: relative; width: 100%; max-width: 680px; height: 340px;
border-radius: 12px; overflow: hidden; background: #E8EDF3;
border: 1px solid var(--line, #E2E8F0);
}
.stay .route .map-tiles { position: absolute; inset: 0; }
.stay .route .map-tiles .tile { position: absolute; width: 256px; height: 256px; background-size: 256px 256px; background-repeat: no-repeat; }
.stay .route .map-pins { position: absolute; inset: 0; width: 100%; height: 100%; }
.stay .route .map-credit {
position: absolute; right: 0; bottom: 0;
background: rgba(255, 255, 255, 0.82); color: var(--slate-600, #475569);
font-size: 0.66rem; padding: 0.1rem 0.35rem; border-top-left-radius: 6px;
}