/* /s/stay 목업 주입분 — ① 캐치프레이즈 순환 ② 헤더 안 미니 플레이어. * * ★ 플레이어는 자리를 세 번 옮겼다. 지금은 **사이트 헤더 안**이다. * ① 하단 바 → 모바일에서 발행본의 탭바(fixed bottom:0 · 61px · z-50 — 위치·전화·예약)를 덮었다. * ② 상단 바 → 헤더와 두 줄이 되어 화면 위 120px 이 크롬이 됐다("조금 이상한데"). * ③ 헤더 안 아이콘 — 줄이 늘지 않는다. 원판·곡명 없이 재생·다음·목록 세 단추뿐이고, * 곡 정보는 목록을 열 때만 보인다. * * ★ 헤더는 React 가 그린다. 그래서 두 가지를 지킨다. * · 단추는 헤더 안에 꽂지만, 없어졌는지 감시해서 다시 꽂는다(하이드레이션 재렌더가 지운다). * · 목록 패널은 #root **밖**(body 직속)에 둔다 — 안에 두면 재렌더에 통째로 날아간다. * * ★ 재생은 곡의 audioUrl(사장님이 직접 만든 음원)만 쓴다. 파일은 `/s//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 를 **통째로 다시 그린다** — 그때 히어로의

가 새 노드로 바뀐다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ 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: '' + '' + '' + '' + '', note: '', play: '', pause: '', /* ★ 햄버거(줄 세 개)를 쓰지 않는다 (2026-09-11 사장님: "햄버거 메뉴 버튼이랑 음악 메뉴 아이콘이랑 같아서 헷갈림"). 바로 옆에 사이트 메뉴 단추가 같은 모양으로 서 있었다. 줄 끝에 음표를 달아 **재생 목록**으로 읽히게 한다. */ list: '' + '', close: '', }; /* 플레이어가 트는 것은 **숙소가 만든 곡**이다(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 = '' + '' + '' + ''; /* ── 목록 패널 (body 직속) ──────────────────────────────────────── */ var panel = document.createElement('div'); panel.id = 'w4d-panel'; panel.hidden = true; panel.innerHTML = '

' + '머뭄이 만든 노래' + '' + songs.length + '곡' + '
' + '
    ' + ''; 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 = '' + '' + '' + '' + '' + ''; 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 타일 가 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); })();