/* /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; /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ /* ★ 대표 문구는 고정이고, 순환은 그 **아래 줄**에서 돈다 (2026-09-11 사장님 지시: "메인 모토는 고정으로 하고, 그 아래 계속 변동되는 모토가 돌아가는 방향"). 예전에는 대표 문구 자리를 통째로 갈아 끼워 "히로쓰 가옥 담 너머…" 가 5초 뒤 사라졌다 — 숙소 이름 다음으로 남아야 할 한 줄이 첫 화면에서 지워지는 셈이었다. */ var base = ((P.narrative && P.narrative.tagline) || '').trim(); var node = null; /* ★ 노드를 들고 있지 않고 매번 확인한다. 이 목업은 payload 를 손으로 고친 것이라 구워진 마크업과 어긋나고, React 는 하이드레이션에 실패하면 #root 를 **통째로 다시 그린다** — 대표 문구

가 새 노드로 바뀌고 그 아래 붙여 둔 줄은 같이 사라진다. 한 번 잡아 둔 참조로 계속 쓰면 그 순간부터 아무 일도 안 일어나면서 오류도 안 난다. */ function findTagline() { var candidates = document.querySelectorAll('#root p'); for (var i = 0; i < candidates.length; i += 1) { if (candidates[i].textContent.trim() === base) return candidates[i]; } return null; } function ensureNode() { if (node && node.isConnected) return node; var tagline = findTagline(); if (!tagline) return null; node = document.createElement('p'); node.id = 'w4d-sub'; node.className = 'w4d-sub measure'; tagline.insertAdjacentElement('afterend', node); reserve(); return node; } if (!base || !findTagline()) return; /* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면 숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */ function reserve() { var width = node.getBoundingClientRect().width; if (!width) return; var probe = node.cloneNode(false); probe.removeAttribute('id'); probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px'; node.parentNode.appendChild(probe); var max = 0; items.forEach(function (it) { probe.textContent = it.text; max = Math.max(max, probe.getBoundingClientRect().height); }); probe.remove(); node.style.minHeight = Math.ceil(max) + 'px'; } var resizeTimer = null; window.addEventListener('resize', function () { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200); }); 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); } function ensureNode() { if (node && node.isConnected) return node; var tagline = findTagline(); if (!tagline) return null; node = document.createElement('p'); node.id = 'w4d-sub'; node.className = 'w4d-sub measure'; tagline.insertAdjacentElement('afterend', node); reserve(); return node; } if (!base || !findTagline()) return; /* 줄 높이를 **가장 긴 문구**에 맞춰 박는다. 히어로는 아래 정렬이라 이 줄이 한 줄↔두 줄을 오가면 숙소 이름과 대표 문구가 통째로 위아래로 튄다. 지금 폭에서 문구를 다 재 보고 가장 높은 값을 쓴다. */ function reserve() { var width = node.getBoundingClientRect().width; if (!width) return; var probe = node.cloneNode(false); probe.removeAttribute('id'); probe.style.cssText = 'position:absolute;visibility:hidden;pointer-events:none;width:' + width + 'px'; node.parentNode.appendChild(probe); var max = 0; items.forEach(function (it) { probe.textContent = it.text; max = Math.max(max, probe.getBoundingClientRect().height); }); probe.remove(); node.style.minHeight = Math.ceil(max) + 'px'; } var resizeTimer = null; window.addEventListener('resize', function () { window.clearTimeout(resizeTimer); resizeTimer = window.setTimeout(function () { if (node && node.isConnected) reserve(); }, 200); }); var cycle = []; function buildCycle() { return items.filter(function (item) { return item.kind === "general"; }); } /* 문장 하나를 단어 칸으로 조립한다. 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) { var fresh = !(node && node.isConnected); if (!ensureNode()) return; 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 (!ensureNode()) return; paint(text); }, fresh ? 0 : 300); } function tick() { 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); // 대표 문구가 먼저 읽히고 나서 아래 줄이 선다 — 둘이 한꺼번에 뜨면 어느 쪽이 숙소의 한 줄인지 안 읽힌다. window.setTimeout(function () { tick(); window.setInterval(function () { if (!document.hidden && !REDUCED) tick(); }, 6000); }, 1200); } /* ══ ② 헤더 안 미니 플레이어 ═══════════════════════════════════════════════ * ★ 단추는 둘뿐이다 — 재생/멈춤과 목록 (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(); return started; // 자동재생이 막혔는지 부르는 쪽이 알아야 한다 } /* 패널은 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; disarmAutoplay(); // 손님이 직접 잡았다 — 첫 손길 대기를 푼다 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-14 대표 지시. 2026-09-11 에는 "자동 빼" 였다). 브라우저는 소리 나는 재생을 사용자 조작 없이 막는다(정책이고, 거절된 약속으로 온다). 그래서 두 단계다 — ① 일단 걸어 보고 ② 막히면 **손님이 처음 만지는 순간** 켠다. ★ 첫 손길 리스너는 **플레이어 안에서 난 이벤트를 흘려보낸다.** 예전 판은 window 캡처라 재생 단추의 클릭을 먼저 먹었고, 단추 핸들러가 다시 꺼서 두 번 눌러야 켜졌다(2026-09-11). 지금은 버블 단계에 걸고 `#w4d-mini`·`#w4d-panel` 안이면 그냥 돌아선다 — 그 클릭은 단추 핸들러의 몫이고, 그 핸들러가 대기를 푼다. ★ 시작 곡이 무작위인 것은 그대로다. */ /* ★ 두 벌로 나눈다. 브라우저가 소리를 풀어 주는 것은 **활성화를 주는 입력**뿐이다 (mousedown·pointerdown/up·touchend·keydown — HTML 표준 "activation triggering input"). mousemove·wheel·scroll 은 아무리 받아도 활성화가 안 붙는다. 실측 2026-09-14, 킹서버 · evaluate 를 안 쓴 측정: 가만히 / mousemove / wheel → `navigator.userActivation.isActive` 계속 false 클릭 → true ★ 그런데도 약한 입력을 버리지 않는다 — 크롬이 이 출처에 이미 자동재생을 허락한 상태(Media Engagement)면 그때는 아무 입력에서나 켜지고, 그게 제일 빠르다. ★ 다만 **약한 입력에만 스로틀을 건다.** 예전 판은 한 벌로 묶어 1초 스로틀을 걸었는데, 클릭 직전의 mousemove 가 그 1초를 먹어서 **정작 활성화를 주는 mousedown 이 걸러졌다** — 눌러도 안 켜졌다(실측 2026-09-14: 클릭 뒤 activation=true 인데 play=0). */ var STRONG = ['pointerdown', 'mousedown', 'pointerup', 'mouseup', 'click', 'keydown', 'touchstart', 'touchend']; var WEAK = ['wheel', 'scroll', 'mousemove', 'pointermove', 'touchmove']; var GESTURES = STRONG.concat(WEAK); var lastWeak = 0; function onFirstTouch(event) { /* ★ **코드가 쏜 이벤트는 손길이 아니다.** 대기는 한 번뿐이라, 가짜 이벤트가 먼저 먹으면 자동재생은 영영 안 켜진다 — 브라우저는 신뢰된 조작에만 소리를 허락하므로 그때 부른 play() 는 반드시 거절된다. 실측(2026-09-14): 목업 body 에 `class="site"` 를 붙여 레일 CSS 가 되살아나자 `tameRails()` 의 `press()` 가 레일 13개에 pointerdown 을 쏘았고(자동 넘김을 세우는 정상 동작이다), 로드 1.05초에 그게 전부 이 자리로 들어와 대기를 태웠다. 그 뒤로는 손님이 아무 데나 눌러도 음악이 안 켜졌다. */ if (!event || !event.isTrusted) return; var node = event.target; if (node && node.closest && node.closest('#w4d-mini, #w4d-panel')) return; /* ★ **켜진 뒤에** 대기를 푼다. 예전에는 먼저 풀고 play() 를 불렀는데, wheel·scroll 은 브라우저가 "조작" 으로 쳐 주지 않아 그 자리의 play() 는 거절된다 — 손님이 그냥 스크롤만 한 번 해도 대기가 타 버리고, 그 뒤로는 어디를 눌러도 안 켜졌다. 지금은 거절되면 대기를 그대로 두고 다음 손길을 기다린다. */ if (WEAK.indexOf(event.type) >= 0) { // 약한 입력은 초당 수십 번 온다. 거절될 것을 그 횟수만큼 부르면 화면이 깜빡인다. var now = Date.now(); if (now - lastWeak < 1000) return; lastWeak = now; } var started = play(); if (started && started.then) started.then(disarmAutoplay, function () { /* 다음 손길에 다시 */ }); else disarmAutoplay(); } function disarmAutoplay() { GESTURES.forEach(function (name) { window.removeEventListener(name, onFirstTouch); }); } function armAutoplay() { GESTURES.forEach(function (name) { window.addEventListener(name, onFirstTouch, {passive: true}); }); } function autoplay() { var started = play(); if (started && started.then) { started.then(disarmAutoplay, armAutoplay); } else { armAutoplay(); // 약속을 안 주는 브라우저 — 켜졌는지 알 수 없으니 대기도 같이 건다 } /* 탭을 다시 열 때 한 번 더 걸어 본다. 다른 탭에서 열어 둔 페이지는 브라우저가 소리를 아예 막아 두는데, 앞으로 나오는 그 순간은 조작으로 쳐 주는 경우가 있다. 이미 켜져 있으면 아무 일도 안 한다. */ document.addEventListener('visibilitychange', function () { if (document.visibilityState !== 'visible' || playing) return; var again = play(); if (again && again.then) again.then(disarmAutoplay, function () { /* 계속 대기 */ }); }); } buildList(); mount(); render(); autoplay(); 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 + '곡'); } /* ══ ③ 군산 읽기 ═══════════════════════════════════════════════════════════ * 받은 자료(군산 365 이야기 DB)의 주제를 갈래로 묶어 보여준다. * * ★ 왜 주입인가 — 렌더러에 이 섹션이 없다. 렌더러를 고치면 번들 해시가 바뀌어 전 사이트 * 재굽기 + 전체 재업로드가 따라온다(README 2.1). 목업에만 필요한 것은 여기서 덮는다. * ★ **독립 섹션이 아니라 '군산 이야기'(`#story`) 탭 하나다** (2026-09-14 대표: "굳이 * 나눌 필요가 없는것 같고" — 맞는 말이다. 가요·인물·연표·엽서·일력이 이미 그 탭 묶음에 * 있는데 이것만 FAQ 앞에 따로 서 있을 이유가 없다). `#story` 의 tablist 에 버튼을 하나 * 더 꽂고, 그 옆에 패널을 하나 더 붙인다 — 나머지 다섯 개와 같은 자리, 같은 모양이다. * ★ **탭은 React 상태로 도는데 이 탭만 아니다.** React 의 `active` 는 그대로 두고 * (React 가 모르는 탭이라 못 건드린다), 내 탭을 누르면 **다섯 개를 전부 손으로 숨기고** * 내 것만 보인다. 반대로 진짜 탭(다섯 개 중 하나)을 누르면 **내 패널만 손으로 숨긴다** * — React 는 자기 다섯 개를 알아서 정리하니 그건 안 건드린다. * ★ 소개 문장("이 도시를 다섯 갈래로 봅니다")의 숫자도 여섯으로 고쳐 쓴다 — 탭 개수를 * 문장에 안 박는 게 이 컴포넌트의 원칙인데(`COUNT_WORD`), 그 문장이 세는 `tabs.length` * 가 React 상태라 내 탭을 모른다. 안 고치면 "다섯 갈래"라 해 놓고 탭이 여섯 개인 * 꼴이 난다 — `daily` 가 한동안 빈칸이던 것과 같은 종류의 조용한 어긋남이다. * ★ 처음엔 31꼭지를 한 화면에 전부 폈다. 그런데 **너무 길어 스크롤 압박**이 됐다 * (2026-09-14 대표: "내용들이 여러개 안해도되고 3-4개 정도 그룹묶어서 랜덤하게 보여주게"). * → 갈래마다 3~4개씩 뽑아 갈래 순서대로 잇는 방식을 거쳐, 지금은 **갈래를 가리지 * 않고 전체 34가지 중 5~6개만 무작위로** 뽑는다(2026-09-14 대표: "34개중 15~19개인데 * 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 나머지는 * 다음에 들어왔을 때 나온다 — 접은 게 아니라 돌려 보여주는 것이라 `
    ` 를 쓰지 * 않는다(문서에 다 남겨야 하는 FAQ·일력과는 다른 경우다). payload 의 `subtitle` 도 * 전체 개수를 내걸지 않게 고쳤다(`build_reading.py`) — 화면과 문구가 어긋나면 안 된다. * → **방문할 때마다 다시 섞는다**(빌드타임에 한 번 고정하지 않는다). 매 페이지 로드가 * 새 셔플이라 손님마다, 새로고침마다 다른 조합을 본다. * ★ React 가 #root 를 다시 그리면 사라진다 — 감시해서 다시 꽂는다(플레이어와 같은 수법). * 단, 무작위 조합은 **첫 마운트 때 한 번만** 뽑는다 — 재마운트마다 다시 뽑으면 하이드레이션 * 재렌더가 잦은 페이지에서 손님이 읽던 카드가 눈앞에서 바뀐다. */ function shuffled(list) { var copy = list.slice(); for (var i = copy.length - 1; i > 0; i -= 1) { var j = Math.floor(Math.random() * (i + 1)); var tmp = copy[i]; copy[i] = copy[j]; copy[j] = tmp; } return copy; } function readingData() { var sections = (P.theme && P.theme.sections) || []; for (var i = 0; i < sections.length; i += 1) { if (sections[i].id !== 'reading') continue; try { return JSON.parse(sections[i].data); } catch (err) { return null; } } return null; } function escapeHtml(text) { return String(text == null ? '' : text) .replace(/&/g, '&').replace(//g, '>') .replace(/"/g, '"'); } /* ★ 카로셀 껍데기는 새로 만들지 않는다 — **이 사이트가 이미 쓰는 것**을 그대로 가져다 쓴다 (2026-09-14 대표: "기존 카로셀 쓰라고, 니가 뭔데 니맘대로 그지같이 구현해"— 맞는 말이다). `site/src/lib/ui/Carousel.tsx` 가 만드는 마크업·클래스를 손으로 그대로 찍어낸다. ★ `.slider-viewport`(overflow-x:auto + scroll-snap) · `.slider-track`(flex) 는 React 컴포넌트 전용이 아니라 **번들에 이미 컴파일된 전역 CSS**다 — 이 페이지의 다른 레일(인물 열전 등)이 지금 쓰고 있는 그 클래스라 새 CSS 를 안 써도 그대로 먹는다. embla 는 못 붙인다(inject.js 는 React 번들 밖의 순정 스크립트라 embla 인스턴스를 가져다 쓸 수 없다) — 그런데 Carousel.tsx 자신도 "**스크립트 없이도 내용이 보여야 한다**" 며 embla 가 붙기 전 기본 상태를 이 스크롤+snap 상자로 정의해 뒀다. 즉 지금 이 카드가 보이는 모양은 실제 레일의 **무-스크립트 상태와 정확히 같다.** ★ 점(dots)·화살표도 Carousel.tsx 의 클래스를 그대로 옮긴다(`h-1.5 rounded-full bg-current opacity-70/20`, `tap border-line rounded-full border` 등) — 전부 Tailwind 유틸리티라 전역에 이미 있다. inject.css 에 카로셀용 CSS를 새로 안 만드는 이유다. */ /* '군산 이야기' 탭 버튼의 on/off 인라인 스타일 — StorySection.tsx 의 값 그대로 (`common.tsx` ITEM_INK · ITEM_INVERSE_INK · ITEM_BORDER). React 가 다른 다섯 탭에 주는 것과 같은 색이라야 여섯 번째가 "끼어든 것"처럼 안 보인다. */ var STORY_TAB_ON = 'background-color:var(--tpl-text, #09090b);' + 'color:var(--tpl-bg, #ffffff);border-color:var(--tpl-text, #09090b)'; var STORY_TAB_OFF = 'border-color:var(--tpl-border, #d6d3d1)'; // lucide 의 chevron-left/right 와 같은 path 다 — NavButton(Carousel.tsx)이 쓰는 그 아이콘. // 군산 읽기·가요 다방 화살표가 같이 쓰므로 함수 밖(모듈 스코프)에 한 벌만 둔다. var CHEVRON_L = ''; var CHEVRON_R = ''; var NAV_BTN = 'tap border-line flex items-center justify-center rounded-full border ' + 'transition-opacity hover:opacity-70 disabled:opacity-25'; function startReading() { var env = readingData(); if (!env || !env.items || !env.items.length) return; /* ★ **포스트 하나씩만** (2026-09-14 대표: "포스트 하나씩만 보여주라고, 한 줄인거 나랑 장난하자는거?"). 3~4개를 한 슬라이드에 욱여넣으면 카드 하나가 좁아져 본문이 한 줄로 접혔다. 지금은 슬라이드 하나 = 꼭지 하나, 전체 폭을 다 쓴다. ★ '~면' 이라고는 안 쓴다(2026-09-14 대표: "문학면? 말이 안 되는거 같네") — 신문 마스트헤드를 뺀 지금은 '면'(신문 지면)이라는 틀이 없다. 그냥 갈래 이름(문학·역사·…)만 카드 위에 이름표로 붙인다. ★ **갈래를 가리지 않고 전체에서 5~6개만** (2026-09-14 대표: "34개중 15~19개인데 왜 34개라고 써놔?" → "그냥 5-6개 정도만 보여주는거 어때?" → "랜덤으로"). 갈래마다 3~4개씩 뽑던 전에는 한 번에 15~19개가 떴는데, payload 의 subtitle 은 "34가지"라고 적혀 있어 화면과 문구가 어긋났다. 지금은 34가지를 갈래 구분 없이 통째로 섞어 5개 또는 6개만 뽑는다. */ var count = Math.random() < 0.5 ? 5 : 6; var picked = shuffled(env.items).slice(0, count).map(function (item) { return {group: item.group, item: item}; }); if (!picked.length) return; var trackId = 'w4d-read-track'; /* ★ 제목을 단다 (2026-09-14 대표: "군산읽기만 타이틀이 없서 통일 시켜"). 탭 버튼 라벨과 겹쳐 보여서 처음엔 뺐는데, 정작 실제 다섯 탭(가요 다방·인물 열전 등)은 전부 안에도

    를 그대로 세워 둔다(`StorySection.tsx`: "탭 이름과 겹쳐 보이지만 …읽는 기계에게는 그 제목이 다섯 덩이를 가르는 유일한 표지다") — 나만 빼서 되레 튀었다. 같은 마크업(`header.mb-8.sm:mb-10 > h2.h2 + p.measure`)으로 맞춘다. */ var html = '
    ' + '

    ' + escapeHtml(env.title || '군산 읽기') + '

    ' + '

    ' + escapeHtml(env.subtitle) + '

    ' + '
    '; /* ↓ 여기부터 Carousel.tsx 의 실제 출력(화살표 header · slider-viewport/track · 점 · 카운터)을 그대로 옮긴 것이다 — 클래스 이름 하나까지 같다(파일 머리 주석 참고). */ html += '
    ' + '' + '' + '
    '; html += '
    ' + '
    ' + '
    '; picked.forEach(function (row, index) { var item = row.item; var source = item.source || {}; // 출처는 항목마다 단다 — 개별 문서 대신 네이버 검색 링크로 통일했다(2026-09-14 // 대표: "확인필요 없애고 링크는 네이버 링크로", `build_reading.py` `naver()`). var credit = source.url ? '' + escapeHtml(source.name) + '' : escapeHtml(source.name || ''); html += '
    ' + '
    ' + '

    ' + escapeHtml(row.group) + (item.year ? ' · ' + escapeHtml(item.year) + '' : '') + '

    ' + '

    ' + escapeHtml(item.title) + '

    ' + '

    ' + escapeHtml(item.body) + '

    ' + '

    출처 · ' + credit + '

    '; }); html += '
    '; html += '
    '; if (picked.length <= 12) { html += '
      '; picked.forEach(function (_row, index) { html += '
    • '; }); html += '
    '; } html += '' + '1 / ' + picked.length + '
    '; var panel = document.createElement('div'); panel.id = 'w4d-reading-panel'; panel.setAttribute('aria-labelledby', 'w4d-reading-heading'); panel.hidden = true; // 처음엔 다른 탭(가요 다방 등)이 보인다 — React 의 기본 active 를 안 뺏는다 // `.shell` 은 이 사이트가 폭·좌우 여백을 맞출 때 쓰는 그 전역 클래스다 — 바로 위 // `#story .shell` 과 같은 너비라 탭이 바뀌어도 좌우 선이 흔들리지 않는다. panel.innerHTML = '
    ' + html + '
    '; var tabBtn = document.createElement('button'); tabBtn.type = 'button'; tabBtn.setAttribute('role', 'tab'); tabBtn.setAttribute('aria-selected', 'false'); tabBtn.setAttribute('aria-controls', 'w4d-reading-panel'); tabBtn.className = 'rounded-full border px-4 py-1.5 text-[length:var(--fs-sm)] font-bold ' + 'transition-opacity hover:opacity-80'; tabBtn.style.cssText = STORY_TAB_OFF; tabBtn.textContent = '군산 읽기'; /* 자리 — '군산 이야기' 탭 묶음 **안**이다. `#story` 다음에 진짜 탭 패널 다섯 개가 형제로 이어지고(각자 안에 #songs·#daily·#people·#chronicle·#postcard 를 하나씩 담는다), 그 마지막 뒤에 내 패널을 하나 더 잇는다 — FAQ 앞이라는 점은 그대로다. */ var KNOWN_TABS = ['songs', 'daily', 'people', 'chronicle', 'postcard']; function mount() { var tablist = document.querySelector('#story [role="tablist"]'); var story = document.getElementById('story'); if (!tablist || !story || !story.parentNode) return false; if (!tabBtn.isConnected) tablist.appendChild(tabBtn); if (!panel.isConnected) { var anchor = story; var sib = story.nextElementSibling; while (sib && KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) { anchor = sib; sib = sib.nextElementSibling; } anchor.parentNode.insertBefore(panel, anchor.nextElementSibling); } // "이 도시를 다섯 갈래로 봅니다" — 진짜 탭 개수(React state)는 내 탭을 모르니 // +1 을 손으로 반영한다. 매 재마운트마다 다시 검사해도 이미 고쳤으면 그대로 둔다. var countWord = {1: '한', 2: '두', 3: '세', 4: '네', 5: '다섯', 6: '여섯', 7: '일곱', 8: '여덟', 9: '아홉', 10: '열'}; var real = tablist.querySelectorAll('[role="tab"]').length - 1; // 방금 내가 하나 더했다 var subtitle = story.querySelector('p.measure'); if (subtitle && real > 0) { var word = countWord[real + 1] || String(real + 1); subtitle.textContent = subtitle.textContent.replace( /^이 도시를 .+ 갈래로 봅니다/, '이 도시를 ' + word + ' 갈래로 봅니다'); } return true; } if (!mount()) return; /* ── 탭 전환 ──────────────────────────────────────────────────────────── * ★ 버그였던 자리 (2026-09-14 대표 제보: "시간의 골목 누르면 사라지기만 함, 탭활성화도"). * `hidden` 은 React 가 `active` 상태로 매번 다시 계산해 주는 prop(`hidden={index!==active}`) * 이라, 내가 여기서 그 값을 직접 true 로 덮으면 React 의 실제 상태와 DOM 이 어긋난다. * 그 상태로 손님이 **같은 탭을 다시 누르면** `setActive(sameIndex)` 는 React 입장에서 * 상태가 안 바뀐 것이라 리렌더를 생략한다(bail-out) — `hidden` 은 내가 true 로 덮은 채 * 영영 안 돌아온다. 버튼 스타일(`aria-selected`·style)도 React 가 매 렌더 계산해 주는 * 값이라 같은 이유로 OFF 에 갇힌다. → **React 가 관리하는 속성은 아예 안 건드린다.** * 대신 내가 전적으로 소유한 `style.display` 로만 가리고(그 프롭은 JSX 에 없어 React 가 * 절대 안 건드린다), 되돌아올 때는 그 오버라이드만 지운다 — `hidden` 은 그동안 한 번도 * 틀린 적이 없으니 지우자마자 바로 맞는 것이 보인다. 버튼도 "방금 누른 그 탭"을 직접 * 켜 준다 — React 리렌더가 생략돼도 항상 맞게. */ function selectMine() { var tablist = document.querySelector('#story [role="tablist"]'); if (tablist) { Array.prototype.forEach.call(tablist.querySelectorAll('[role="tab"]'), function (btn) { if (btn === tabBtn) return; btn.setAttribute('aria-selected', 'false'); btn.style.cssText = STORY_TAB_OFF; }); } var story = document.getElementById('story'); var sib = story && story.nextElementSibling; while (sib && sib !== panel) { if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = 'none'; sib = sib.nextElementSibling; } tabBtn.setAttribute('aria-selected', 'true'); tabBtn.style.cssText = STORY_TAB_ON; panel.hidden = false; } tabBtn.addEventListener('click', selectMine); document.addEventListener('click', function (event) { var real = event.target.closest && event.target.closest('#story [role="tab"]'); if (!real || real === tabBtn) return; panel.hidden = true; tabBtn.setAttribute('aria-selected', 'false'); tabBtn.style.cssText = STORY_TAB_OFF; // 방금 누른 진짜 탭을 직접 켠다 — 같은 탭을 다시 눌러 React 리렌더가 생략돼도 맞다. real.setAttribute('aria-selected', 'true'); real.style.cssText = STORY_TAB_ON; var story = document.getElementById('story'); var sib = story && story.nextElementSibling; while (sib && sib !== panel) { if (KNOWN_TABS.some(function (id) { return sib.querySelector('#' + id); })) sib.style.display = ''; sib = sib.nextElementSibling; } }); if (window.MutationObserver) { /* ★ 무작위 조합은 여기서 다시 뽑지 않는다 — panel 은 이미 innerHTML 이 채워진 노드라, 재마운트는 **같은 노드를 그대로 다시 꽂는 것**뿐이다(플레이어 패널과 같은 수법). React 가 다시 그려도 손님이 보던 조합·지금 켜져 있던 탭은 그대로 남는다. */ new MutationObserver(function () { if (!tabBtn.isConnected || !panel.isConnected) mount(); }).observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); } /* ── 카로셀 넘기기 ────────────────────────────────────────────────────── * ★ **embla-carousel 코어**(파일 머리 주석)로 움직인다 — 이 사이트의 다른 레일과 * 같은 라이브러리, 같은 드래그 물리다. `align:'center'` 라 가운데 한 장이 크게 * 오고 양옆이 살짝 걸친다(2026-09-14 대표 지시). * ★ **스크립트 없이도 봐야 한다**(Carousel.tsx 의 원칙과 같다) — embla 를 못 붙이면 * `.slider-viewport` 는 `overflow-x:auto` 기본 상태 그대로 남아 손가락·트랙패드로는 * 여전히 밀린다. 그때는 점·화살표를 `scrollIntoView` 로 대신 움직인다. */ var viewport = panel.querySelector('.slider-viewport'); var track = document.getElementById(trackId); var slideEls = Array.prototype.slice.call(panel.querySelectorAll('[id^="w4d-read-slide-"]')); var dots = Array.prototype.slice.call(panel.querySelectorAll('.w4d-read-dot')); var counter = panel.querySelector('.w4d-read-counter [data-cur]'); var prevBtn = panel.querySelector('.w4d-read-prev'); var nextBtn = panel.querySelector('.w4d-read-next'); if (!viewport || !track || !slideEls.length) return; function paint(active, canPrev, canNext) { dots.forEach(function (dot, index) { var on = index === active; dot.setAttribute('aria-current', on ? 'true' : 'false'); dot.className = 'w4d-read-dot h-1.5 rounded-full transition-all ' + (on ? 'w-6 bg-current opacity-70' : 'w-1.5 bg-current opacity-20 hover:opacity-45'); }); if (counter) counter.textContent = String(active + 1); if (prevBtn) prevBtn.disabled = !canPrev; if (nextBtn) nextBtn.disabled = !canNext; } var emblaApi = null; if (window.EmblaCarousel) { emblaApi = window.EmblaCarousel(viewport, {align: 'center', loop: false, containScroll: 'trimSnaps'}); viewport.setAttribute('data-slider', 'on'); // CSS 가 여기부터 스크롤 상자를 드래그 영역으로 바꾼다 var sync = function () { paint(emblaApi.selectedScrollSnap(), emblaApi.canScrollPrev(), emblaApi.canScrollNext()); }; emblaApi.on('select', sync); emblaApi.on('reInit', sync); // 드래그 중 텍스트가 잡히면 카드가 안 끌린다 — 커서도 grabbing 으로 바꾼다(Carousel.tsx 와 동일). emblaApi.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); }); emblaApi.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); }); dots.forEach(function (dot) { dot.addEventListener('click', function () { emblaApi.scrollTo(Number(dot.dataset.index)); }); }); if (prevBtn) prevBtn.addEventListener('click', function () { emblaApi.scrollPrev(); }); if (nextBtn) nextBtn.addEventListener('click', function () { emblaApi.scrollNext(); }); sync(); } else { // embla 스크립트가 막혔거나 못 받아 왔을 때 — 가로 스크롤 자체는 CSS 만으로 이미 된다. var goTo = function (index) { slideEls[index].scrollIntoView({behavior: 'smooth', inline: 'center', block: 'nearest'}); }; dots.forEach(function (dot) { dot.addEventListener('click', function () { goTo(Number(dot.dataset.index)); }); }); var findActive = function () { var mid = viewport.scrollLeft + viewport.clientWidth / 2; var active = 0; for (var i = 0; i < slideEls.length; i += 1) { if (slideEls[i].offsetLeft <= mid) active = i; } return active; }; if (prevBtn) prevBtn.addEventListener('click', function () { goTo(Math.max(0, findActive() - 1)); }); if (nextBtn) nextBtn.addEventListener('click', function () { goTo(Math.min(slideEls.length - 1, findActive() + 1)); }); var syncTimer = null; viewport.addEventListener('scroll', function () { window.clearTimeout(syncTimer); syncTimer = window.setTimeout(function () { var active = findActive(); paint(active, active > 0, active < slideEls.length - 1); }, 100); }, {passive: true}); paint(0, false, slideEls.length > 1); } console.log('[w4d] 군산 읽기 ' + picked.length + '꼭지 카로셀(' + (emblaApi ? 'embla' : 'CSS scroll-snap') + ') · 매번 무작위 재구성'); } /* ══ ③-b 가요 다방 판꽂이 — 화살표 + 실제 카로셀 ═══════════════════════════════ * (2026-09-14 대표: "옆으로 갈수있는 버튼도 주고 slider.js 적용해줘") * * ★ 렌더러는 이 판 목록을 `Carousel.tsx` 로 감싸지 않는다 — 그냥 * `
    {판들}
    ` 이다(`SongsSection.tsx`). * 그래서 "군산 읽기" 처럼 이미 있는 `.slider-viewport` 를 마크업에서 그대로 못 쓰고, * **이 상자 하나를 감싸는 바깥 상자를 새로 만들어야** embla 를 붙일 자리(viewport)가 * 생긴다 — embla 는 "바깥(viewport) 안에 트랙 하나, 그 트랙 안에 슬라이드들"이라는 * 2단 구조를 기대한다. * ★ **판 버튼들은 옮기지 않는다.** 그 버튼들을 담은 상자(`track`, `:has()` 로 잡는 그 * div) 하나만 새 `viewport` 안으로 옮긴다 — 버튼 25개를 하나씩 옮기는 게 아니라 * 부모 상자 하나만 옮기는 것이라 위험이 작다. * ★ **React 참조는 안 깨진다.** `setPlaying` 으로 판을 고르면 React 는 이 `track` 노드를 * (같은 자리, 같은 key 구조라) 다시 만들지 않고 속성만 고친다 — 그 노드를 누가 * 감쌌는지는 React 가 안 본다. 그래서 옮겨 놓은 뒤에도 클릭이 계속 잘 먹는다. * 전체가 통째로 다시 그려지는 경우(하이드레이션 재렌더)만 대비해 감시해 다시 감싼다. * ★ 스크롤/스냅 CSS 를 내가 새로 안 만든다 — `.slider-viewport` 클래스 자체가 이미 * 그 스타일과 `[data-slider='on']` 전환까지 갖고 있다(전역 번들, "군산 읽기" 와 같은 * 근거). embla 가 못 붙어도 이 클래스 하나로 손가락 스크롤은 이미 된다. */ function startSongsRail() { var track = document.querySelector('#songs div:has(> button > .w4-disc-mini)'); if (!track) return false; if (track.parentNode && track.parentNode.classList.contains('slider-viewport')) return true; var viewport = document.createElement('div'); viewport.className = 'slider-viewport'; viewport.setAttribute('role', 'group'); viewport.setAttribute('aria-roledescription', '캐러셀'); viewport.setAttribute('aria-label', '가요 다방 판 고르기'); viewport.tabIndex = 0; track.parentNode.insertBefore(viewport, track); viewport.appendChild(track); var nav = document.createElement('div'); // mt-4 — 위 검은 턴테이블 카드와 거리를 준다. 원래 이 자리(판 목록 div) 자체가 // mt-4 였는데, 그 div 를 한 단 더 감싸면서(viewport) 그 여백이 안쪽으로 들어가 // 버렸다 — 화살표 줄이 카드에 바로 붙어 보인 이유(2026-09-14 대표 지적). nav.className = 'mt-4 mb-3 flex justify-end gap-2'; nav.innerHTML = '' + ''; viewport.parentNode.insertBefore(nav, viewport); // 카운터 — Carousel.tsx 도 점(dots)이 12개를 넘으면 점은 숨기고 이 숫자 줄만 남긴다. // 여기는 25~50곡이라 항상 그 경우다. var counter = document.createElement('p'); counter.className = 'mt-2 text-center text-current/55 text-[length:var(--fs-xs)] tabular-nums'; counter.innerHTML = '1 / ' + track.children.length; viewport.parentNode.insertBefore(counter, viewport.nextSibling); var prevBtn = nav.querySelector('.w4d-songs-prev'); var nextBtn = nav.querySelector('.w4d-songs-next'); var curEl = counter.querySelector('[data-cur]'); if (window.EmblaCarousel) { // align:'start' — Carousel.tsx 의 규칙과 같다("사진은 가운데, 카드 목록은 왼쪽"). // 판 목록은 사진이 아니라 고르는 목록이라 왼쪽 정렬이다. var api = window.EmblaCarousel(viewport, {align: 'start', loop: false, containScroll: 'trimSnaps'}); viewport.setAttribute('data-slider', 'on'); var sync = function () { curEl.textContent = String(api.selectedScrollSnap() + 1); prevBtn.disabled = !api.canScrollPrev(); nextBtn.disabled = !api.canScrollNext(); }; api.on('select', sync); api.on('reInit', sync); api.on('pointerDown', function () { viewport.setAttribute('data-dragging', 'true'); }); api.on('pointerUp', function () { viewport.removeAttribute('data-dragging'); }); prevBtn.addEventListener('click', function () { api.scrollPrev(); }); nextBtn.addEventListener('click', function () { api.scrollNext(); }); sync(); } else { // embla 를 못 붙였을 때 — `.slider-viewport` 기본 상태가 이미 스크롤 상자라 // 손가락·트랙패드로는 여전히 밀린다. 화살표만 판 한 장 폭만큼 밀어 대신한다. var step = function () { var first = track.querySelector('button'); return (first ? first.getBoundingClientRect().width : 68) + 14; }; var syncFallback = function () { prevBtn.disabled = viewport.scrollLeft <= 2; nextBtn.disabled = viewport.scrollLeft >= viewport.scrollWidth - viewport.clientWidth - 2; curEl.textContent = String(Math.round(viewport.scrollLeft / step()) + 1); }; prevBtn.addEventListener('click', function () { viewport.scrollBy({left: -step(), behavior: 'smooth'}); }); nextBtn.addEventListener('click', function () { viewport.scrollBy({left: step(), behavior: 'smooth'}); }); var songsSyncTimer = null; viewport.addEventListener('scroll', function () { window.clearTimeout(songsSyncTimer); songsSyncTimer = window.setTimeout(syncFallback, 100); }, {passive: true}); syncFallback(); } return true; } /* ══ ④ 링크 걸기 ═══════════════════════════════════════════════════════════ * 렌더러는 일정의 "지도 검색 …" 과 엽서의 장소를 **글자로만** 낸다. 손님이 그 자리에서 * 지도를 열 방법이 없다 — 이름을 복사해 다른 앱에 붙여야 한다. * 렌더러를 고치면 다른 사이트까지 바뀌므로(목업만 손대는 규약), 여기서 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); } /* ══ ⑥ 엽서 쓰기 — 스테이,머뭄 엽서를 직접 써서 공유 ═══════════════════════════ * (2026-09-14 대표 지시 세 번에 걸쳐 정리됐다) * ① "오늘의 엽서를 진짜 엽서처럼 자기가 텍스트 입력해서 공유하면 어떨까?" * ② "오늘의 엽서 칸을 빼고 이 엽서 포맷으로 스테이 머뭄 관련 엽서를 쓰는 걸로 하자. * 이건 축제소개 전에 섹션으로 빼줘." — 도시 엽서(옛 postcard 탭)는 **없앤다**. * 이 기능은 더 이상 '군산 이야기' 탭 하나가 아니라 **독립 섹션**이고, 사진도 * 군산 명소가 아니라 **이 숙소 사진**(payload.media)을 쓴다. * ③ "이미지는 스테이머뭄 이미지 선택으로" · "엔터치면 줄바꿈 부탁" * ④ "태그 지우셈 UI깨짐 #스테이머뭄" · "플레이스홀더도 그냥 일반적인 문구로 — * 스테이머뭄뿐만 아니라 다른 숙박업소나 카페도 써야 하는데 이렇게 써놓으면 * 어떡해" — **이 업소 이름을 코드에 못 박지 않는다.** 해시태그 줄(#스테이머뭄 * #군산여행)은 통째로 뺐고, 입력창 placeholder 의 예문도 이 업소 이름이 빠진 * 일반 문구로 바꿨다. 우표 옆 소인(원 안의 상호명)도 "스테이"·"머뭄" 고정 * 두 글자가 아니라 `placeName` 을 받아 어느 상호든 접어 넣는 `stampLines()` * 로 바꿨다 — 이 스크립트가 다른 업소에 그대로 옮겨져도 코드를 안 고쳐도 된다. * * ★ **og:image 링크 카드가 아니라 파일 그대로 공유다**(2026-09-14 대표 확정, 별도 논의). * 이 백엔드엔 익명 업로드 엔드포인트가 없다(사진은 전부 로그인한 사장님 전용). * Web Share API 로 캔버스 이미지를 파일째 공유 시트에 넘긴다 — 카카오톡이 그 시트에 * 있으면 백엔드 없이도 이미지가 그대로 대화방에 올라간다. 데스크톱처럼 파일 공유를 * 못 받는 브라우저는 "이미지로 저장" 버튼으로 대신한다(항상 같이 둔다). * ★ **글꼴은 화면과 같은 것**(대표: "폰트 동일한걸로") — 이미 떠 있는 `.serif` 요소의 * computed font-family 를 그대로 `ctx.font` 에 옮긴다. * ★ **모양은 진짜 엽서 카드(`PostcardSection.tsx`)를 그대로 베낀다** — 사진(위) + * 괘선으로 가른 두 칸(왼쪽 문구, 오른쪽 우표+소인) + 맨 아래 서명 줄. * 새로 만든 위젯이 아니라 "이 사이트에 있던 그 엽서를 내가 쓴 것"처럼 보여야 한다. * ★ **자리 — 독립 섹션, `#location`(오시는 길)과 `#festival`(계절별 축제) 사이**다. * 더는 '군산 이야기' 탭이 아니라서 React 의 tab hidden 로직에 안 걸린다 — 늘 보인다. * ★ 옛 '오늘의 엽서' 탭은 `patch_stay.py` 에서 payload 의 `postcard` 섹션 데이터를 * 비워 없앤다(StorySection 은 `items.length > 0` 인 탭만 세운다 — data 를 비우는 것만 * 으로 렌더러를 안 건드리고 탭이 사라진다). */ function postcardMaker() { // ★ 4장으로 자르지 않는다(2026-09-14 대표: "이미지는 다 가져와서 그냥 카로셀로 // 보여주든가 하셈") — 숙소 사진 전부(지금 9장)를 후보로 둔다. 개수가 늘면 한 줄에 // 다 못 앉으니 아래서 썸네일 줄 자체를 카로셀(`.slider-viewport` + embla)로 만든다. var photos = (P.media || []).filter(function (m) { return m.url; }); if (!photos.length) return false; if (document.getElementById('w4d-pm-section')) return true; // 이미 붙어 있다 var placeName = (P.place && (P.place.name || P.place.title)) || '스테이,머뭄'; var region = (P.place && (P.place.regionName || P.place.address || '')) || '전북 군산'; var regionShort = String(region).split(' ').slice(0, 2).join(' '); var section = document.createElement('section'); section.id = 'w4d-pm-section'; section.setAttribute('aria-labelledby', 'w4d-pm-heading'); section.innerHTML = '
    ' + '
    ' + '

    엽서 쓰기

    ' + '

    ' + escapeHtml(placeName) + '에서 보내는 한 장을 직접 써 봅니다. 사진을 고르고 한 마디를 적으면 ' + '그 자리에서 엽서가 되고, 그대로 공유하거나 저장할 수 있습니다.

    ' + '
    ' + '
    ' + '' + '
    ' + /* 썸네일 줄도 이 사이트의 진짜 카로셀 껍데기를 그대로 쓴다(가요 다방·군산 읽기와 같은 근거 — README `startSongsRail` 주석 참고). role=radiogroup 은 안쪽 track 이 아니라 이 뷰포트에 둔다 — 스크린리더가 "그룹" 보다 "라디오군" 을 먼저 읽어야 사진 고르기라는 게 전달된다. */ '
    ' + '
    ' + photos.map(function (m, i) { return ''; }).join('') + '
    ' + '' + '

    0/60

    ' + '
    ' + '' + '' + '
    ' + '' + '
    '; function mount() { if (section.isConnected) return true; var festival = document.getElementById('festival'); if (festival && festival.parentNode) { festival.parentNode.insertBefore(section, festival); return true; } var location = document.getElementById('location'); if (location && location.parentNode) { location.parentNode.insertBefore(section, location.nextElementSibling); return true; } return false; } if (!mount()) return false; var canvas = section.querySelector('.w4d-pm-canvas'); var ctx = canvas.getContext('2d'); var textarea = section.querySelector('.w4d-pm-text'); var lenEl = section.querySelector('[data-len]'); // draw() 안 사진 아래 레이아웃(margin·우표 칸 폭 190)과 같은 값이다 — 하나를 고치면 // 이 상수도 같이 고친다. 여기서도 필요한 이유: 몇 줄로 접힐지 **입력 중에 미리 재서** // MAX_LINES 를 넘기지 못하게 막는다(2026-09-14 대표: "4줄이상 못쓰게 막으라고"). var TEXT_COL_W = canvas.width - 56 - 190 - 56 - 24; var MAX_LINES = 4; var thumbs = Array.prototype.slice.call(section.querySelectorAll('.w4d-pm-thumb')); // 썸네일 카로셀 — embla 가 있으면 붙이고, 없으면 `.slider-viewport` 기본 스크롤로 남는다 // (가요 다방과 같은 폴백 규약). 사진을 고르는 라디오 그룹이라 화살표·점은 안 둔다 — // 판만 보고 눌러서 고르면 되는 자리다. var thumbsViewport = section.querySelector('.w4d-pm-thumbs-viewport'); if (thumbsViewport && window.EmblaCarousel) { var thumbsApi = window.EmblaCarousel(thumbsViewport, {align: 'start', loop: false, containScroll: 'trimSnaps'}); thumbsViewport.setAttribute('data-slider', 'on'); thumbsApi.on('pointerDown', function () { thumbsViewport.setAttribute('data-dragging', 'true'); }); thumbsApi.on('pointerUp', function () { thumbsViewport.removeAttribute('data-dragging'); }); } var shareBtn = section.querySelector('.w4d-pm-share'); var saveBtn = section.querySelector('.w4d-pm-save'); var hint = section.querySelector('.w4d-pm-hint'); /* ★ 버튼 문구에 "카카오톡" 을 못 박지 않는다 (2026-09-14 대표: "맥에서 메세지로밖에 안되는데..? 카카오톡 등으로 공유가 아니잖아 그럼" — 실측·정확한 지적). `navigator.share` 가 여는 시트는 **OS 가 채운다** — iOS·Android 는 설치된 앱이 공유 대상으로 등록되어 카카오톡이 뜨지만, **macOS 데스크톱은 앱이 시스템 공유 확장(NSSharingService)에 거의 등록을 안 해서** 메시지·메모·AirDrop 정도만 뜬다. 카카오톡 macOS 앱도 그 확장을 안 만든다 — 그래서 맥에서는 "메시지"만 보이는 게 버그가 아니라 OS 사양이다. 대신 **손끝으로 조작하는 기기(coarse pointer)에서만** "카카오톡 등으로 공유" 라고 적고, 마우스로 조작하는 기기(주로 데스크톱)에서는 그 앱 이름을 빼고 그냥 "공유하기" 라고 적는다 — 버튼이 실제로 하는 일은 같지만 손님이 기대할 결과를 기기에 맞게 정직하게 알려준다. */ var isTouch = window.matchMedia && window.matchMedia('(pointer: coarse)').matches; shareBtn.textContent = isTouch ? '카카오톡 등으로 공유' : '공유하기'; var picked = 0; var images = {}; // url → HTMLImageElement(로드 완료분) var redrawTimer = null; function loadImage(url) { if (images[url]) return Promise.resolve(images[url]); return new Promise(function (resolve, reject) { var img = new Image(); img.onload = function () { images[url] = img; resolve(img); }; img.onerror = reject; img.src = url; }); } /* 엽서 문구용 줄바꿈. 캔버스는 CSS 처럼 자동 줄바꿈을 안 해 준다. ★ **엔터는 그대로 줄바꿈이다**(2026-09-14 대표: "엔터치면 줄바꿈 부탁") — 먼저 `\n` 으로 문단을 가르고, 각 문단만 폭에 맞춰 다시 접는다. `\s+` 로 한 번에 쪼개면 사용자가 누른 엔터가 스페이스와 같은 취급을 받아 그대로 이어 붙어 버린다. */ function wrapLines(text, maxWidth) { var lines = []; text.split('\n').forEach(function (para) { if (para.trim() === '') { lines.push(''); return; } var words = para.split(' '); var line = ''; words.forEach(function (word) { var test = line ? line + ' ' + word : word; if (ctx.measureText(test).width <= maxWidth) { line = test; return; } if (line) lines.push(line); if (ctx.measureText(word).width <= maxWidth) { line = word; return; } /* ★ 공백 없는 긴 글자열은 단어 단위로 안 접힌다 (2026-09-14 대표 제보 — 자음만 줄줄이 이어친 문구가 우표 칸까지 넘쳐 나갔다). `stampLines()` 와 같은 방법으로 글자 단위로도 접는다. */ var chunk = ''; for (var i = 0; i < word.length; i += 1) { var next = chunk + word[i]; if (ctx.measureText(next).width > maxWidth && chunk) { lines.push(chunk); chunk = word[i]; } else { chunk = next; } } line = chunk; }); if (line) lines.push(line); }); return lines; } /* 소인(우표 옆 회전 원) 안 상호명 줄바꿈 — `wrapLines` 와 따로 둔다. ★ **이 업소 이름을 박아 두지 않는다**(2026-09-14 대표: "다른 숙박업소나 카페도 써야 하는데 이렇게 써놓으면 어떡해"). "스테이"·"머뭄" 두 글자씩 고정 두 줄이던 걸 빼고, **어느 상호가 와도** 원 안에 맞게 접는다. 콤마·공백처럼 상호 안의 자연스러운 경계에서 먼저 끊고, 그 조각 하나가 원보다 넓으면 글자 단위로도 접는다 — "스테이,머뭄" 처럼 사이 공백 없는 이름도 있어 `wrapLines`(공백 기준)만으로는 한 줄로 넘쳐 버린다. */ function stampLines(name, maxWidth) { var parts = name.split(/[,\s]+/).filter(Boolean); var lines = []; var line = ''; parts.forEach(function (part) { var test = line ? line + ' ' + part : part; if (ctx.measureText(test).width <= maxWidth) { line = test; return; } if (line) lines.push(line); if (ctx.measureText(part).width <= maxWidth) { line = part; return; } var chunk = ''; for (var i = 0; i < part.length; i += 1) { var next = chunk + part[i]; if (ctx.measureText(next).width > maxWidth && chunk) { lines.push(chunk); chunk = part[i]; } else { chunk = next; } } line = chunk; }); if (line) lines.push(line); return lines; } function draw() { var photo = photos[picked]; var size = canvas.width; // 정사각형(1080) — 카톡 등 공유의 1:1 관례와 같다 var serifFont = 'serif'; var probe = document.querySelector('.serif'); if (probe) { var family = getComputedStyle(probe).fontFamily; if (family) serifFont = family; } var ink = '#1b1a15'; var paper = '#efe7d3'; var accent = '#bf2f1b'; var margin = 56; ctx.clearRect(0, 0, size, size); ctx.fillStyle = paper; ctx.fillRect(0, 0, size, size); loadImage(photo.url).then(function (img) { /* ── 위: 사진 (실제 카드의 aspect-3/2 앞면과 같은 비율) ── */ var photoX = margin, photoY = margin; var photoW = size - margin * 2; var photoH = photoW / 1.5; var scale = Math.max(photoW / img.width, photoH / img.height); var w = img.width * scale, h = img.height * scale; ctx.save(); ctx.beginPath(); ctx.rect(photoX, photoY, photoW, photoH); ctx.clip(); ctx.drawImage(img, photoX + (photoW - w) / 2, photoY + (photoH - h) / 2, w, h); ctx.restore(); ctx.strokeStyle = 'rgba(27,26,21,0.25)'; ctx.lineWidth = 1; ctx.strokeRect(photoX + 0.5, photoY + 0.5, photoW - 1, photoH - 1); /* ── 아래: 괘선으로 가른 두 칸(실제 카드 grid-cols-[1fr_auto] 그대로) ── */ var bodyY = photoY + photoH + 36; var stampColW = 190; var dividerX = size - margin - stampColW; var textColW = dividerX - margin - 24; ctx.strokeStyle = 'rgba(27,26,21,0.35)'; ctx.lineWidth = 1; ctx.beginPath(); ctx.moveTo(dividerX, bodyY); ctx.lineTo(dividerX, size - margin - 46); ctx.stroke(); // 왼쪽 — 사용자 문구(“ ” 인용부호, 실제 카드와 같은 서체) // ★ 해시태그 줄은 뺐다(2026-09-14 대표: "태그 지우셈 UI깨짐") — 짧은 문구 뒤에 // 고정 해시태그(#스테이머뭄 #군산여행)를 더 얹었더니 두 줄짜리 긴 문구에서는 // 해시태그가 우표 칸 밑으로 겹쳐 나갔다. 문구 하나만 남긴다. var text = textarea.value.trim() || '오늘, 이 숙소에서 하루를 보냅니다.'; ctx.fillStyle = ink; ctx.font = '600 46px ' + serifFont; ctx.textAlign = 'left'; ctx.textBaseline = 'alphabetic'; var wrapped = wrapLines('“' + text + '”', textColW); // 입력 쪽(textarea input 핸들러)에서 이미 4줄을 못 넘게 막지만, 여기서도 한 번 더 // 자른다 — 그래야 어떤 경로로 그려지든(붙여넣기 등) 카드가 절대 안 깨진다. var lines = wrapped.slice(0, MAX_LINES); if (wrapped.length > MAX_LINES) { var last = lines[MAX_LINES - 1]; while (last.length && ctx.measureText(last + '…').width > textColW) last = last.slice(0, -1); lines[MAX_LINES - 1] = last + '…'; } var lineHeight = 60; lines.forEach(function (line, i) { ctx.fillText(line, margin, bodyY + 50 + i * lineHeight); }); // 오른쪽 — 우표 + 소인(실제 카드의 郵票 박스·회전 원 그대로) var stampCx = dividerX + 24 + stampColW / 2 - 24; ctx.strokeStyle = ink; ctx.lineWidth = 1.5; ctx.setLineDash([4, 4]); ctx.strokeRect(stampCx - 44, bodyY, 88, 108); ctx.setLineDash([]); ctx.fillStyle = ink; ctx.font = '400 20px ' + serifFont; ctx.textAlign = 'center'; ctx.fillText('郵票', stampCx, bodyY + 46); ctx.fillText('10원', stampCx, bodyY + 78); ctx.save(); ctx.translate(stampCx, bodyY + 210); ctx.rotate((-6 * Math.PI) / 180); ctx.strokeStyle = accent; ctx.lineWidth = 3; ctx.beginPath(); ctx.arc(0, 0, 62, 0, Math.PI * 2); ctx.stroke(); ctx.fillStyle = accent; ctx.font = '600 22px ' + serifFont; // 상호가 무엇이든(숙소·카페 어디에도 이 스크립트가 그대로 쓰인다) 원 안에 // 맞춰 접는다 — 이 업소 이름을 고정으로 박지 않는다. var nameLines = stampLines(placeName, 100).slice(0, 2); var nameLH = 26; var nameStartY = 8 - ((nameLines.length - 1) * nameLH) / 2; nameLines.forEach(function (line, i) { ctx.fillText(line, 0, nameStartY + i * nameLH); }); ctx.restore(); // 맨 아래 — 점선 + 서명 줄(실제 카드의 출처 줄과 같은 자리) var footY = size - margin - 30; ctx.strokeStyle = 'rgba(27,26,21,0.3)'; ctx.setLineDash([3, 3]); ctx.beginPath(); ctx.moveTo(margin, footY - 20); ctx.lineTo(size - margin, footY - 20); ctx.stroke(); ctx.setLineDash([]); ctx.fillStyle = 'rgba(27,26,21,0.6)'; ctx.font = '400 22px ' + serifFont; ctx.textAlign = 'left'; ctx.fillText(placeName + ' · ' + regionShort, margin, footY + 6); }); } function scheduleDraw() { window.clearTimeout(redrawTimer); redrawTimer = window.setTimeout(draw, 80); } thumbs.forEach(function (btn, i) { btn.addEventListener('click', function () { picked = i; thumbs.forEach(function (t, j) { t.setAttribute('aria-pressed', j === i ? 'true' : 'false'); }); scheduleDraw(); }); }); // 몇 줄로 접힐지는 폭·서체에 달려 있어 글자 수로는 못 잰다 — draw() 와 같은 // wrapLines() 로 실제 접은 결과를 재서 넘으면 되돌린다(2026-09-14 대표: "4줄이상 // 못쓰게 막으라고"). 자음만 이어치거나 엔터를 여러 번 눌러도 여기서 막힌다. var lastGoodText = textarea.value; textarea.addEventListener('input', function () { if (wrapLines('“' + textarea.value + '”', TEXT_COL_W).length > MAX_LINES) { textarea.value = lastGoodText; } else { lastGoodText = textarea.value; } lenEl.textContent = String(textarea.value.length); scheduleDraw(); }); textarea.addEventListener('keydown', function (event) { // Enter 는 기본 동작(줄바꿈)을 그대로 둔다 — 폼 전송이 아니므로 막을 이유가 없다. // 여기서는 눌린 즉시 다시 그려 손님이 줄바꿈 결과를 바로 본다(input 이벤트보다 빠르다). if (event.key === 'Enter') scheduleDraw(); }); function exportBlob(cb) { canvas.toBlob(function (blob) { cb(blob); }, 'image/png', 0.95); } /* ★ 사진 + 링크만 보낸다, 캡션 문구는 안 넣는다(2026-09-14 대표: "이미지+내용+링크 하니까 안예쁨" — 손님이 쓴 문구는 이미 canvas 안 엽서 그림에 박혀 있는데, 시트에 또 같은 문구를 글자로 한 번 더 얹으면 같은 말이 두 번 보였다). `files` 와 `url` 을 같은 navigator.share() 호출에 같이 넣는 조합은 기기마다 지원이 갈려서, 파일이 실리는 경우엔 링크를 `text` 자리에 적어 붙인다(문구 없이 링크 한 줄만). */ shareBtn.addEventListener('click', function () { var urlMeta = document.querySelector('meta[property="og:url"]'); var pageUrl = (urlMeta && urlMeta.content) || (location.origin + location.pathname); exportBlob(function (blob) { if (!blob) return; var file = new File([blob], 'stay-postcard.png', {type: 'image/png'}); if (navigator.canShare && navigator.canShare({files: [file]})) { navigator.share({files: [file], title: placeName + ' 엽서', text: pageUrl}) .catch(function () { /* 손님이 시트를 닫은 것도 여기로 온다 — 오류로 안 다룬다 */ }); } else if (navigator.share) { // 파일 공유를 못 받는 브라우저(주로 데스크톱)는 링크만이라도 시트로 보낸다. navigator.share({title: placeName + ' 엽서', url: pageUrl}) .catch(function () { /* 위와 같은 이유로 오류로 안 다룬다 */ }); hint.hidden = false; hint.textContent = '이 브라우저는 사진은 같이 못 보내 링크만 보냈습니다. 사진은 "이미지로 저장" 을 눌러 주세요.'; } else { hint.hidden = false; var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'stay-postcard.png'; a.click(); URL.revokeObjectURL(url); } }); }); saveBtn.addEventListener('click', function () { exportBlob(function (blob) { if (!blob) return; var url = URL.createObjectURL(blob); var a = document.createElement('a'); a.href = url; a.download = 'stay-postcard.png'; a.click(); URL.revokeObjectURL(url); }); }); // 폰트가 이 시점에 아직 안 실렸을 수 있다 — 실리고 나서 한 번 더 그린다. if (document.fonts && document.fonts.ready) { document.fonts.ready.then(draw); } draw(); return true; } /* ══ ⑦ 날씨 카드 문구 — 케이스마다 여러 벌, 랜덤 ═══════════════════════════════ * ★ 왜 주입인가 (2026-09-11 사장님: "그외만 … 으로 바꿔줘 · 목업만이야") * 카드는 하늘을 넷(맑음·흐림·비·눈)으로만 가르므로 **구름많음(코드 1~3)과 그 외**(안개 45 · * 소나기 80~82 · 뇌우 95~99 …)가 payload 의 같은 칸(`local.weather.notes.흐림`)을 본다 — * 칸이 하나라 payload 로는 둘을 못 가른다. 렌더러(`derive.ts weatherMood`)를 가르는 건 * 제품을 건드리는 일이고, 목업은 재굽기 대상이 아니라 고쳐도 이 페이지엔 오지 않는다. * ★ 문장을 여러 벌 돌린다 (2026-09-14 사장님: "케이스당 여러개로 만들어서 랜덤으로") * 전에는 케이스마다 한 줄이라 새로고침해도 같은 문장이었다. 이제 하늘 5 · 기온대 5, * 각 다섯 벌씩 `local.weather.noteSets` · `tempNoteSets` 에 있다(patch_stay.py 가 넣는다). * **자루에서 뽑아 쓰고 비면 다시 채운다** — 매번 랜덤이면 같은 문장이 연달아 나온다 * (캐치프레이즈와 같은 규칙). * ★ 뽑는 것은 **들어올 때 한 번**이다. 화면에 떠 있는 동안 저절로 바뀌지 않는다 — * 읽는 중에 문장이 갈리는 것이 손님에게는 고장으로 보인다. 10분마다 도는 날씨 갱신에서 * 하늘·기온대가 달라졌을 때만 다시 뽑는다. * ★ 굽힌 판에는 케이스마다 첫 문장이 박혀 있다. 그래서 DOM 에서 바꿀 줄을 **아는 문장 전부**와 * 대조해 찾는다 — 한 번 갈아 끼운 뒤에는 원래 문장이 거기 없기 때문이다. * ★ React 는 날씨를 10분마다 다시 받아 그리면서 payload 문장으로 되돌린다 * (`use-live-weather`) — 옵저버로 되돌아간 것을 보고 다시 넣는다. 이미 맞으면 손대지 * 않으므로 옵저버가 자기 변경을 다시 보는 순환은 생기지 않는다. */ /* 렌더러의 기온대(`site/src/lib/derive.ts weatherBand`)와 **같은 경계**여야 한다 — 화면의 배지는 렌더러가 붙이고 문장만 여기서 갈아 끼우므로, 어긋나면 '추움' 배지 옆에 혹서 문장이 선다. */ function bandOf(temperature) { if (temperature >= 30) return '혹서'; if (temperature >= 25) return '더움'; if (temperature >= 20) return '선선'; if (temperature >= 10) return '쌀쌀'; return '추움'; } /* 카드가 '흐림' 칸을 쓰는 코드 가운데 **구름많음이 아닌** 것. 렌더러의 분류에서 맑음·비·눈에 해당하는 구간과 1~3 을 뺀 나머지가 전부 '그외'다. */ function skyOf(code) { if (code === 0) return '맑음'; if (code >= 1 && code <= 3) return '구름많음'; if (code >= 50 && code <= 69) return '비'; if (code >= 70 && code <= 79) return '눈'; return '그외'; } /* 구운 payload 의 조건 문자열 → 같은 다섯. 렌더러의 `derive.ts weatherMood` 와 순서를 맞춘다 — "비/눈" 같은 합성 표기에서 눈을 먼저 본다. */ function skyOfText(text) { if (/눈|설/.test(text)) return '눈'; if (/비|우|소나기/.test(text)) return '비'; if (/구름/.test(text)) return '구름많음'; if (/흐림|안개|박무/.test(text)) return '그외'; return '맑음'; } /* 자루 — 한 바퀴를 다 쓰기 전에는 같은 문장이 다시 나오지 않는다. */ function bagger(pools) { var bags = {}; return function next(key) { var pool = pools[key]; if (!pool || !pool.length) return null; if (!bags[key] || !bags[key].length) { bags[key] = pool.slice(); if (bags[key].length > 1) bags[key].sort(function () { return Math.random() - 0.5; }); } return bags[key].shift(); }; } function startWeatherNote() { var w = (P.local && P.local.weather) || {}; var place = P.place || {}; var skyPools = w.noteSets || {}; var tempPools = w.tempNoteSets || {}; /* 아는 문장 전부. 굽힌 판의 값(notes·tempNotes)도 넣는다 — 첫 화면은 그것이 서 있다. */ function known(pools, baked) { var set = Object.create(null); // 문장이 키다 — 프로토타입의 이름과 부딪히지 않게 빈 객체로 판다. Object.keys(pools).forEach(function (k) { (pools[k] || []).forEach(function (t) { set[t] = true; }); }); Object.keys(baked || {}).forEach(function (k) { set[baked[k]] = true; }); return set; } var skyKnown = known(skyPools, w.notes); var tempKnown = known(tempPools, w.tempNotes); if (!Object.keys(skyKnown).length && !Object.keys(tempKnown).length) return; var nextSky = bagger(skyPools); var nextTemp = bagger(tempPools); var sky = null; // 지금 하늘 케이스 var band = null; // 지금 기온대 var line = {sky: null, temp: null}; /* 문장 하나를 갈아 끼운다. 접었다 펴는 건 눈이 따라오게 하려는 것뿐이라, 실패하면 그냥 바꾼다. */ function put(node, text) { if (!node || !text || node.textContent.trim() === text) return; if (REDUCED) { node.textContent = text; return; } node.setAttribute('data-w4d-fade', ''); node.style.opacity = '0'; window.setTimeout(function () { node.textContent = text; node.style.opacity = ''; }, 300); } /* 하늘 줄은

    자체가 문장이고, 기온 줄은 배지 옆의 이 문장이다. 클래스로 찾지 않는다 — 번들 판이 바뀌면 달라지고 두 줄 다 id 가 없다. */ function apply() { var section = document.getElementById('weather'); if (!section) return; var nodes = section.querySelectorAll('p, p span'); for (var i = 0; i < nodes.length; i += 1) { var node = nodes[i]; if (node.children.length) continue; // 잎만 본다 var text = node.textContent.trim(); if (line.sky && skyKnown[text]) { put(node, line.sky); continue; } if (line.temp && tempKnown[text]) { put(node, line.temp); } } } function reroll() { if (sky) line.sky = nextSky(sky) || line.sky; if (band) line.temp = nextTemp(band) || line.temp; apply(); } var pending = null; var observer = new window.MutationObserver(function () { window.clearTimeout(pending); pending = window.setTimeout(apply, 100); }); var root = document.getElementById('root'); if (root) observer.observe(root, {childList: true, subtree: true, characterData: true}); /* 렌더러가 보는 그 엔드포인트다(같은 오리진). 실패하면 구운 문장을 그대로 둔다. */ function refresh() { if (!place.regionCode || place.latitude == null) return; var query = 'region_code=' + encodeURIComponent(place.regionCode) + '&latitude=' + place.latitude + '&longitude=' + place.longitude; window.fetch('/v1/local/weather?' + query) .then(function (r) { return r.ok ? r.json() : null; }) .then(function (body) { if (!body || !body.weather) return; var nextSkyCase = skyOf(Number(body.weather.weather_code)); var nextBand = bandOf(Number(body.weather.temperature)); // 케이스가 그대로면 문장도 그대로 둔다 — 10분마다 눈앞에서 갈리면 안 된다. if (nextSkyCase === sky && nextBand === band) return; sky = nextSkyCase; band = nextBand; reroll(); }) .catch(function () { /* 구운 문장 그대로 */ }); } /* 굽힌 값으로 먼저 한 벌 뽑아 둔다 — 날씨 API 가 늦거나 죽어도 문장은 돌아간다. 구운 payload 에는 코드가 없고 사람이 읽는 문자열뿐이라 그쪽에서 되짚는다. */ sky = skyOfText(String(w.condition || '')); band = bandOf(Number(w.temperature)); reroll(); refresh(); window.setInterval(refresh, 10 * 60 * 1000); } /* ══ ⑧ 사진 갤러리 쪽 넘김 — 넓은 화면에서만 ═══════════════════════════════ * ★ 왜 (2026-09-14 사장님: "사진갤러리 전체 렌더링 말고 페이징 좀. 너무 많아 데스크톱에서는") * `GallerySection` 은 좁은 화면에서 한 장씩 미는 캐러셀이고, 넓은 화면에서는 같은

      이 * 4열 격자가 된다(`lg:grid lg:grid-cols-4`). 사진 37장이면 열 줄 — 갤러리 하나가 * 페이지를 끊는다. 그래서 넓은 화면에서만 12장(3줄)씩 끊어 보여 준다. * * ★
    • 를 **지우지 않는다.** 숨기기는 `data-w4d-gal-on='0'` + 주입 CSS 가 하고, 그 규칙은 * `min-width: 1024px` 안에만 있다. 두 가지 이유다. * · 좁은 화면 캐러셀은 손대지 않아야 한다 — 거기선 이미 한 번에 한 장이다. * · 사진이 DOM 에 남아 있어야 alt 문장이 문서에 남는다. 렌더러도 같은 이유로 배리에이션 * 셋 모두에서 사진을 전부 싣는다(`GallerySection.tsx` 머리주석). * * ★ 크게 보기(모달)와 어긋나지 않는다. 모달의 앞/뒤는 React 가 `images` **배열 색인**으로 * 도는 것이라 화면에 무엇이 보이는지와 무관하다 — 3쪽에서 연 사진도 ←/→ 로 37장을 다 돈다. * * ★ 페이저는 React 가 그리는 상자(`div.relative`) 안에 꽂는다. 재렌더에 날아가므로 * #root 를 감시해 다시 꽂는다(② 헤더 단추와 같은 방식). 다시 부를 수 있게 만들었다 — * 이미 꽂혀 있으면 표시만 다시 찍는다. */ var GAL_PER_PAGE = 12; // 4열 × 3줄. 줄 수를 바꾸려면 4의 배수로 둔다 function galleryPager() { var box = document.getElementById('gallery'); if (!box) return; var list = box.querySelector('ul'); if (!list) return; var items = list.children; var total = items.length; // 한 쪽에 다 들어가면 페이저를 만들지 않는다 — 단추 한 개짜리 줄만 남는다. if (total <= GAL_PER_PAGE) return; var pages = Math.ceil(total / GAL_PER_PAGE); var pager = box.querySelector('.w4d-gal-pager'); var fresh = !pager; if (fresh) { pager = document.createElement('nav'); pager.className = 'w4d-gal-pager'; pager.setAttribute('aria-label', '사진 갤러리 쪽 넘기기'); pager.dataset.w4dGalPage = '1'; var html = ''; for (var i = 1; i <= pages; i += 1) { html += ''; } html += '' + '사진 ' + total + '장'; pager.innerHTML = html; pager.addEventListener('click', function (event) { var hit = event.target.closest('button'); if (!hit) return; var now = Number(pager.dataset.w4dGalPage) || 1; var next = hit.dataset.w4dGalPage ? Number(hit.dataset.w4dGalPage) : now + Number(hit.dataset.w4dGalStep); if (next < 1 || next > pages || next === now) return; pager.dataset.w4dGalPage = String(next); paint(); }); } function paint() { var page = Number(pager.dataset.w4dGalPage) || 1; var from = (page - 1) * GAL_PER_PAGE; list.dataset.w4dGal = '1'; for (var i = 0; i < items.length; i += 1) { items[i].dataset.w4dGalOn = (i >= from && i < from + GAL_PER_PAGE) ? '1' : '0'; } var buttons = pager.querySelectorAll('button'); for (var b = 0; b < buttons.length; b += 1) { var button = buttons[b]; if (button.dataset.w4dGalPage) { button.setAttribute('aria-current', Number(button.dataset.w4dGalPage) === page ? 'true' : 'false'); } else { var step = Number(button.dataset.w4dGalStep); button.disabled = (page + step) < 1 || (page + step) > pages; } } } paint(); if (fresh) list.parentElement.appendChild(pager); } /* ★ `load` 를 기다리지 않는다. 이 페이지는 경로 지도 때문에 OSM 타일 가 151장이라 (일정 20개 × 2~3일) load 는 그게 다 끝나야 나온다 — 타일이 느리면 영원히 안 뜬다. */ /* ★ 어느 판을 보고 있는지 콘솔에서 바로 알게 한다. `/s/` 는 5분 캐시라 "고쳤는데 안 된다" 의 대부분이 **옛 파일을 보고 있는 것**이었다 — 그걸 눈으로 가른다. `__W4D_BUILD__` 는 patch_stay.py 가 굽는 시각(UPDATED_AT)으로 바꿔 박는다. */ console.log('[w4d] 주입분 로드 · 빌드 __W4D_BUILD__'); try { startPlayer(); } catch (err) { console.warn('[w4d] 플레이어 실패', err); } try { startReading(); } catch (err) { console.warn('[w4d] 군산 읽기 실패', err); } try { startSongsRail(); if (window.MutationObserver) { // ★ 판 하나 고르는 클릭(`setPlaying`)에는 안 걸린다 — track 노드가 그대로라 // `.slider-viewport` 를 계속 물고 있다. 이 감시는 **하이드레이션 재렌더처럼 // #songs 통째가 다시 그려질 때만** 다시 감싼다. new MutationObserver(function () { startSongsRail(); }) .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); } console.log('[w4d] 가요 다방 카로셀 — 화살표 + embla'); } catch (err) { console.warn('[w4d] 가요 다방 카로셀 실패', err); } try { postcardMaker(); if (window.MutationObserver) { new MutationObserver(function () { postcardMaker(); }) .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); } console.log('[w4d] 엽서 직접 쓰기'); } catch (err) { console.warn('[w4d] 엽서 만들기 실패', err); } try { tameRails(); console.log('[w4d] 카로셀 — 만지는 동안 정지(7초) · 직접 민 레일은 영구 정지'); } catch (err) { console.warn('[w4d] 카로셀 실패', err); } try { galleryPager(); if (window.MutationObserver) { // 재렌더가
    • 를 다시 만들면 표시(data-w4d-gal-on)가 사라진다 — 다시 찍는다. // 속성은 감시하지 않는다(childList 만) — 우리가 찍는 속성에 우리가 다시 깨어난다. new MutationObserver(function () { galleryPager(); }) .observe(document.getElementById('root') || document.body, {childList: true, subtree: true}); } console.log('[w4d] 사진 갤러리 — 넓은 화면 ' + GAL_PER_PAGE + '장씩'); } 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); } try { startWeatherNote(); console.log('[w4d] 날씨 문구 — 하늘 5 · 기온대 5, 케이스마다 다섯 벌 중 하나'); } catch (err) { console.warn('[w4d] 날씨 문구 실패', err); } }, 1200); })();