이 목업은 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>
709 lines
35 KiB
JavaScript
709 lines
35 KiB
JavaScript
/* /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);
|
||
})();
|