o2o-site-AEO/solution/site/scripts/mockup/inject.js
Mina Choi 43ba33dd65 [feat] site/scripts: /s/stay 시연본 조립 도구 일습 — 일정 생성기·주입분·검증
이 목업은 payload 가 없어 프리렌더 재굽기 대상이 아니다 — `index.html` 한 장이 유일본이고
자산이 지워지면 사람이 되돌려 넣어야 한다(CLAUDE.md 의 ★★ 함정). 지금까지 그 한 장을
이 맥에서만 만들고 있었다. 다른 사람이 이어받을 수 있도록 재료를 전부 올린다.
`build/index.html` 은 생성물이라 이그노어 그대로다 — `patch_stay.py` 로 다시 나온다.

- build_itinerary.py: 테마 21개 일정 생성. 좌표에서 이동시간을 계산하고(도보 4km/h,
  1.5km 초과는 차 25km/h + 주차 5분) 입·퇴실·끼니 창을 맞춘다. 규칙 14종 감사가
  **빌드 안**에 있어 하나라도 어기면 payload 를 쓰지 않고 멈춘다 — 검사가 빌드 밖에
  있던 동안 뼈대를 고칠 때마다 안 보는 규칙이 생겼다(2026-09-11 REVIEW)
- build_story.py: 노래 25곡·인물 57명. 사진은 위키백과 문서 pageimages 만 믿는다
  (이름 검색은 동명이인을 끌고 온다 — 이수현→걸그룹, 박성현→골퍼)
- patch_stay.py: 캐치프레이즈 100개·자작곡 5곡·객실 사진(A동 12/B동 10)을 넣고
  payload 를 갈아 끼운 뒤 inject.css/js 를 `</body>` 앞에 주입해 index.html 을 짠다
- inject.js/css: React 가 다시 그려도 살아남아야 하는 다섯 가지(캐치프레이즈 순환·
  헤더 미니 플레이어·지도 링크·사진 저작자 표시·카로셀 제어). `#root` 밖에 둔다
- audit-all.mjs / rails-test.mjs: 실제 브라우저로 34종 검사, 레일 13개 자동 넘김 전수
- README.md: 이어받는 사람이 먼저 읽는 문서. 배포·함정·§7 "내가 틀렸던 6가지"
- PROMPTS.md / TEXT.md / REVIEW-2026-09-11.md: 문구 생성 프롬프트 · 전체 텍스트 · 검수 보고

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-09-11 10:28:52 +09:00

709 lines
35 KiB
JavaScript
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.

