PageSourceSearch

https://mingyue.org.tw/moonpoetry/moonpoetry.js?v=20260910-1

js mingyue.org.tw collected 2026-10-07 10:42:47 UTC 12,050 bytes, 357 lines download raw bytes

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.