1(() => { 2 'use strict'; 3 4 const app = document.querySelector('#moonpoetry-app'); 5 if (!app) return; 6 7 const body = document.body; 8 const numberReel = app.querySelector('[data-number-reel]'); 9 const numberTrack = app.querySelector('[data-number-track]'); 10 const numberSelect = app.querySelector('[data-number-select]'); 11 const drawButton = app.querySelector('[data-draw]'); 12 const detail = app.querySelector('[data-poem-detail]'); 13 const detailInner = app.querySelector('[data-detail-inner]'); 14 const resultNumber = app.querySelector('[data-result-number]'); 15 const resultOmen = app.querySelector('[data-result-omen]'); 16 const resultPoetry = app.querySelector('[data-result-poetry]'); 17 const resultExplanations = app.querySelector('[data-result-explanations]'); 18 const status = app.querySelector('[data-status]'); 19 const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)'); 20 21 function setupSiteMenu() { 22 const menu = document.querySelector('[data-site-menu]'); 23 if (!menu) return; 24 25 const toggle = menu.querySelector('[data-site-menu-toggle]'); 26 const list = menu.querySelector('[data-site-menu-list]'); 27 if (!toggle || !list) return; 28 29 const setOpen = (open, restoreFocus = false) => { 30 menu.classList.toggle('is-open', open); 31 toggle.setAttribute('aria-expanded', String(open)); 32 toggle.setAttribute('aria-label', open ? 'ééç¶²ç«é¸å®' : 'éåç¶²ç«é¸å®'); 33 list.setAttribute('aria-hidden', String(!open)); 34 body.classList.toggle('site-menu-open', open); 35 36 if (restoreFocus) toggle.focus({ preventScroll: true }); 37 }; 38 39 toggle.addEventListener('click', () => { 40 setOpen(!menu.classList.contains('is-open')); 41 }); 42 43 list.addEventListener('click', (event) => { 44 if (event.target.closest('a')) setOpen(false); 45 }); 46 47 document.addEventListener('click', (event) => { 48 if (menu.classList.contains('is-open') && !menu.contains(event.target)) setOpen(false); 49 }); 50 51 document.addEventListener('keydown', (event) => { 52 if (event.key === 'Escape' && menu.classList.contains('is-open')) setOpen(false, true); 53 }); 54 } 55 56 const state = { 57 poems: [], 58 selectedIndex: 0, 59 drawing: false, 60 revealTimer: 0, 61 resetTimer: 0, 62 wheelLocked: false, 63 dragStartY: null, 64 }; 65 66 const explanationFields = [ 67 ['解說', 'explained'], 68 ['é義', 'shiyi'], 69 ['äºè¡(é)', 'five_gold'], 70 ['äºè¡(æ¨)', 'five_wood'], 71 ['äºè¡(æ°´)', 'five_water'], 72 ['äºè¡(ç«)', 'five_fire'], 73 ['äºè¡(å)', 'five_earth'], 74 ]; 75 76 function createStack() { 77 const stack = app.querySelector('[data-stack-repeats]'); 78 const fragment = document.createDocumentFragment(); 79 80 for (let index = 1; index <= 14; index += 1) { 81 const item = document.createElement('div'); 82 const image = document.createElement('img'); 83 item.className = 'poem-stack-repeat'; 84 item.style.setProperty('--stack-index', String(index)); 85 item.style.setProperty('--stack-show-delay', `${.12 + (14 - index) * .1}s`); 86 item.style.setProperty('--stack-hide-delay', `${2.15 + (14 - index) * .1}s`); 87 item.style.zIndex = String(18 - index); 88 image.src = './assets/poem-repeat.svg'; 89 image.alt = ''; 90 item.append(image); 91 fragment.append(item); 92 } 93 94 stack.append(fragment); 95 } 96 97 function startInitialAnimation() { 98 let started = false; 99 100 const start = () => { 101 if (started) return; 102 started = true; 103 104 requestAnimationFrame(() => { 105 requestAnimationFrame(() => { 106 body.classList.add('stack-animation-started'); 107 108 const finishDelay = reduceMotion.matches ? 30 : 4250; 109 window.setTimeout(() => { 110 body.classList.add('stack-animation-finished'); 111 }, finishDelay); 112 }); 113 }); 114 }; 115 116 const cardImages = [...app.querySelectorAll('.poem-card-frame, .poem-stack-repeat img')]; 117 const imageReadyTasks = cardImages.map((image) => { 118 if (image.complete) return Promise.resolve(); 119 120 return new Promise((resolve) => { 121 image.addEventListener('load', resolve, { once: true }); 122 image.addEventListener('error', resolve, { once: true }); 123 }); 124 }); 125 126 Promise.all(imageReadyTasks).then(start); 127 } 128 129 function createSelectors() { 130 const reelFragment = document.createDocumentFragment(); 131 const selectFragment = document.createDocumentFragment(); 132
133 state.poems.forEach((poem, index) => { 134 const chineseNumber = String(poem.chinese_number || poem.number || index + 1); 135 const optionButton = document.createElement('button'); 136 optionButton.className = 'number-option'; 137 optionButton.type = 'button'; 138 optionButton.dataset.index = String(index); 139 optionButton.setAttribute('role', 'option'); 140 optionButton.textContent = chineseNumber; 141 reelFragment.append(optionButton); 142 143 const selectOption = document.createElement('option'); 144 selectOption.value = String(index); 145 selectOption.textContent = chineseNumber; 146 selectFragment.append(selectOption); 147 }); 148 149 numberTrack.append(reelFragment); 150 numberSelect.append(selectFragment); 151 updateSelectedNumber(0, false); 152 } 153 154 function updateSelectedNumber(index, animate = true) { 155 const lastIndex = Math.max(0, state.poems.length - 1); 156 state.selectedIndex = Math.min(lastIndex, Math.max(0, index)); 157 158 if (!animate) numberTrack.style.transition = 'none'; 159 numberTrack.style.transform = `translateY(${101.5 - state.selectedIndex * 45}px)`; 160 if (!animate) { 161 requestAnimationFrame(() => { 162 numberTrack.style.transition = ''; 163 }); 164 } 165 166 numberTrack.querySelectorAll('.number-option').forEach((option, optionIndex) => { 167 const active = optionIndex === state.selectedIndex; 168 option.classList.toggle('is-active', active); 169 option.setAttribute('aria-selected', active ? 'true' : 'false'); 170 option.tabIndex = active ? 0 : -1; 171 }); 172 numberSelect.value = String(state.selectedIndex); 173 } 174 175 function moveSelectedNumber(direction) { 176 updateSelectedNumber(state.selectedIndex + direction); 177 } 178 179 function addExplanation(title, content) { 180 const value = content === null || content === undefined ? '' : String(content).trim(); 181 if (value === '' || value === 'ç¡' || value === 'NULL') return; 182 183 const item = document.createElement('li'); 184 const heading = document.createElement('div'); 185 const text = document.createElement('div'); 186 heading.className = 'explanation-title'; 187 text.className = 'explanation-content'; 188 heading.textContent = title; 189 text.textContent = value; 190 item.append(heading, text); 191 resultExplanations.append(item); 192 } 193 194 function populateResult(poem) { 195 const chineseNumber = String(poem.chinese_number || poem.number || ''); 196 resultNumber.replaceChildren(); 197 198 ['第', ...chineseNumber, '籤'].forEach((character) => { 199 const characterElement = document.createElement('span'); 200 characterElement.textContent = character; 201 resultNumber.append(characterElement); 202 }); 203 204 resultOmen.textContent = poem.omen && poem.omen !== 'ç¡' ? String(poem.omen) : ''; 205 resultPoetry.textContent = String(poem.poetry || '').replace(/[\u3000]+/g, '\n'); 206 resultExplanations.replaceChildren(); 207 explanationFields.forEach(([title, field]) => addExplanation(title, poem[field])); 208 } 209 210 function resetDetailScroll() { 211 detailInner.scrollTop = 0; 212 detailInner.scrollLeft = 0; 213 } 214 215 function getVisibleBackButton() { 216 return [...app.querySelectorAll('[data-detail-back]')] 217 .find((button) => button.offsetParent !== null); 218 } 219 220 function revealDetail() { 221 resetDetailScroll(); 222 detail.classList.add('is-show'); 223 detail.setAttribute('aria-hidden', 'false'); 224 state.drawing = false; 225 const backButton = getVisibleBackButton(); 226 if (backButton) backButton.focus({ preventScroll: true }); 227 } 228 229 function drawPoem() { 230 if (state.drawing || state.poems.length === 0) return; 231 232 state.drawing = true; 233 drawButton.disabled = true; 234 populateResult(state.poems[state.selectedIndex]); 235 resetDetailScroll(); 236 body.classList.remove('is-drawing'); 237 void body.offsetWidth; 238 body.classList.add('is-drawing'); 239 240 const revealDelay = reduceMotion.matches ? 20 : 3000; 241 clearTimeout(state.revealTimer); 242 state.revealTimer = window.setTimeout(revealDetail, revealDelay); 243 } 244 245 function closeDetail() { 246 clearTimeout(state.revealTimer);
247 detail.classList.remove('is-show'); 248 detail.setAttribute('aria-hidden', 'true'); 249 250 const resetDelay = reduceMotion.matches ? 20 : 500; 251 clearTimeout(state.resetTimer); 252 state.resetTimer = window.setTimeout(() => { 253 body.classList.remove('is-drawing'); 254 drawButton.disabled = false; 255 state.drawing = false; 256 drawButton.focus({ preventScroll: true }); 257 }, resetDelay); 258 } 259 260 function bindEvents() { 261 numberTrack.addEventListener('click', (event) => { 262 const option = event.target.closest('.number-option'); 263 if (option) updateSelectedNumber(Number(option.dataset.index)); 264 }); 265 266 numberReel.addEventListener('wheel', (event) => { 267 event.preventDefault(); 268 if (state.wheelLocked || Math.abs(event.deltaY) < 2) return; 269 state.wheelLocked = true; 270 moveSelectedNumber(event.deltaY > 0 ? 1 : -1); 271 window.setTimeout(() => { 272 state.wheelLocked = false; 273 }, 120); 274 }, { passive: false }); 275 276 numberReel.addEventListener('keydown', (event) => { 277 const keyDirections = { 278 ArrowDown: 1, 279 PageDown: 5, 280 ArrowUp: -1, 281 PageUp: -5, 282 }; 283 284 if (event.key in keyDirections) { 285 event.preventDefault(); 286 moveSelectedNumber(keyDirections[event.key]); 287 } else if (event.key === 'Home') { 288 event.preventDefault(); 289 updateSelectedNumber(0); 290 } else if (event.key === 'End') { 291 event.preventDefault(); 292 updateSelectedNumber(state.poems.length - 1); 293 } 294 }); 295 296 numberReel.addEventListener('pointerdown', (event) => { 297 state.dragStartY = event.clientY; 298 if (numberReel.setPointerCapture) numberReel.setPointerCapture(event.pointerId); 299 }); 300 301 numberReel.addEventListener('pointerup', (event) => { 302 if (state.dragStartY === null) return; 303 const difference = state.dragStartY - event.clientY; 304 state.dragStartY = null; 305 if (Math.abs(difference) >= 20) moveSelectedNumber(difference > 0 ? 1 : -1); 306 }); 307 308 numberSelect.addEventListener('change', () => { 309 updateSelectedNumber(Number(numberSelect.value)); 310 }); 311 312 drawButton.addEventListener('click', drawPoem); 313 app.querySelectorAll('[data-detail-back]').forEach((button) => { 314 button.addEventListener('click', closeDetail); 315 }); 316 317 detailInner.addEventListener('wheel', (event) => { 318 if (window.innerWidth <= 1024 || Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return; 319 event.preventDefault(); 320 detailInner.scrollLeft += event.deltaY; 321 }, { passive: false }); 322 323 document.addEventListener('keydown', (event) => { 324 if (event.key === 'Escape' && detail.classList.contains('is-show')) closeDetail(); 325 }); 326 } 327 328 async function loadPoems() { 329 try { 330 let poems = window.YUELAO_POEMS; 331 332 if (!Array.isArray(poems)) { 333 const response = await fetch('./yuelao-poems.json', { cache: 'no-store' }); 334 if (!response.ok) throw new Error(`HTTP ${response.status}`); 335 poems = await response.json(); 336 } 337 338 if (!Array.isArray(poems) || poems.length !== 100) { 339 throw new Error('ç±¤è©©è³æå¿ é å å« 100 çå §å®¹'); 340 } 341 342 state.poems = poems; 343 createSelectors(); 344 drawButton.disabled = false; 345 status.textContent = ''; 346 } catch (error) { 347 console.error(error); 348 status.textContent = 'ç±¤è©©è³æè¼å ¥å¤±æï¼è«éæ°æ´çé é¢'; 349 } 350 } 351 352 setupSiteMenu(); 353 createStack(); 354 bindEvents(); 355 loadPoems(); 356 startInitialAnimation(); 357})();
Line numbers count LF bytes from the start of the resource, as the search results do. Vendor segments are library code the classifier recognised; they are stored but not indexed. Bytes are shown as Latin1 characters, one per byte.