/* /s/stay 목업 주입분 — ① 캐치프레이즈 순환 ② 헤더 안 미니 플레이어.
*
* ★ 플레이어는 자리를 세 번 옮겼다. 지금은 **사이트 헤더 안**이다.
* ① 하단 바 → 모바일에서 발행본의 탭바(fixed bottom:0 · 61px · z-50 — 위치·전화·예약)를 덮었다.
* ② 상단 바 → 헤더와 두 줄이 되어 화면 위 120px 이 크롬이 됐다("조금 이상한데").
* ③ 헤더 안 아이콘 — 줄이 늘지 않는다. 원판·곡명 없이 재생·다음·목록 세 단추뿐이고,
* 곡 정보는 목록을 열 때만 보인다.
*
* ★ 헤더는 React 가 그린다. 그래서 두 가지를 지킨다.
* · 단추는 헤더 안에 꽂지만, 없어졌는지 감시해서 다시 꽂는다(하이드레이션 재렌더가 지운다).
* · 목록 패널은 #root **밖**(body 직속)에 둔다 — 안에 두면 재렌더에 통째로 날아간다.
*
* ★ 재생은 곡의 audioUrl(사장님이 직접 만든 음원)만 쓴다. 파일은 `/s/<slug>/audio/…` 에 있고,
* nginx 의 `^~ /s/` 가 그대로 서빙한다(Range 206 확인). 옛 가요 네 곡은 우리 음원이 아니라
* 플레이어에 올리지 않는다 — 가요 다방 섹션에는 그대로 남는다.
* ★ 가사는 payload 에 있을 때만 낸다. 직접 만든 곡이라 원문을 실어도 되는 유일한 경우다.
*/
(function () {
'use strict';
var P = window.__SITE_PAYLOAD__;
if (!P) return;
var REDUCED = window.matchMedia && window.matchMedia('(prefers-reduced-motion: reduce)').matches;
function pick(list) {
return list && list.length ? list[Math.floor(Math.random() * list.length)] : null;
}
/* ══ ① 캐치프레이즈 ══════════════════════════════════════════════════════
* 일반(랜덤) → 계절 → 일반 → 월 → 일반 → 날씨 순으로 한 바퀴를 만들고 그 바퀴를 돈다.
* 일반은 자루에서 뽑아 쓰고 자루가 비면 다시 채운다 — 매번 랜덤이면 같은 문장이 연달아 나온다.
*/
function catchphrases() {
var box = P.narrative && P.narrative.catchphrases;
return (box && box.items) || [];
}
/* 3개월 단위. 12·1·2 겨울 / 3·4·5 봄 / 6·7·8 여름 / 9·10·11 가을 */
var SEASON_OF_MONTH = ['겨울', '겨울', '봄', '봄', '봄', '여름', '여름', '여름', '가을', '가을', '가을', '겨울'];
function startCatchphrase() {
var items = catchphrases();
if (items.length === 0) return;
var base = ((P.narrative && P.narrative.tagline) || '').trim();
var node = null;
var last = null;
/* ★ 노드를 들고 있지 않고 매번 찾는다.
이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에
실패하면 #root 를 **통째로 다시 그린다** — 그때 히어로의 <p> 가 새 노드로 바뀐다.
한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */
function findNode() {
var wanted = [base];
if (last) wanted.push(last);
var candidates = document.querySelectorAll('#root p');
for (var i = 0; i < candidates.length; i += 1) {
if (wanted.indexOf(candidates[i].textContent.trim()) >= 0) return candidates[i];
}
return null;
}
if (!findNode()) return;
var byKind = function (kind) {
return items.filter(function (it) { return it.kind === kind; });
};
var weather = null; // 못 받아 오면 날씨 문장은 아예 순환에 넣지 않는다.
var bag = [];
var cycle = [];
function nextGeneral() {
if (bag.length === 0) {
bag = byKind('general').slice();
for (var j = bag.length - 1; j > 0; j -= 1) {
var k = Math.floor(Math.random() * (j + 1));
var t = bag[j]; bag[j] = bag[k]; bag[k] = t;
}
}
return bag.pop();
}
function buildCycle() {
var now = new Date();
var month = now.getMonth() + 1;
var season = SEASON_OF_MONTH[month - 1];
var out = [nextGeneral()];
out.push(pick(byKind('season').filter(function (it) { return it.season === season; })));
out.push(nextGeneral());
out.push(pick(byKind('month').filter(function (it) { return it.month === month; })));
out.push(nextGeneral());
if (weather) {
out.push(pick(byKind('weather').filter(function (it) { return it.weather === weather; })));
}
return out.filter(Boolean);
}
/* 문장 하나를 단어 칸으로 조립한다. innerHTML 을 안 쓰는 이유는 문구가 데이터라서다 —
지금은 우리가 쓴 문장이지만, 이 자리는 나중에 서버가 채운다. */
function paint(text) {
var line = document.createElement('span');
line.className = 'w4d-line';
text.split(/\s+/).forEach(function (word, index) {
if (index > 0) line.appendChild(document.createTextNode(' '));
var mask = document.createElement('span');
mask.className = 'w4d-w';
var inner = document.createElement('span');
inner.textContent = word;
// 단어마다 조금씩 늦게 선다. 55ms 면 열 단어가 0.5초 안에 다 서서 읽기를 안 기다리게 한다.
inner.style.setProperty('--w4d-d', index * 55 + 'ms');
mask.appendChild(inner);
line.appendChild(mask);
});
node.textContent = '';
node.appendChild(line);
// 클래스를 한 프레임 뒤에 건다 — 같은 프레임에 붙이면 애니메이션 시작 상태가 없다.
window.requestAnimationFrame(function () { line.classList.add('w4d-line--in'); });
}
function show(text) {
last = text;
if (REDUCED) { node.textContent = text; return; }
var current = node.firstElementChild;
if (current && current.classList.contains('w4d-line')) {
current.classList.remove('w4d-line--in');
current.classList.add('w4d-line--out');
}
window.setTimeout(function () {
if (!node || !node.isConnected) node = findNode();
if (!node) return;
paint(text);
}, 300);
}
function tick() {
if (!node || !node.isConnected) node = findNode();
if (!node) return;
if (cycle.length === 0) cycle = buildCycle();
var next = cycle.shift();
if (next && next.text) show(next.text);
}
/* 날씨는 렌더러가 쓰는 그 엔드포인트로 받는다(같은 오리진). 실패하면 조용히 넘어간다 —
날씨 문장 몇 개 때문에 순환이 멈추면 안 된다. */
function refreshWeather() {
var place = P.place || {};
if (!place.regionCode || place.latitude == null) return;
var query = 'region_code=' + encodeURIComponent(place.regionCode) +
'&latitude=' + place.latitude + '&longitude=' + place.longitude;
fetch('/v1/local/weather?' + query)
.then(function (r) { return r.ok ? r.json() : null; })
.then(function (body) {
if (!body || !body.weather) return;
var code = Number(body.weather.weather_code);
// 렌더러(use-live-weather.ts)와 같은 분류를 쓴다 — 갈리면 화면의 날씨와 문장이 어긋난다.
weather = code === 0 ? '맑음'
: code >= 1 && code <= 3 ? '구름많음'
: code >= 50 && code <= 69 ? '비'
: code >= 70 && code <= 79 ? '눈' : '흐림';
})
.catch(function () { /* 구운 값 그대로 둔다 */ });
}
refreshWeather();
window.setInterval(refreshWeather, 10 * 60 * 1000);
// 구워진 줄 높이를 하한으로 박는다 — 문장이 짧아 한 줄이 되면 아래 단추가 위로 튄다.
(function reserve() {
var found = findNode();
if (!found) return;
var h = found.getBoundingClientRect().height;
if (h > 0) found.style.minHeight = Math.ceil(h) + 'px';
})();
// 첫 문장은 구워진 그대로 5초 두고 시작한다 — 들어오자마자 글자가 바뀌면 읽던 것을 놓친다.
window.setTimeout(function () {
tick();
window.setInterval(tick, 7000);
}, 5000);
}
/* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════
* ★ 단추는 둘뿐이다 — 재생/멈춤과 목록 (2026-09-10 사장님: 셔플 단추 "얘는 빼도 되지 않을까?").
* 곡이 끝나면 어차피 무작위로 다음 곡이 걸리므로 셔플 단추가 할 일이 없었다.
* 다른 곡을 지금 듣고 싶으면 목록에서 고른다 — 그게 더 짧은 길이다. */
var ICON = {
/* ★ 카세트가 먼저 선다 (2026-09-10 사장님: "전혀 뮤직플레이어 같지가 않아").
세모·셔플·줄 세 개만 두면 헤더의 다른 아이콘과 구별이 안 된다 — 무엇을 하는 물건인지가
한눈에 안 읽힌다. 카세트는 그 자체가 '노래' 라는 표시이고, 재생 중에는 릴이 돈다. */
cassette: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.6">' +
'<rect x="2" y="5" width="20" height="14" rx="2"/>' +
'<g class="w4d-reel"><circle cx="8.5" cy="12" r="2.6"/><path d="M8.5 9.4v5.2M5.9 12h5.2"/></g>' +
'<g class="w4d-reel"><circle cx="15.5" cy="12" r="2.6"/><path d="M15.5 9.4v5.2M12.9 12h5.2"/></g>' +
'<path d="M6 19l1.5-2.4h9L18 19"/></svg>',
note: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M20 4v10.5a3.5 3.5 0 1 1-2-3.16V7.8l-8 1.6v7.1a3.5 3.5 0 1 1-2-3.16V6.2z"/></svg>',
play: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M8 5v14l11-7z"/></svg>',
pause: '<svg viewBox="0 0 24 24" fill="currentColor"><path d="M7 5h3.4v14H7zm6.6 0H17v14h-3.4z"/></svg>',
/* ★ 햄버거(줄 세 개)를 쓰지 않는다 (2026-09-11 사장님: "햄버거 메뉴 버튼이랑 음악 메뉴
아이콘이랑 같아서 헷갈림"). 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있었다.
줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다. */
list: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" ' +
'stroke-linecap="round"><path d="M4 7h11M4 12h11M4 17h7"/>' +
'<path d="M19 17.5V9l3 1.2"/><circle cx="17.4" cy="17.6" r="1.7" fill="currentColor" stroke="none"/></svg>',
close: '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M6 6l12 12M18 6L6 18"/></svg>',
};
/* 플레이어가 트는 것은 **숙소가 만든 곡**이다(payload.narrative.ownSongs).
가요 다방 섹션(theme.sections.songs)은 이 도시를 노래한 옛 곡이라 우리 음원이 없다 —
목록에 세우면 누를 때마다 유튜브로 튕겨 나간다. 두 목록은 섞지 않는다. */
function songsOfPayload() {
var box = P.narrative && P.narrative.ownSongs;
return (box && box.items) || [];
}
function startPlayer() {
var songs = songsOfPayload().filter(function (song) { return song.audioUrl; });
if (songs.length === 0) return;
/* ★ **시작 곡만 무작위, 그다음은 순차**다 (2026-09-11 사장님 지시).
매번 무작위로 뽑으면 다섯 곡을 한 바퀴 도는 데 순서가 없어 "아까 그 곡" 을 다시 찾기 어렵다.
손님마다 다른 곡으로 시작하되(들어올 때마다 같은 곡이면 배경음악이 하나인 것처럼 들린다),
한 번 시작하면 A면 순서대로 끝까지 돌고 처음으로 돌아온다. */
var cur = Math.floor(Math.random() * songs.length);
var playing = false;
function nextIndex() {
return (cur + 1) % songs.length;
}
/* ── 헤더에 꽂을 단추 세 개 ─────────────────────────────────────── */
var mini = document.createElement('span');
mini.id = 'w4d-mini';
mini.innerHTML =
'<span id="w4d-tape" aria-hidden="true" title="머뭄이 만든 노래">' + ICON.cassette + '</span>' +
'<span id="w4d-now" aria-live="polite"></span>' +
'<button type="button" id="w4d-play" title="머뭄의 노래 재생">' + ICON.play + '</button>' +
'<button type="button" id="w4d-toggle" title="노래 목록" aria-expanded="false">' + ICON.list + '</button>';
/* ── 목록 패널 (body 직속) ──────────────────────────────────────── */
var panel = document.createElement('div');
panel.id = 'w4d-panel';
panel.hidden = true;
panel.innerHTML =
'<div class="w4d-head">' +
'<span class="w4d-head-t">머뭄이 만든 노래</span>' +
'<span class="w4d-head-n">' + songs.length + '곡</span>' +
'<button type="button" id="w4d-close" title="닫기" aria-label="닫기">' + ICON.close + '</button></div>' +
'<ol></ol>' +
'<div id="w4d-lyrics-box" hidden></div>';
document.body.appendChild(panel);
var listOl = panel.querySelector('ol');
var $ = function (id) { return document.getElementById(id); };
var audio = new Audio();
audio.preload = 'none'; // 손님이 누를 때까지 6MB 를 받지 않는다
audio.addEventListener('timeupdate', paintRow);
audio.addEventListener('durationchange', paintRow);
audio.addEventListener('ended', function () { load(nextIndex(), true); });
audio.addEventListener('error', function () { playing = false; render(); });
function clock(sec) {
if (typeof sec !== 'number' || !isFinite(sec)) return '';
var m = Math.floor(sec / 60);
var s = Math.floor(sec % 60);
return m + ':' + (s < 10 ? '0' : '') + s;
}
function meta(song) {
return [song.artist, song.year].filter(Boolean).join(' · ');
}
/* 목록 한 줄 = 판 하나 + 제목/가수 + 시간.
★ 줄 높이를 48px 아래로 내리지 않는다 — 손가락으로 누르는 자리다(모바일). */
function buildList() {
listOl.innerHTML = '';
songs.forEach(function (song, index) {
var li = document.createElement('li');
var btn = document.createElement('button');
btn.type = 'button';
btn.className = 'w4d-item';
btn.innerHTML =
'<span class="w4d-mark">' +
'<span class="w4d-disc-sm"></span>' +
'<span class="w4d-eq" aria-hidden="true"><i></i><i></i><i></i></span>' +
'</span>' +
'<span class="w4d-info"><span class="w4d-t"></span><span class="w4d-sub"></span></span>' +
'<span class="w4d-time"></span>';
btn.querySelector('.w4d-disc-sm').style.setProperty('--w4d-lbl', song.labelColor || '#bf2f1b');
btn.querySelector('.w4d-t').textContent = song.title;
btn.querySelector('.w4d-sub').textContent = song.artist || '';
btn.addEventListener('click', function () { load(index, true); });
li.appendChild(btn);
listOl.appendChild(li);
});
}
/* 시간은 지금 재생 중인 줄에만 적는다 — 헤더에는 곡명도 시간도 두지 않기로 했다.
★ 나머지 줄은 비워 둔다. 예전에는 가수 이름을 넣었는데 다섯 곡이 전부 '스테이,머뭄' 이라
같은 글자가 다섯 줄 반복됐다(가수는 줄 안에 이미 있다). */
function paintRow() {
var rows = listOl.querySelectorAll('.w4d-item');
for (var i = 0; i < rows.length; i += 1) {
var slot = rows[i].querySelector('.w4d-time');
if (i !== cur || !audio.src) { slot.textContent = ''; continue; }
slot.textContent = playing || audio.currentTime > 0
? clock(audio.currentTime) + (audio.duration ? ' / ' + clock(audio.duration) : '')
: '';
}
}
function render() {
var song = songs[cur] || {};
/* 헤더에 **지금 나오는 곡명**을 적는다 (2026-09-11 사장님: "현재 곡명이 나오는 게 맞지 않나").
소리가 나는데 무슨 곡인지 화면 어디에도 없으면 손님이 목록을 열어 봐야 안다. */
$('w4d-now').textContent = song.title || '';
$('w4d-play').innerHTML = playing ? ICON.pause : ICON.play;
$('w4d-play').dataset.playing = playing ? '1' : '0';
mini.dataset.playing = playing ? '1' : '0';
$('w4d-play').title = (playing ? '멈춤 · ' : '재생 · ') + (song.title || '');
var rows = listOl.querySelectorAll('.w4d-item');
for (var i = 0; i < rows.length; i += 1) {
rows[i].setAttribute('aria-current', i === cur ? 'true' : 'false');
rows[i].dataset.playing = i === cur && playing ? '1' : '0';
}
var box = $('w4d-lyrics-box');
if (song.lyrics) { box.textContent = song.lyrics; box.hidden = false; } else { box.hidden = true; }
paintRow();
}
function stop() {
playing = false;
audio.pause(); // 자리는 남긴다 — 다시 누르면 이어 듣는다
}
function load(index, alsoPlay) {
cur = (index + songs.length) % songs.length;
stop();
audio.removeAttribute('src');
render();
if (alsoPlay) play();
}
function play() {
var song = songs[cur] || {};
if (!song.audioUrl) return;
if (audio.getAttribute('src') !== song.audioUrl) audio.src = song.audioUrl;
var started = audio.play();
// 자동재생 차단·파일 오류는 예외가 아니라 거절된 약속으로 온다.
if (started && started.catch) started.catch(function () { playing = false; render(); });
playing = true;
render();
}
/* 패널은 fixed 라 헤더 밑에 손으로 붙인다 — 헤더가 sticky 라 스크롤해도 자리가 같다. */
/* 패널 자리. 좁은 화면에서는 좌우를 화면에 맞춰 펼친다 — 320px 고정이면 390px 화면에서
한쪽이 잘리거나 글자가 두 줄로 접힌다(2026-09-11 사장님: "모바일 대응이 안 되어 있음"). */
function placePanel() {
var header = document.querySelector('#root header');
var box = header ? header.getBoundingClientRect() : null;
panel.style.top = (box ? box.bottom + 6 : 66) + 'px';
if (window.innerWidth <= 640) {
panel.style.left = '8px';
panel.style.right = '8px';
} else {
panel.style.left = 'auto';
var anchor = mini.getBoundingClientRect();
panel.style.right = Math.max(12, window.innerWidth - anchor.right) + 'px';
}
}
function togglePanel(force) {
var open = typeof force === 'boolean' ? force : panel.hidden;
panel.hidden = !open;
$('w4d-toggle').setAttribute('aria-expanded', String(open));
if (open) placePanel();
}
mini.addEventListener('click', function (event) {
var btn = event.target.closest('button');
if (!btn) return;
if (btn.id === 'w4d-play') { if (playing) { stop(); render(); } else { play(); } }
if (btn.id === 'w4d-toggle') { togglePanel(); }
});
panel.querySelector('#w4d-close').addEventListener('click', function () { togglePanel(false); });
window.addEventListener('resize', function () { if (!panel.hidden) placePanel(); });
window.addEventListener('scroll', function () { if (!panel.hidden) placePanel(); }, {passive: true});
document.addEventListener('click', function (event) {
if (panel.hidden) return;
if (panel.contains(event.target) || mini.contains(event.target)) return;
togglePanel(false);
});
/* 가요 다방 섹션의 판을 누르면 그 곡이 얹힌다. 섹션에는 옛 가요도 서 있어 순번이 어긋나므로
제목으로 찾는다. 우리 목록에 없는 곡(옛 가요)이면 섹션이 알아서 한다. */
document.addEventListener('click', function (event) {
var btn = event.target && event.target.closest ? event.target.closest('#songs button') : null;
if (!btn) return;
var label = (btn.textContent || '').trim();
var index = -1;
songs.forEach(function (song, i) { if (index < 0 && song.title === label) index = i; });
if (index >= 0) load(index, false);
});
/* ★ 헤더에 꽂고, 지워지면 다시 꽂는다.
React 가 하이드레이션에 실패하면 #root 를 통째로 다시 그린다 — 그때 단추가 사라진다.
한 번 꽂고 끝내면 그 순간부터 조용히 없어진다(오류도 안 난다). */
function mount() {
if (mini.isConnected) return true;
var header = document.querySelector('#root header');
if (!header) return false;
// 헤더 오른쪽 묶음(전화·메뉴 단추가 있는 자리) 맨 앞에 꽂는다.
var slot = header.querySelector('.shell > div:last-child') ||
header.querySelector('div:last-child');
if (!slot) return false;
slot.insertBefore(mini, slot.firstChild);
return true;
}
/* ★ 자동재생은 두지 않는다 (2026-09-11 사장님: "그럼 자동 빼").
크롬·사파리는 소리 나는 재생을 사용자 조작 없이 허용하지 않는다. 그래서 "막히면
손님이 처음 만지는 순간 켠다" 를 넣어 봤는데, 그 리스너가 window 캡처라
**재생 단추의 클릭을 먼저 먹었다** — 첫 클릭에 켜졌다가 단추 핸들러가 다시 꺼서
두 번 눌러야 켜졌다. 자동재생을 빼면 그 충돌 자체가 없어진다.
시작 곡이 무작위인 것은 그대로다 — 손님이 재생을 누르면 그 곡부터 순서대로 돈다. */
buildList();
mount();
render();
if (window.MutationObserver) {
new MutationObserver(function () {
if (!mini.isConnected) { mount(); render(); }
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
console.log('[w4d] 미니 플레이어 — 머뭄이 만든 곡 ' + songs.length + '곡');
}
/* ══ ③ 링크 걸기 ═══════════════════════════════════════════════════════════
* 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서
* 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다.
* 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 DOM 을 덧칠한다.
*
* ★ 네이버 지도 검색 주소를 쓴다. 좌표로 핀을 찍는 주소는 앱·웹에서 형태가 달라 한쪽이 깨진다.
* ★ React 가 다시 그리면 사라지므로 감시해서 다시 건다.
*/
function sectionItems(id) {
var sections = (P.theme && P.theme.sections) || [];
for (var i = 0; i < sections.length; i += 1) {
if (sections[i].id !== id) continue;
try {
var env = typeof sections[i].data === 'string' ? JSON.parse(sections[i].data) : sections[i].data;
return (env && env.items) || [];
} catch (err) {
return [];
}
}
return [];
}
function mapUrl(query) {
return 'https://map.naver.com/p/search/' + encodeURIComponent(query);
}
function linkify() {
credits();
// 일정: "10:20–11:50 · 지도 검색 군산근대역사박물관"
var rows = document.querySelectorAll('#itinerary li p');
Array.prototype.forEach.call(rows, function (row) {
if (row.querySelector('a')) return;
var text = row.textContent || '';
var mark = text.indexOf('지도 검색 ');
if (mark < 0) return;
var query = text.slice(mark + 6).trim();
if (!query) return;
var head = document.createTextNode(text.slice(0, mark));
var link = document.createElement('a');
link.href = mapUrl(query);
link.target = '_blank';
link.rel = 'noopener noreferrer nofollow';
link.className = 'w4d-link';
link.textContent = '지도에서 열기 ↗';
row.textContent = '';
row.appendChild(head);
row.appendChild(link);
});
/* 엽서: **payload 의 place** 로 링크를 만든다.
화면의 소인 글자를 긁었더니 "군산 군산" 같은 주소가 나왔다 — 소인은 지명이 아니라
도장 문구다(예: "군산 內港"). 카드 순서와 payload 항목 순서가 같으므로 자리로 짝짓는다. */
var cards = document.querySelectorAll('#postcard article');
var postcards = sectionItems('postcard');
Array.prototype.forEach.call(cards, function (card, index) {
if (card.querySelector('a.w4d-link')) return;
var item = postcards[index];
var place = item && (item.place || item.postmark);
if (!place) return;
var tags = Array.prototype.filter.call(card.querySelectorAll('p'), function (el) {
return (el.textContent || '').trim().charAt(0) === '#';
})[0];
if (!tags) return;
var link = document.createElement('a');
link.href = mapUrl(place.indexOf('군산') >= 0 ? place : '군산 ' + place);
link.target = '_blank';
link.rel = 'noopener noreferrer nofollow';
link.className = 'w4d-link';
link.textContent = ' 지도에서 열기 ↗';
tags.appendChild(link);
});
}
/* 인물 사진의 저작자 표시.
★ 이 목업이 물고 있는 렌더러 번들은 `imageCredit` 을 그리지 않는 옛 판이다(고쳐도 다른
사이트까지 다시 구워야 해서 손대지 않는다). 그런데 사진은 CC BY·BY-SA·공공누리라
**저작자 표시가 라이선스 조건**이다 — 안 나오면 침해다. 그래서 여기서 붙인다. */
function credits() {
var cards = document.querySelectorAll('#people article');
var items = sectionItems('people');
Array.prototype.forEach.call(cards, function (card, index) {
if (card.querySelector('.w4d-credit')) return;
var item = items[index];
if (!item || !item.imageCredit || !card.querySelector('img')) return;
var line = card.querySelector('p:last-of-type') || card;
var span = document.createElement('p');
span.className = 'w4d-credit';
span.textContent = item.imageCredit;
line.parentElement.appendChild(span);
});
}
function watchLinks() {
linkify();
if (!window.MutationObserver) return;
var timer = null;
new MutationObserver(function () {
window.clearTimeout(timer);
timer = window.setTimeout(linkify, 300);
}).observe(document.getElementById('root') || document.body, {childList: true, subtree: true});
}
/* ══ ④ 카로셀 — 손님이 만지는 동안에는 자동으로 넘기지 않는다 ══════════════════
*
* ★ 이 페이지의 카로셀은 **구현이 둘**이다.
* ① embla-carousel(라이브러리) — 일정·축제·주변 안내. `.slider-viewport` 를 쓰고
* 자동 넘김은 `embla.scrollNext()` 로 움직인다.
* ② 손으로 만든 가로 스크롤 상자 — 객실·영상·갤러리. `box.scrollBy()` 로 움직인다.
* 정책만 `site/src/lib/ui/use-rail-autoplay.ts` 한 벌을 같이 쓴다.
* 처음에는 ②만 막았다(`scrollBy` 를 삼키는 방식) — 그래서 **일정 카로셀만 계속 넘어갔다**
* (2026-09-11 사장님: "스케줄 보려고 밑에 스크롤 했는데 그다음에 바로 자동 넘어가").
*
* ★ 그래서 훅이 **이미 가진 정지 장치**를 쓴다.
* 훅은 레일 위 `pointerdown` 에 정지 카운터를 올리고 window `pointerup` 에 내린다.
* 구현이 둘이어도 이 카운터는 하나다 — 여기에 손을 얹으면 embla 든 스크롤 상자든 같이 멈춘다.
* (`button: 2` 로 쏜다. embla 는 주 버튼이 아닌 pointerdown 을 드래그로 치지 않아
* 손님의 진짜 드래그를 방해하지 않는다.)
*
* ★ 두 가지로 나눠 대응한다.
* · **쿨타임** — 페이지를 세로로 스크롤하는 것도 "읽는 중" 이다. 무엇이든 만지면
* 7초 동안 모든 레일이 멈춘다. 손을 떼고 7초가 지나야 다시 돈다.
* · **영구 정지** — 레일을 직접 민 경우. 자동 넘김이 하려던 일("옆에 더 있다")은
* 그 손님에게 이미 전달됐다. 그 레일은 다시 돌지 않는다.
*/
var COOLDOWN_MS = 7000;
function tameRails() {
var quietUntil = 0;
var holding = false;
var forever = []; // 손님이 직접 민 레일
var rails = [];
/*
* ★ 손이 화면에 닿아 있는 동안은 무조건 멈춘다 (2026-09-11, 사장님: "유저가 터치로 잡고 있는데 자동으로 넘어가는 게 문제")
* 쿨타임(7초)만으로는 못 막는다. 손가락을 대고 가만히 읽고 있으면 touchmove 도 scroll 도
* 안 오니 쿨타임이 그냥 끝난다. 게다가 브라우저는 터치가 페이지 스크롤로 넘어가는 순간
* **손가락이 아직 닿아 있는데 `pointercancel` 을 쏜다** — 훅의 정지 카운터가 거기서 풀린다.
* 실측: touchstart 0.0s → pointercancel 7.5s → 8.4s 에 손가락 밑에서 카드가 넘어갔다.
* 그래서 pointer 가 아니라 **touches 개수**로 센다. 손이 떨어져야 0 이 된다.
*/
var fingers = 0;
var mouseDown = false;
function collect() {
rails = Array.prototype.slice.call(document.querySelectorAll('#root .slider-viewport'));
Array.prototype.forEach.call(document.querySelectorAll('#root *'), function (el) {
if (rails.indexOf(el) >= 0) return;
var flow = window.getComputedStyle(el).overflowX;
if ((flow === 'auto' || flow === 'scroll') && el.scrollWidth - el.clientWidth > 40) {
rails.push(el);
}
});
}
function press(rail) {
rail.dispatchEvent(new PointerEvent('pointerdown', {bubbles: true, button: 2, buttons: 2}));
}
function holdAll() {
collect();
rails.forEach(press);
holding = true;
}
function releaseAll() {
// 해제는 window 하나로 전부에게 간다(훅이 window 에서 받는다). 영구 정지분은 곧바로 다시 잡는다.
window.dispatchEvent(new PointerEvent('pointerup', {bubbles: true, button: 2}));
holding = false;
forever.forEach(press);
}
function railOf(node) {
while (node && node.nodeType === 1 && node !== document.body) {
if (rails.indexOf(node) >= 0) return node;
node = node.parentElement;
}
return null;
}
/*
* ★ "만졌다" 와 "밀었다" 를 가른다 (2026-09-11, 사장님: "한번 스크롤한뒤에는 auto play 아예 안되는데")
* 전에는 레일 위에서 일어난 pointerdown·touchstart·wheel 이면 **곧바로** 영구 정지로 보냈다.
* 그런데 페이지를 세로로 내릴 때 손가락(과 마우스 커서)은 거의 항상 레일 위를 지난다 —
* 일정 카로셀은 화면 가운데를 가득 채우니까. 그래서 **한 번 스크롤하면 그 레일이 영영 멈췄다.**
* 실측: 세로 휠 한 번 → 20초 동안 0px.
* 이제 **레일이 실제로 가로로 움직였을 때만** 영구 정지다. 제스처 시작 때 위치를 적어 두고
* 손을 뗀 뒤(관성 350ms) 다시 재서, 달라졌으면 손님이 민 것이다.
*/
function railPos(rail) {
var slid = 0;
var first = rail.firstElementChild;
if (first) {
var tf = window.getComputedStyle(first).transform;
if (tf && tf !== 'none') {
try { slid = new DOMMatrix(tf).m41; } catch (err) { slid = 0; }
}
}
return rail.scrollLeft + slid;
}
var pending = null; // {rail, pos} — 지금 진행 중인 제스처
function markIfMoved() {
if (!pending) return;
var rail = pending.rail;
var was = pending.pos;
pending = null;
window.setTimeout(function () {
// 8px 여유 — 탭 한 번에도 embla 가 스냅을 다시 잡느라 소수점이 흔들린다.
if (Math.abs(railPos(rail) - was) < 8) return;
if (forever.indexOf(rail) < 0) { forever.push(rail); press(rail); }
}, 350);
}
['pointerdown', 'touchstart', 'touchmove', 'wheel', 'keydown', 'scroll'].forEach(function (type) {
window.addEventListener(type, function (event) {
if (!event.isTrusted) return;
quietUntil = Date.now() + COOLDOWN_MS;
if (event.touches) fingers = event.touches.length;
if (type === 'pointerdown' && event.pointerType === 'mouse') mouseDown = true;
if (type === 'scroll' || type === 'keydown' || type === 'touchmove') return;
collect();
var rail = railOf(event.target);
if (!rail) return;
if (type === 'wheel') {
// 가로 휠·트랙패드 가로 스와이프만 "민 것"이다. 세로는 페이지를 내리는 중이다.
if (Math.abs(event.deltaX) > Math.abs(event.deltaY) && Math.abs(event.deltaX) > 2) {
pending = {rail: rail, pos: railPos(rail)};
markIfMoved();
}
return;
}
pending = {rail: rail, pos: railPos(rail)};
}, true);
});
['pointerup', 'pointercancel', 'touchend', 'touchcancel'].forEach(function (type) {
window.addEventListener(type, function (event) {
if (!event.isTrusted) return;
quietUntil = Date.now() + COOLDOWN_MS;
// 손가락 수는 touch 계열에서만 갱신한다 — pointercancel 은 손이 닿아 있어도 온다.
if (event.touches) fingers = event.touches.length;
if (event.pointerType === 'mouse') mouseDown = false;
if (fingers === 0 && !mouseDown) markIfMoved();
}, true);
});
// 250ms 마다 조용해야 할 때인지 본다. 타이머를 직접 만지지 않고 훅의 정지 장치만 건드린다.
window.setInterval(function () {
var quiet = fingers > 0 || mouseDown || Date.now() < quietUntil;
if (quiet && !holding) holdAll();
else if (!quiet && holding) releaseAll();
}, 250);
}
/* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 <img> 가 151장이라
(일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */
console.log('[w4d] 주입분 로드');
try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); }
try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); }
catch (err) { console.warn('[w4d] 카로셀 실패', err); }
window.setTimeout(function () {
try {
watchLinks();
console.log('[w4d] 링크 걸기 — 일정 · 엽서');
} catch (err) { console.warn('[w4d] 링크 실패', err); }
try {
startCatchphrase();
console.log('[w4d] 캐치프레이즈 ' + catchphrases().length + '개 · 7초마다 전환');
} catch (err) { console.warn('[w4d] 캐치프레이즈 실패', err); }
}, 1200);
})();