PageSourceSearch

https://yosuke4061.com/new_toppage/js/main.js

js yosuke4061.com collected 2026-09-25 14:51:00 UTC 59,437 bytes, 1,609 lines download raw bytes

1/* =====================================================
2   Midori Photo Art - メインJavaScript
3   ===================================================== */
4
5// ===================
6// BASE_PATH 自動解決 (404エラー防止)
7// ===================
8if (typeof window !== 'undefined' && window.BASE_PATH === undefined) {
9    const isSubdir = window.location.pathname.includes('/new_toppage/');
10    window.BASE_PATH = isSubdir ? '' : 'new_toppage/';
11}
12
13// ===================
14// グローバル変数
15// ===================
16let articlesData = [];
17let currentPage = 1;
18const articlesPerPage = 20;
19let searchTerm = '';
20let activeTag = '';
21const randomArticlesState = {
22    pool: [],
23    visibleIndices: []
24};
25const newestArticleIds = new Set();
26const cardHeightCache = new Map();
27
28/** 静的 index 記事は contentFile へ、従来は article.html?id= へ */
29function getArticleReadUrl(article) {
30    if (!article) return `${window.BASE_PATH || ''}article.html?id=`;
31    const basePath = window.BASE_PATH || '';
32    const normalized = basePath.endsWith('/') ? basePath 
32: (basePath ? `${basePath}/` : '');
33    const cf = article.contentFile || '';
34    if (cf && /\.html$/i.test(cf) && !/^https?:\/\//i.test(cf)) {
35        if (cf.startsWith('/')) return cf;
36        if (cf.startsWith('../')) return cf;
37        return normalized + cf.replace(/^\//, '');
38    }
39    return `${normalized}article.html?id=${encodeURIComponent(article.id)}`;
40}
41
42function shouldShowFutureArticles() {
43    if (typeof window === 'undefined') {
44        return false;
45    }
46
47    if (window.SHOW_FUTURE_ARTICLES === true) {
48        return true;
49    }
50
51    const protocol = window.location?.protocol || '';
52    if (protocol === 'file:') {
53        return true;
54    }
55
56    const hostname = (window.location?.hostname || '').toLowerCase();
57    if (!hostname) {
58        return true;
59    }
60
61    if (['localhost', '127.0.0.1', '::1', '0.0.0.0'].includes(hostname)) {
62        return true;
63    }
64
65    if (hostname.endsWith('.local') || hostname.endsWith('.test')) {
66        return true;
67    }
68
69    if (/^192\.168\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
70        return true;
71    }
72
73    if (/^10\.\d{1,3}\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
74        return true;
75    }
76
77    if (/^172\.(1[6-9]|2\d|3[0-1])\.\d{1,3}\.\d{1,3}$/.test(hostname)) {
78        return true;
79    }
80
81    return false;
82}
83
84// ===================
85// 初期化
86// ===================
87document.addEventListener('DOMContentLoaded', async () => {
88    console.log('サイト初期化開始');
89
90    document.body.classList.add('hero-active');
91    setTimeout(() => {
92        document.body.classList.remove('hero-active');
93        console.log('ヒーローアニメーション完了、スクロール有効化');
94    }, 4000);
95
96    initializeNav();
97    initializeSearch();
98    initializeScrollEffects();
99
100    const articlesReady = loadArticlesData();
101    const extraReady = Promise.allSettled([
102        loadBriefings(),
103        loadPowerWords(),
104        articlesReady.then(() => displayRandomArticles())
105    ]);
106
107    await articlesReady;
108    renderPopularTags();
109    displayArticles();
110    await extraReady;
111
112    console.log('サイト初期化完了');
113});
114
115// ===================
116// データ読み込み
117// ===================
118async function loadArticlesData() {
119    try {
120        // ベースパスを取得(ルートから実行される場合に対応)
121        const basePath = window.BASE_PATH || '';
122        const cacheBuster = `_=${Date.now()}`;
123        const legacyUrl = `${basePath}data/articles.json?${cacheBuster}`;
124        const interactiveUrl = `${basePath}data/interactive-articles.json?${cacheBuster}`;
125
126        const [legacyRes, interactiveRes] = await Promise.all([
127            fetch(legacyUrl, { cache: 'no-store' }),
128            fetch(interactiveUrl, { cache: 'no-store' })
129        ]);
130
131        if (!legacyRes.ok) {
132            throw new Error('記事データ(articles.json)の読み込みに失敗しました');
133        }
134        const legacyData = await legacyRes.json();
135        let legacyArticles = Array.isArray(legacyData.articles) ? legacyData.articles : [];
136
137        let interactiveArticles = [];
138        if (interactiveRes.ok) {
139            try {
140                const intData = await interactiveRes.json();
141                interactiveArticles = Array.isArray(intData.articles) ? intData.articles : [];
142            } catch (e) {
143                console.warn('interactive-articles.json の解析に失敗:', e);
144            }
145        }
146
147        const includeFutureArticles = shouldShowFutureArticles();
148        const filterFuture = (arr) => {
149            if (includeFutureArticles) return [...arr];
150            const today = new Date();
151            today.setHours(0, 0, 0, 0);
152            return arr.filter((article) => {
153                const articleDate = new Date(article.date);
154                articleDate.setHours(0, 0, 0, 0);
155                return articleDate <= today;
156            });
157        };
158
159        let a = filterFuture(interactiveArticles);
160        let b = filterFuture(legacyArticles);
161        const interactiveIds = new Set(a.map((x) => x.id).filter(Boolean));
162        b = b.filter((x) => x && x.id && !interactiveIds.has(x.id));
163
164        articlesData = [...a, ...b];
165        articlesData.sort((x, y) => {
166            const dx = new Date(x.date || 0).getTime();
167            const dy = new Date(y.date || 0).getTime();
168            if (dy !== dx) return dy - dx;
169            return String(y.id || '').localeCompare(String(x.id || ''), 'ja');
170        });
171
172        // 最新記事上位3件を記録
173        newestArticleIds.clear();
174        articlesData.slice(0, 3).forEach(article => {
175            if (article && article.id !== undefined && article.id !== null) {
176                newestArticleIds.add(article.id);
177            }
178        });
179        
180        console.log(
181            includeFutureArticles
182                ? `${articlesData.length}件の記事を読み込みました(日付新しい順・未来含む)`
183                : `${articlesData.length}件の記事を読み込みました(日付新しい順)`
184        );
185    } catch (error) {
186        console.error('記事データ読み込みエラー:', error);
187        displayError('記事データの読み込みに失敗しました。しばらく経ってから再度お試しください。');
188    }
189}
190
191// ===================
192// ナビゲーション
193// ===================
194function initializeNav() {
195    const navToggle = document.querySelector('.nav-toggle');
196    const navMenu = document.querySelector('.nav-menu');
197    const nav = document.querySelector('.main-nav');
198    
199    if (navToggle && navMenu) {
200        navToggle.addEventListener('click', () => {
201            navMenu.classList.toggle('active');
202            
203            // アニメーション
204            const spans = navToggle.querySelectorAll('span');
205            if (navMenu.classList.contains('active')) {
206                spans[0].style.transform = 'rotate(45deg) translate(5px, 5px)';
207                spans[1].style.opacity = '0';
208                spans[2].style.transform = 'rotate(-45deg) translate(7px, -6px)';
209            } else {
210                spans[0].style.transform = '';
211                spans[1].style.opacity = '';
212                spans[2].style.transform = '';
213            }
214        });
215        
216        // メニューリンククリックで閉じる
217        navMenu.querySelectorAll('.nav-link').forEach(link => {
218            link.addEventListener('click', () => {
219                navMenu.classList.remove('active');
220                const spans = navToggle.querySelectorAll('span');
221                spans[0].style.transform = '';
222                spans[1].style.opacity = '';
223                spans[2].style.transform = '';
224            });
225        });
226    }
227
228    if (!nav) return;
229    const mq = window.matchMedia('(min-width: 769px)');
230    const IDLE_MS = 3000;
231    let hideTimer = 0;
232    let lastScrollY = window.scrollY;
233
234    function isPc() { return mq.matches; }
235
236    function showNav() {
237        nav.classList.remove('main-nav--compact');
238        scheduleHide();
239    }
240
241    function scheduleHide() {
242        clearTimeout(hideTimer);
243        if (!isPc()) {
244            nav.classList.remove('main-nav--compact');
245            return;
246        }
247        hideTimer = setTimeout(() => {
248            if (nav.matches(':hover') || nav.contains(document.activeElement)) return;
249            if (navMenu && navMenu.classList.contains('active')) return;
250            nav.classList.add('main-nav--compact');
251        }, IDLE_MS);
252    }
253
254    nav.addEventListener('mouseenter', showNav);
255    nav.addEventListener('mouseleave', scheduleHide);
256    nav.addEventListener('focusin', showNav);
257    document.addEventListener('mousemove', (e) => {
258        if (isPc() && e.clientY < 72) showNav();
259    });
260    window.addEventListener('scroll', () => {
261        const y = window.scrollY;
262        if (y + 10 < lastScrollY) showNav();
263        lastScrollY = y;
264    }, { passive: true });
265    mq.addEventListener('change', scheduleHide);
266    scheduleHide();
267}
268
269// ===================
270// 検索機能
271// ===================
272function initializeSearch() {
273    const searchInput = document.getElementById('articleSearchInput');
274    const searchBtn = document.getElementById('articleSearchButton');
275    
276    if (searchInput) {
277        // リアルタイム検索
278        searchInput.addEventListener('input', debounce((e) => {
279            searchTerm = e.target.value.toLowerCase();
280            currentPage = 1;
281            displayArticles();
282            displayRandomArticles();
283        }, 300));
284        
285        // Enterキーで検索
286        searchInput.addEventListener('keypress', (e) => {
287            if (e.key === 'Enter') {
288                searchTerm = e.target.value.toLowerCase();
289                currentPage = 1;
290                displayArticles();
291                displayRandomArticles();
292            }
293        });
294    }
295    
296    if (searchBtn) {
297        searchBtn.addEventListener('click', () => {
298            if (searchInput) {
299                searchTerm = searchInput.value.toLowerCase();
300                currentPage = 1;
301                displayArticles();
302                displayRandomArticles();
303            }
304        });
305    }
306}
307
308
309// ===================
310// 人気タグ
311// ===================
312function renderPopularTags() {
313    const container = document.getElementById('popularTags');
314    if (!container) return;
315    
316    const tagCounts = new Map();
317    articlesData.forEach(article => {
318        if (!Array.isArray(article.tags)) return;
319        article.tags.forEach(tag => {
320            const trimmed = (tag || '').trim();
321            if (!trimmed) return;
322            tagCounts.set(trimmed, (tagCounts.get(trimmed) || 0) + 1);
323        });
324    });
325    
326    const popularEntries = [...tagCounts.entries()]
327        .sort((a, b) => {
328            if (b[1] !== a[1]) {
329                return b[1] - a[1];
330            }
331            return a[0].localeCompare(b[0], 'ja');
332        })
333        .slice(0, 15);
334    
335    if (popularEntries.length === 0) {
336        container.innerHTML = '';
337        container.style.display = 'none';
338        return;
339    }
340    
341    container.style.display = 'flex';
342    
343    const buttonsHtml = [
344        `<button class="popular-tag" data-tag="">すべて</button>`,
345        ...popularEntries.map(([tag, count]) => {
346            const encodedTag = encodeURIComponent(tag);
347            const safeTag = escapeHtml(tag);
348            return `<button class="popular-tag" data-tag="${encodedTag}">
349                ${safeTag}<span class="popular-tag__count">${count}</span>
350            </button>`;
351        })
352    ].join('');
353    
354    container.innerHTML = buttonsHtml;
355    
356    container.querySelectorAll('.popular-tag').forEach(button => {
357        button.addEventListener('click', () => {
358            const encodedValue = button.dataset.tag || '';
359            const tagValue = encodedValue ? decodeURIComponent(encodedValue) : '';
360            activeTag = activeTag === tagValue ? '' : tagValue;
361            currentPage = 1;
362            displayArticles();
363            displayRandomArticles();
364        });
365    });
366    
367    updatePopularTagsActiveState();
368}
369
370function updatePopularTagsActiveState() {
371    const container = document.getElementById('popularTags');
372    if (!container) return;
373    
374    container.querySelectorAll('.popular-tag').forEach(button => {
375        const encodedValue = button.dataset.tag || '';
376        const tagValue = encodedValue ? decodeURIComponent(encodedValue) : '';
377        const isActive = activeTag ? tagValue === activeTag : encodedValue === '';
378        button.classList.toggle('popular-tag--active', isActive);
379        button.setAttribute('aria-pressed', isActive ? 'true' : 'false');
380    });
381}
382
383
384// ===================
385// 記事表示
386// ===================
387function displayArticles() {
388    const articlesGrid = document.getElementById('articlesGrid');
389    if (!articlesGrid) return;
390    
391    // 検索を適用
392    let filteredArticles = articlesData;
393    
394    if (activeTag) {
395        const normalizedTag = activeTag.toLowerCase();
396        filteredArticles = filteredArticles.filter(article =>
397            Array.isArray(article.tags) && article.tags.some(tag => tag.toLowerCase() === normalizedTag)
398        );
399    }
400    
401    if (searchTerm) {
402        filteredArticles = filteredArticles.filter(article => {
403            return article.title.toLowerCase().includes(searchTerm) ||
404                   article.description.toLowerCase().includes(searchTerm) ||
405                   (article.tags && article.tags.some(tag => 
406                       tag.toLowerCase().includes(searchTerm)
407                   ));
408        });
409    }
410    
411    // 記事数を表示
412    updateArticleCount(filteredArticles.length);
413    updatePopularTagsActiveState();
414    
415    if (filteredArticles.length === 0) {
416        articlesGrid.innerHTML = '<div class="loading">該当する記事が見つかりませんでした</div>';
417        document.getElementById('pagination').innerHTML = '';
418        return;
419    }
420    
421    // ページネーション
422    const totalPages = Math.ceil(filteredArticles.length / articlesPerPage);
423    if (currentPage > totalPages) {
424        currentPage = totalPages;
425    }
426    const startIndex = (currentPage - 1) * articlesPerPage;
427    const endIndex = startIndex + articlesPerPage;
428    const displayedArticles = filteredArticles.slice(startIndex, endIndex);
429    
430    // 記事カード生成
431    articlesGrid.innerHTML = displayedArticles.map(article => createArticleCard(article)).join('');
432    paintCardBars(articlesGrid);
433    
434    // ページネーション生成
435    createPagination(totalPages);
436
437    normalizeArticleCardHeights();
438}
439
440// ===================
441// 記事カード生成
442// ===================
443const CARD_DATE_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect><line x1="16" y1="2" x2="16" y2="6"></line><line x1="8" y1="2" x2="8" y2="6"></line><line x1="3" y1="10" x2="21" y2="10"></line></svg>';
444const CARD_TIME_SVG = '<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><circle cx="12" cy="12" r="10"></circle><polyline points="12 6 12 12 16 14"></polyline></svg>';
445
446function createBriefingStyleCard(item) {
447    const tagsHtml = (item.tags || []).slice(0, 5).map((tag) =>
448        `<span class="briefing-card__tag">#${escapeHtml(tag)}</span>`
449    ).join('');
450    const readHtml = (item.readTime !== undefined && item.readTime !== null && item.readTime !== '')
451        ? `<span class="briefing-card__read-time">${CARD_TIME_SVG}${escapeHtml(String(item.readTime))}分</span>`
452        : '';
453    const thumb = item.thumb
454        ? `<img src="${escapeHtml(item.thumb)}" alt="${escapeHtml(item.title || '')}" loading="lazy" onerror="this.style.display='none'">`
455        : '';
456    return `<article class="briefing-card">
457        <a href="${escapeHtml(item.href || '#')}" class="briefing-card__link">
458            <div class="briefing-card__thumbnail">${thumb}</div>
459            <div class="briefing-card__content">
460                <div class="briefing-card__meta">
461                    <time class="briefing-card__date">${CARD_DATE_SVG}${escapeHtml(item.date || '')}</time>
462                    ${readHtml}
463                </div>
464                <h3 class="briefing-card__title">${escapeHtml(item.title || '')}</h3>
465                <p class="briefing-card__description">${escapeHtml(item.desc || '')}</p>
466                <div class="briefing-card__tags">${tagsHtml}</div>
467            </div>
468        </a>
469    </article>`;
470}
471
472function createArticleCard(article) {
473    const basePath = window.BASE_PATH || '';
474    return createBriefingStyleCard({
475        href: getArticleReadUrl(article),
476        thumb: article.thumbnail ? basePath + article.thumbnail : '',
477        title: article.title || article.id || '',
478        desc: article.description || '',
479        date: formatDate(article.date),
480        readTime: article.readTime || 5,
481        tags: article.tags || []
482    });
483}
484
485// ===================
486// ページネーション
487// ===================
488function createPagination(totalPages) {
489    const paginationContainer = document.getElementById('pagination');
490    if (!paginationContainer || totalPages <= 1) {
491        paginationContainer.innerHTML = '';
492        return;
493    }
494    
495    let html = '';
496    
497    // 前へボタン
498    html += `<button onclick="changePage(${currentPage - 1})" ${currentPage === 1 ? 'disabled' : ''}>
499        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
500            <path d="M15 18l-6-6 6-6"/>
501        </svg>
502        前へ
503    </button>`;
504    
505    // ページ番号
506    const maxButtons = 5;
507    let startPage = Math.max(1, currentPage - Math.floor(maxButtons / 2));
508    let endPage = Math.min(totalPages, startPage + maxButtons - 1);
509    
510    if (endPage - startPage < maxButtons - 1) {
511        startPage = Math.max(1, endPage - maxButtons + 1);
512    }
513    
514    if (startPage > 1) {
515        html += `<button onclick="changePage(1)">1</button>`;
516        if (startPage > 2) {
517            html += `<span>...</span>`;
518        }
519    }
520    
521    for (let i = startPage; i <= endPage; i++) {
522        html += `<button onclick="changePage(${i})" ${i === currentPage ? 'class="active"' : ''}>${i}</button>`;
523    }
524    
525    if (endPage < totalPages) {
526        if (endPage < totalPages - 1) {
527            html += `<span>...</span>`;
528        }
529        html += `<button onclick="changePage(${totalPages})">${totalPages}</button>`;
530    }
531    
532    // 次へボタン
533    html += `<button onclick="changePage(${currentPage + 1})" ${currentPage === totalPages ? 'disabled' : ''}>
534        次へ
535        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
536            <path d="M9 18l6-6-6-6"/>
537        </svg>
538    </button>`;
539    
540    paginationContainer.innerHTML = html;
541}
542
543function changePage(page) {
544    currentPage = page;
545    displayArticles();
546}
547
548// ===================
549// スクロール効果
550// ===================
551function initializeScrollEffects() {
552    // トップへ戻るボタン
553    const scrollTopBtn = document.getElementById('scrollTop');
554    if (scrollTopBtn) {
555        window.addEventListener('scroll', () => {
556            if (window.scrollY > 500) {
557                scrollTopBtn.classList.add('visible');
558            } else {
559                scrollTopBtn.classList.remove('visible');
560            }
561        });
562        
563        scrollTopBtn.addEventListener('click', () => {
564            window.scrollTo({ top: 0, behavior: 'smooth' });
565        });
566    }
567}
568
569// ===================
570// ユーティリティ関数
571// ===================
572function debounce(func, wait) {
573    let timeout;
574    return function executedFunction(...args) {
575        const later = () => {
576            clearTimeout(timeout);
577            func(...args);
578        };
579        clearTimeout(timeout);
580        timeout = setTimeout(later, wait);
581    };
582}
583
584function formatDate(dateString) {
585    const date = new Date(dateString);
586    const year = date.getFullYear();
587    const month = (date.getMonth() + 1).toString().padStart(2, '0');
588    const day = date.getDate().toString().padStart(2, '0');
589    return `${year}/${month}/${day}`;
590}
591
592function displayError(message) {
593    const articlesGrid = document.getElementById('articlesGrid');
594    if (articlesGrid) {
595        articlesGrid.innerHTML = `
596            <div style="grid-column: 1 / -1; text-align: center; padding: 2rem; color: var(--color-secondary);">
597                <p>${message}</p>
598            </div>
599        `;
600    }
601}
602
603// ===================
604// 記事数の表示を更新
605// ===================
606function updateArticleCount(count) {
607    const countElement = document.getElementById('articleCount');
608    if (!countElement) return;
609    
610    const totalCount = articlesData.length;
611    const hasFilter = (searchTerm && searchTerm.trim() !== '') || !!activeTag;
612    
613    if (hasFilter) {
614        countElement.textContent = `該当${count}件 / 全${totalCount}件`;
615    } else {
616        countElement.textContent = `全${count}件の記事`;
617    }
618}
619
620// ===================
621// ランダム記事表示
622// ===================
623function siteRootPrefix() {
624    return (window.BASE_PATH || '').indexOf('new_toppage') === 0 ? '' : '../';
625}
626function pickRand(arr) {
627    if (!arr || !arr.length) return null;
628    return arr[Math.floor(Math.random() * arr.length)];
629}
630function pickRandN(arr, n) {
631    if (!arr || !arr.length || n <= 0) return [];
632    const copy = arr.slice();
633    for (let i = copy.length - 1; i > 0; i--) {
634        const j = Math.floor(Math.random() * (i + 1));
635        [copy[i], copy[j]] = [copy[j], copy[i]];
636    }
637    return copy.slice(0, Math.min(n, copy.length));
638}
639function dirOfPath(path) {
640    return String(path || '').replace(/index\.html$/i, '').replace(/\/?$/, '/');
641}
642
643async function displayRandomArticles() {
644    const randomContainer = document.getElementById('randomArticles');
645    const randomSection = randomContainer && randomContainer.closest('.random-section');
646    if (!randomContainer || randomContainer.dataset.mixed === '1') return;
647
648    const root = siteRootPrefix();
649    const bp = (window.BASE_PATH || 'new_toppage/').replace(/\/?$/, '/');
650
651    const fetchJson = (url, fallback) => fetch(url, { cache: 'no-store' })
652        .then((r) => r.ok ? r.json() : fallback)
653        .catch(() => fallback);
654
655    const [br, ur, gm, sw] = await Promise.all([
656        fetchJson(bp + 'data/briefings.json', { briefings: [] }),
657        fetchJson(root + 'uranai/data/uranai.json', { omikuji: [] }),
658        fetchJson(root + 'online-free-game/data/games.json', { games: [] }),
659        fetchJson(root + 'online-free-software/data/software.json', { software: [] })
660    ]);
661
662    const items = [];
663    const notFuture = (dateStr) => {
664        if (shouldShowFutureArticles() || !dateStr) return true;
665        const d = new Date(dateStr);
666        if (isNaN(d.getTime())) return true;
667        const today = new Date();
668        today.setHours(0, 0, 0, 0);
669        d.setHours(0, 0, 0, 0);
670        return d <= today;
671    };
672    const briefingPool = (br.briefings || []).filter((b) => notFuture(b.date));
673    const omikujiPool = (ur.omikuji || []).filter((o) => notFuture(o.createdAt));
674    const gamePool = (gm.games || []).filter((g) => notFuture(g.created));
675    const softwarePool = (sw.software || []).filter((s) => notFuture(s.created || s.released || s.updated));
676    const used = {};
677    function pushUnique(item) {
678        if (!item || !item.href || used[item.href] || items.length >= 8) return;
679        used[item.href] = true;
680        items.push(item);
681    }
682    pickRandN(briefingPool, 2).forEach((briefing) => {
683        pushUnique({
684            href: bp + (briefing.path || ''),
685            thumb: briefing.thumbnail ? bp + briefing.thumbnail : '',
686            title: briefing.title || '',
687            desc: briefing.description || '',
688            date: formatDate(briefing.date),
689            readTime: briefing.readTime || 3,
690            tags: briefing.tags || []
691        });
692    });
693    pickRandN(articlesData || [], 2).forEach((art) => {
694        pushUnique({
695            href: getArticleReadUrl(art),
696            thumb: art.thumbnail ? bp + art.thumbnail : '',
697            title: art.title || art.id || '',
698            desc: art.description || '',
699            date: formatDate(art.date),
700            readTime: art.readTime || 5,
701            tags: art.tags || []
702        });
703    });
704    pickRandN(omikujiPool, 1).forEach((omikuji) => {
705        const id = omikuji.id || '';
706        pushUnique({
707            href: root + 'uranai/' + dirOfPath(omikuji.path),
708            thumb: root + 'uranai/' + dirOfPath(omikuji.path) + 'ogp.png',
709            title: omikuji.title || id,
710            desc: omikuji.description || (omikuji.tags || []).join(' · '),
711            date: formatDate(omikuji.createdAt),
712            readTime: '',
713            tags: omikuji.tags || []
714        });
715    });
716    pickRandN(gamePool, 2).forEach((game) => {
717        const dir = root + 'online-free-game/' + dirOfPath(game.path);
718        pushUnique({
719            href: dir,
720            thumb: dir + 'ogp.png',
721            title: game.title || game.id || '',
722            desc: (game.tags || []).filter((t) => t && t !== '·').join(' · '),
723            date: formatDate(game.created),
724            readTime: '',
725            tags: game.tags || []
726        });
727    });
728    pickRandN(softwarePool, 1).forEach((tool) => {
729        const dir = root + 'online-free-software/' + dirOfPath(tool.path);
730        const tags = (tool.tags || []).filter((t) => t && t !== '·');
731        pushUnique({
732            href: dir,
733            thumb: tool.logo
734                ? root + 'online-free-software/' + String(tool.logo).replace(/^\//, '')
735                : (dir + 'logo.png'),
736            title: tool.title || tool.id || '',
737            desc: tool.description || tags.join(' · '),
738            date: formatDate(tool.created || tool.released || tool.updated),
739            readTime: '',
740            tags: tags
741        });
742    });
743    const leftovers = []
744        .concat(pickRandN(briefingPool, 12).map((briefing) => ({
745            href: bp + (briefing.path || ''),
746            thumb: briefing.thumbnail ? bp + briefing.thumbnail : '',
747            title: briefing.title || '',
748            desc: briefing.description || '',
749            date: formatDate(briefing.date),
750            readTime: briefing.readTime || 3,
751            tags: briefing.tags || []
752        })))
753        .concat(pickRandN(articlesData || [], 12).map((art) => ({
754            href: getArticleReadUrl(art),
755            thumb: art.thumbnail ? bp + art.thumbnail : '',
756            title: art.title || art.id || '',
757            desc: art.description || '',
758            date: formatDate(art.date),
759            readTime: art.readTime || 5,
760            tags: art.tags || []
761        })))
762        .concat(pickRandN(omikujiPool, 8).map((omikuji) => ({
763            href: root + 'uranai/' + dirOfPath(omikuji.path),
764            thumb: root + 'uranai/' + dirOfPath(omikuji.path) + 'ogp.png',
765            title: omikuji.title || omikuji.id || '',
766            desc: omikuji.description || (omikuji.tags || []).join(' · '),
767            date: formatDate(omikuji.createdAt),
768            readTime: '',
769            tags: omikuji.tags || []
770        })))
771        .concat(pickRandN(gamePool, 8).map((game) => {
772            const dir = root + 'online-free-game/' + dirOfPath(game.path);
773            return {
774                href: dir,
775                thumb: dir + 'ogp.png',
776                title: game.title || game.id || '',
777                desc: (game.tags || []).filter((t) => t && t !== '·').join(' · '),
778                date: formatDate(game.created),
779                readTime: '',
780                tags: game.tags || []
781            };
782        }))
783        .concat(pickRandN(softwarePool, 8).map((tool) => {
784            const dir = root + 'online-free-software/' + dirOfPath(tool.path);
785            const tags = (tool.tags || []).filter((t) => t && t !== '·');
786            return {
787                href: dir,
788                thumb: tool.logo
789                    ? root + 'online-free-software/' + String(tool.logo).replace(/^\//, '')
790                    : (dir + 'logo.png'),
791                title: tool.title || tool.id || '',
792                desc: tool.description || tags.join(' · '),
793                date: formatDate(tool.created || tool.released || tool.updated),
794                readTime: '',
795                tags: tags
796            };
797        }));
798    leftovers.sort(() => Math.random() - 0.5);
799    leftovers.forEach(pushUnique);
800
801    items.sort(() => Math.random() - 0.5);
802    if (randomSection) randomSection.style.display = items.length ? 'block' : 'none';
803    if (!items.length) return;
804    randomContainer.dataset.mixed = '1';
805    randomContainer.innerHTML = items.map(createBriefingStyleCard).join('');
806    paintCardBars(randomContainer);
807}
808
809// ===================
810// 動画再生モーダル
811// ===================
812function playVideo(videoUrl, title) {
813    // モーダルを作成
814    const modal = document.createElement('div');
815    modal.id = 'videoModal';
816    modal.style.cssText = `
817        position: fixed;
818        top: 0;
819        left: 0;
820        width: 100%;
821        height: 100%;
822        background: rgba(0, 0, 0, 0.9);
823        display: flex;
824        align-items: center;
825        justify-content: center;
826        z-index: 10000;
827        animation: fadeIn 0.3s ease;
828    `;
829    
830    modal.innerHTML = `
831        <div style="position: relative; width: 90%; max-width: 1200px; background: #1a1a1a; border-radius: 12px; overflow: hidden; box-shadow: 0 20px 60px rgba(0, 0, 0, 0.5);">
832            <div style="padding: 1.5rem; background: #2c3e50; color: white; display: flex; justify-content: space-between; align-items: center;">
833                <h3 style="margin: 0; font-size: 1.2rem;">${title}</h3>
834                <button onclick="closeVideoModal()" style="background: none; border: none; color: white; font-size: 2rem; cursor: pointer; padding: 0; width: 40px; height: 40px; display: flex; align-items: center; justify-content: center; border-radius: 50%; transition: all 0.3s ease;">
835                    ×
836                </button>
837            </div>
838            <div style="padding: 0; background: black;">
839                <video controls autoplay style="width: 100%; height: auto; display: block; max-height: 70vh;">
840                    <source src="${videoUrl}" type="video/mp4">
841                    お使いのブラウザは動画タグをサポートしていません。
842                </video>
843            </div>
844        </div>
845    `;
846    
847    // モーダル外をクリックで閉じる
848    modal.addEventListener('click', (e) => {
849        if (e.target === modal) {
850            closeVideoModal();
851        }
852    });
853    
854    // ESCキーで閉じる
855    document.addEventListener('keydown', function escHandler(e) {
856        if (e.key === 'Escape') {
857            closeVideoModal();
858            document.removeEventListener('keydown', escHandler);
859        }
860    });
861    
862    document.body.appendChild(modal);
863}
864
865function closeVideoModal() {
866    const modal = document.getElementById('videoModal');
867    if (modal) {
868        modal.style.animation = 'fadeOut 0.3s ease';
869        setTimeout(() => modal.remove(), 300);
870    }
871}
872
873// アニメーション
874const style = document.createElement('style');
875style.textContent = `
876    @keyframes fadeIn {
877        from { opacity: 0; }
878        to { opacity: 1; }
879    }
880    @keyframes fadeOut {
881        from { opacity: 1; }
882        to { opacity: 0; }
883    }
884`;
885document.head.appendChild(style);
886
887// ===================
888// 動画再生・停止のエラーハンドリング
889// ===================
890function handleVideoPlay(videoElement) {
891    if (!videoElement) return;
892    
893    const playPromise = videoElement.play();
894    
895    if (playPromise !== undefined) {
896        playPromise.catch(error => {
897            // AbortErrorやその他のエラーを静かに処理
898            // ユーザー体験を損なわないよう、コンソールログは出さない
899        });
900    }
901}
902
903function handleVideoPause(videoElement) {
904    if (!videoElement) return;
905    
906    try {
907        videoElement.pause();
908        videoElement.currentTime = 0.1;
909    } catch (error) {
910        // エラーを静かに処理
911    }
912}
913
914function handleRandomImageError(imgElement) {
915    if (!imgElement) return;
916    const basePath = window.BASE_PATH || '';
917    imgElement.onerror = null;
918    imgElement.src = `${basePath}assets/images/default-thumbnail.svg`;
919}
920
921function handleRandomVideoError(videoElement) {
922    if (!videoElement) return;
923    const fallback = videoElement.dataset.fallback || `${window.BASE_PATH || ''}assets/images/default-thumbnail.svg`;
924    const altText = videoElement.dataset.alt || '';
925    
926    const img = document.createElement('img');
927    img.src = fallback;
928    img.alt = altText;
929    img.className = videoElement.className;
930    img.loading = 'lazy';
931    img.onerror = () => handleRandomImageError(img);
932    
933    videoElement.replaceWith(img);
934}
935
936function renderRandomArticleCards() {
937    const randomContainer = document.getElementById('randomArticles');
938    if (!randomContainer) return;
939    
940    const cardsHtml = randomArticlesState.visibleIndices.map((poolIndex, cardIndex) => {
941        const article = randomArticlesState.pool[poolIndex];
942        return createRandomArticleCard(article, cardIndex);
943    }).join('');
944    
945    randomContainer.innerHTML = cardsHtml;
946    attachRandomNavigationHandlers();
947    
948    console.log(`${randomArticlesState.visibleIndices.length}件のランダム記事カードを描画しました(プール:${randomArticlesState.pool.length}件)`);
949
950    normalizeArticleCardHeights();
951}
952
953function createRandomArticleCard(article, cardIndex) {
954    if (!article) return '';
955    
956    const basePath = window.BASE_PATH || '';
957    const articleUrl = getArticleReadUrl(article);
958    const fallbackThumbnail = `${basePath}assets/images/default-thumbnail.svg`;
959    let thumbnailHtml = '';
960    const safeTitle = escapeHtml(article.title);
961    
962    if (article.videoUrl) {
963        const videoUrl = basePath + article.videoUrl;
964        const posterUrl = article.thumbnail ? basePath + article.thumbnail : fallbackThumbnail;
965        thumbnailHtml = `<video src="${videoUrl}"
966                class="article-card__thumbnail article-card__video"
967                data-alt="${safeTitle}"
968                poster="${posterUrl}"
969                muted
970                loop
971                playsinline
972                preload="metadata"
973                onloadeddata="this.currentTime=0.1"
974                onmouseover="handleVideoPlay(this)"
975                onmouseout="handleVideoPause(this)"
976                style="object-fit: cover; background: #000;"
977                data-fallback="${fallbackThumbnail}"
978                onerror="handleRandomVideoError(this)"></video>`;
979    } else if (article.thumbnail) {
980        const thumbnailUrl = basePath + article.thumbnail;
981        thumbnailHtml = `<img src="${thumbnailUrl}"
982                alt="${safeTitle}"
983                class="article-card__thumbnail"
984                loading="lazy"
985                onerror="handleRandomImageError(this)"/>`;
986    } else {
987        thumbnailHtml = `<img src="${fallbackThumbnail}"
988                alt="${safeTitle}"
989                class="article-card__thumbnail"
990                loading="lazy">`;
991    }
992    
993    const tagsHtml = article.tags
994        ? article.tags.map(tag => `<span class="article-card__tag">#${escapeHtml(tag)}</span>`).join('')
995        : '';
996    
997    return `
998        <div class="article-card" data-card-index="${cardIndex}">
999            ${thumbnailHtml}
1000            <div class="article-card__content">
1001                <div class="article-card__meta">
1002                    <span class="article-card__date">
1003                        <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1004                            <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
1005                            <line x1="16" y1="2" x2="16" y2="6"></line>
1006                            <line x1="8" y1="2" x2="8" y2="6"></line>
1007                            <line x1="3" y1="10" x2="21" y2="10"></line>
1008                        </svg>
1009                        ${formatDate(article.date)}
1010                    </span>
1011                </div>
1012                <h3 class="article-card__title">${safeTitle}</h3>
1013                <p class="article-card__description">${escapeHtml(article.description || '')}</p>
1014                <div class="article-card__tags">${tagsHtml}</div>
1015            </div>
1016            <div class="random-card__nav" role="group" aria-label="おすすめ記事の切り替え">
1017                <button type="button"
1018                        class="random-card__btn random-card__btn--prev"
1019                        data-random-card="${cardIndex}"
1020                        data-direction="prev"
1021                        aria-label="前の記事へ">
1022                    ← 前
1023                </button>
1024                <a href="${articleUrl}" class="random-card__btn random-card__btn--primary">
1025                    読む
1026                </a>
1027                <button type="button"
1028                        class="random-card__btn random-card__btn--next"
1029                        data-random-card="${cardIndex}"
1030                        data-direction="next"
1031                        aria-label="次の記事へ">
1032                    次 →
1033                </button>
1034            </div>
1035        </div>
1036    `;
1037}
1038
1039function attachRandomNavigationHandlers() {
1040    document.querySelectorAll('.random-card__btn').forEach(button => {
1041        button.addEventListener('click', (event) => {
1042            const target = event.currentTarget;
1043            const cardIndex = Number(target.dataset.randomCard);
1044            const direction = target.dataset.direction === 'prev' ? 'prev' : 'next';
1045            handleRandomNavigation(cardIndex, direction);
1046        });
1047    });
1048}
1049
1050function handleRandomNavigation(cardIndex, direction) {
1051    const poolLength = randomArticlesState.pool.length;
1052    if (!Number.isInteger(cardIndex) || poolLength === 0) return;
1053    
1054    const currentIndex = randomArticlesState.visibleIndices[cardIndex];
1055    if (currentIndex === undefined) return;
1056    
1057    if (poolLength === 1) {
1058        console.log('ランダム記事: プールが1件のため切り替えなし');
1059        return;
1060    }
1061    
1062    const step = direction === 'prev' ? -1 : 1;
1063    let nextIndex = currentIndex;
1064    
1065    for (let offset = 1; offset <= poolLength; offset++) {
1066        const candidate = (currentIndex + step * offset + poolLength) % poolLength;
1067        
1068        if (!randomArticlesState.visibleIndices.some((value, idx) => idx !== cardIndex && value === candidate) ||
1069            poolLength <= randomArticlesState.visibleIndices.length) {
1070            nextIndex = candidate;
1071            break;
1072        }
1073    }
1074    
1075    if (nextIndex === currentIndex) {
1076        console.log('ランダム記事: 代替候補が見つからず切り替えなし');
1077        return;
1078    }
1079    
1080    randomArticlesState.visibleIndices[cardIndex] = nextIndex;
1081    updateRandomCard(cardIndex);
1082}
1083
1084function updateRandomCard(cardIndex) {
1085    const randomContainer = document.getElementById('randomArticles');
1086    if (!randomContainer) return;
1087    
1088    const poolIndex = randomArticlesState.visibleIndices[cardIndex];
1089    const article = randomArticlesState.pool[poolIndex];
1090    const currentCard = randomContainer.querySelector(`.article-card[data-card-index="${cardIndex}"]`);
1091    
1092    if (!article || !currentCard) return;
1093    
1094    currentCard.outerHTML = createRandomArticleCard(article, cardIndex);
1095    attachRandomNavigationHandlers();
1096    normalizeArticleCardHeights();
1097}
1098
1099function escapeHtml(str) {
1100    if (str === null || str === undefined) return '';
1101    return String(str)
1102        .replace(/&/g, '&amp;')
1103        .replace(/</g, '&lt;')
1104        .replace(/>/g, '&gt;')
1105        .replace(/"/g, '&quot;')
1106        .replace(/'/g, '&#39;');
1107}
1108
1109function normalizeArticleCardHeights(options = {}) {
1110    const { withDelay = true } = options;
1111
1112    window.requestAnimationFrame(() => {
1113        const selectors = ['#articlesGrid .article-card', '#randomArticles .article-card'];
1114
1115        selectors.forEach(selector => {
1116            const cards = document.querySelectorAll(selector);
1117            if (!cards.length) return;
1118
1119            const previousHeight = cardHeightCache.get(selector);
1120
1121            cards.forEach(card => {
1122                card.style.minHeight = '';
1123                card.style.height = '';
1124            });
1125
1126            let maxHeight = 0;
1127            cards.forEach(card => {
1128                const height = card.getBoundingClientRect().height;
1129                if (height > maxHeight) {
1130                    maxHeight = height;
1131                }
1132            });
1133
1134            if (!maxHeight) return;
1135
1136            const normalizedHeight = Math.ceil(maxHeight);
1137            if (previousHeight === normalizedHeight) {
1138                cards.forEach(card => {
1139                    card.style.minHeight = `${normalizedHeight}px`;
1140                    card.style.height = `${normalizedHeight}px`;
1141                });
1142                return;
1143            }
1144
1145            cardHeightCache.set(selector, normalizedHeight);
1146
1147            cards.forEach(card => {
1148                card.style.minHeight = `${normalizedHeight}px`;
1149                card.style.height = `${normalizedHeight}px`;
1150            });
1151        });
1152    });
1153
1154    if (withDelay) {
1155        setTimeout(() => normalizeArticleCardHeights({ withDelay: false }), 400);
1156    }
1157}
1158
1159window.addEventListener('resize', debounce(() => {
1160    normalizeArticleCardHeights();
1161}, 200));
1162
1163window.addEventListener('load', () => {
1164    normalizeArticleCardHeights({ withDelay: false });
1165});
1166
1167// ===================
1168// Tech Briefing読み込み
1169// ===================
1170async function loadBriefings() {
1171    const grid = document.getElementById('briefingsGrid');
1172    if (!grid) {
1173        // briefingsGridが存在しないページ(個別記事ページなど)では何もしない
1174        return;
1175    }
1176    
1177    // 既にカードがレンダリング済みでも、未来日付は本番では外す
1178    if (grid.querySelector('.briefing-card')) {
1179        if (!shouldShowFutureArticles()) {
1180            const today = new Date();
1181            today.setHours(0, 0, 0, 0);
1182            grid.querySelectorAll('.briefing-card').forEach((card) => {
1183                const t = card.querySelector('time[datetime]');
1184                const raw = t && t.getAttribute('datetime');
1185                if (!raw) return;
1186                const d = new Date(raw);
1187                d.setHours(0, 0, 0, 0);
1188                if (d > today) card.remove();
1189            });
1190        }
1191        console.log('Tech Briefing記事は既にサーバーサイドでレンダリング済みです');
1192        paintCardBars(grid);
1193        return;
1194    }
1195    
1196    try {
1197        const basePath = window.BASE_PATH || 'new_toppage/';
1198        const dataUrl = `${basePath}data/briefings.json`;
1199        const cacheBuster = `_=${Date.now()}`;
1200        const fullUrl = `${dataUrl}?${cacheBuster}`;
1201        
1202        console.log('Tech Briefingデータを読み込み中:', fullUrl);
1203        
1204        const response = await fetch(fullUrl, { cache: 'no-store' });
1205        
1206        if (!response.ok) {
1207            throw new Error(`HTTPエラー: ${response.status} ${response.statusText}`);
1208        }
1209        
1210        const data = await response.json();
1211        let briefings = data.briefings || [];
1212        
1213        if (!Array.isArray(briefings)) {
1214            throw new Error('briefingsデータが配列ではありません');
1215        }
1216
1217        if (!shouldShowFutureArticles()) {
1218            const today = new Date();
1219            today.setHours(0, 0, 0, 0);
1220            briefings = briefings.filter((b) => {
1221                if (!b.date) return true;
1222                const d = new Date(b.date);
1223                d.setHours(0, 0, 0, 0);
1224                return d <= today;
1225            });
1226        }
1227        
1228        briefings.sort((a, b) => {
1229            const dateA = new Date(a.date || '1900-01-01');
1230            const dateB = new Date(b.date || '1900-01-01');
1231            return dateB - dateA;
1232        });
1233        
1234        const displayBriefings = briefings.slice(0, 20);
1235        
1236        if (displayBriefings.length === 0) {
1237            grid.innerHTML = '<p class="briefings-empty">
1237Tech Briefing記事はまだありません。</p>';
1238            return;
1239        }
1240        
1241        grid.innerHTML = displayBriefings.map(briefing => {
1242            const basePathForUrl = basePath.replace(/\/$/, '');
1243            const thumbnailUrl = `${basePathForUrl}/${briefing.thumbnail}`;
1244            const briefingUrl = `${basePathForUrl}/${briefing.path}`;
1245            const date = new Date(briefing.date);
1246            const formattedDate = `${date.getFullYear()}/${String(date.getMonth() + 1).padStart(2, '0')}/${String(date.getDate()).padStart(2, '0')}`;
1247            
1248            const tagsHtml = (briefing.tags || []).slice(0, 5).map(tag => 
1249                `<span class="briefing-card__tag">#${tag}</span>`
1250            ).join('');
1251            
1252            return `
1253                <article class="briefing-card">
1254                    <a href="${briefingUrl}" class="briefing-card__link">
1255                        <div class="briefing-card__thumbnail">
1256                            <img src="${thumbnailUrl}" 
1257                                 alt="${briefing.title || ''}" 
1258                                 loading="lazy"
1259                                 onerror="this.style.display='none';">
1260                        </div>
1261                        <div class="briefing-card__content">
1262                            <div class="briefing-card__meta">
1263                                <time class="briefing-card__date" datetime="${briefing.date || ''}">
1264                                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1265                                        <rect x="3" y="4" width="18" height="18" rx="2" ry="2"></rect>
1266                                        <line x1="16" y1="2" x2="16" y2="6"></line>
1267                                        <line x1="8" y1="2" x2="8" y2="6"></line>
1268                                        <line x1="3" y1="10" x2="21" y2="10"></line>
1269                                    </svg>
1270                                    ${formattedDate}
1271                                </time>
1272                                <span class="briefing-card__read-time">
1273                                    <svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1274                                        <circle cx="12" cy="12" r="10"></circle>
1275                                        <polyline points="12 6 12 12 16 14"></polyline>
1276                                    </svg>
1277                                    ${briefing.readTime || 3}分
1278                                </span>
1279                            </div>
1280                            <h3 class="briefing-card__title">${briefing.title || 'タイトル未設定'}</h3>
1281                            <p class="briefing-card__description">${briefing.description || ''}</p>
1282                            <div class="briefing-card__tags">
1283                                ${tagsHtml}
1284                            </div>
1285                        </div>
1286                    </a>
1287                </article>
1288            `;
1289        }).join('');
1290        
1291        // スタイル適用を確実にするため、強制的に再フローを発生させる
1292        grid.offsetHeight; // リフローを発生させる
1293        
1294        console.log(`${displayBriefings.length}件のTech Briefing記事を表示しました`);
1295        paintCardBars(grid);
1296    } catch (error) {
1297        console.error('Tech Briefing記事の読み込みエラー:', error);
1298        console.error('エラー詳細:', {
1299            message: error.message,
1300            stack: error.stack,
1301            url: `${window.BASE_PATH || 'new_toppage/'}data/briefings.json`
1302        });
1303        
1304        if (grid) {
1305            grid.innerHTML = `<p class="briefings-empty">
1305Tech Briefing記事の読み込みに失敗しました。<br><small style="color: #999;">エラー: ${error.message}</small></p>`;
1306        }
1307    }
1308}
1309
1310// ===================
1311// PowerWord Ocean 最新ワード(ランダムローテーション)
1312// ===================
1313const PW_SLOT_COUNT_DESKTOP = 40;
1314const PW_SLOT_COUNT_MOBILE = 20;
1315const PW_DESKTOP_MQ = window.matchMedia('(min-width: 1024px)');
1316const PW_FADE_MS = 1500;
1317const PW_MIN_STAY_MS = 16500;
1318const PW_MAX_STAY_MS = 39000;
1319
1320function pwRandomStayMs() {
1321    return PW_MIN_STAY_MS + Math.random() * (PW_MAX_STAY_MS - PW_MIN_STAY_MS);
1322}
1323
1324function pwGetSlotCount() {
1325    return PW_DESKTOP_MQ.matches ? PW_SLOT_COUNT_DESKTOP : PW_SLOT_COUNT_MOBILE;
1326}
1327
1328function pwGetSlotTier() {
1329    return PW_DESKTOP_MQ.matches ? 'desktop' : 'mobile';
1330}
1331
1332function pwWordKey(w) {
1333    return w.path || w.word || '';
1334}
1335
1336function pwPickWord(pool, exclude) {
1337    const available = pool.filter((w) => !exclude.has(pwWordKey(w)));
1338    const source = available.length > 0 ? available : pool;
1339    return source[Math.floor(Math.random() * source.length)];
1340}
1341
1342function pwSetSlotWord(link, w, base) {
1343    link.href = `${base}/${(w.path || '').replace(/^\//, '')}`;
1344    link.dataset.wordKey = pwWordKey(w);
1345    link.querySelector('.pw-ocean-teaser__word-name').textContent = w.word || '';
1346}
1347
1348function pwScheduleRotation(slot, pool, base, visibleSet) {
1349    if (slot._rotateTimer) clearTimeout(slot._rotateTimer);
1350    slot._rotateTimer = setTimeout(() => pwRotateSlot(slot, pool, base, visibleSet), pwRandomStayMs());
1351}
1352
1353function pwRotateSlot(slot, pool, base, visibleSet) {
1354    const link = slot.querySelector('.pw-ocean-teaser__word');
1355    if (!link) return;
1356
1357    link.classList.remove('pw-ocean-teaser__word--visible');
1358    link.classList.add('pw-ocean-teaser__word--fading');
1359
1360    setTimeout(() => {
1361        visibleSet.delete(link.dataset.wordKey || '');
1362        const w = pwPickWord(pool, visibleSet);
1363        visibleSet.add(pwWordKey(w));
1364        pwSetSlotWord(link, w, base);
1365
1366        link.classList.remove('pw-ocean-teaser__word--fading');
1367        requestAnimationFrame(() => {
1368            link.classList.add('pw-ocean-teaser__word--visible');
1369        });
1370
1371        pwScheduleRotation(slot, pool, base, visibleSet);
1372    }, PW_FADE_MS);
1373}
1374
1375function pwCreateSlot() {
1376    const li = document.createElement('li');
1377    li.className = 'pw-ocean-teaser__slot';
1378    li.innerHTML = `<a href="#" class="pw-ocean-teaser__word pw-ocean-teaser__word--fading">
1379        <span class="pw-ocean-teaser__word-name"></span>
1380    </a>`;
1381    return li;
1382}
1383
1384function pwSeededRandom(index, salt) {
1385    const x = Math.sin(index * 127.1 + salt * 311.7) * 43758.5453;
1386    return x - Math.floor(x);
1387}
1388
1389function pwDecorateSlots(list) {
1390    const slots = [...list.querySelectorAll('.pw-ocean-teaser__slot')];
1391    slots.forEach((slot, i) => {
1392        slot.style.position = '';
1393        slot.style.left = '';
1394        slot.style.top = '';
1395        slot.style.zIndex = '';
1396
1397        const mx = Math.round((pwSeededRandom(i, 1) - 0.5) * 8);
1398        const rot = ((pwSeededRandom(i, 3) - 0.5) * 1.2).toFixed(2);
1399
1400        slot.style.marginLeft = mx ? `${mx}px` : '';
1401        slot.style.marginTop = '';
1402        slot.style.transform = rot !== '0.00' ? `rotate(${rot}deg)` : '';
1403    });
1404
1405    pwLockWordsHeight(list);
1406}
1407
1408function pwLockWordsHeight(list) {
1409    if (list) list.style.minHeight = '';
1410}
1411
1412let pwHeightResizeTimer;
1413let pwLastSlotTier = '';
1414
1415function pwBindWordsHeightLock(list, words, base) {
1416    pwLockWordsHeight(list);
1417
1418    if (list.dataset.pwResizeBound === '1') return;
1419    list.dataset.pwResizeBound = '1';
1420    pwLastSlotTier = pwGetSlotTier();
1421
1422    window.addEventListener('resize', () => {
1423        clearTimeout(pwHeightResizeTimer);
1424        pwHeightResizeTimer = setTimeout(() => {
1425            const tier = pwGetSlotTier();
1426            if (tier !== pwLastSlotTier) {
1427                pwLastSlotTier = tier;
1428                initPowerWordRotator(list, words, base);
1429                return;
1430            }
1431            pwDecorateSlots(list);
1432            pwLockWordsHeight(list);
1433        }, 150);
1434    });
1435}
1436
1437function initPowerWordRotator(list, words, base) {
1438    list.querySelectorAll('li').forEach((slot) => {
1439        if (slot._rotateTimer) clearTimeout(slot._rotateTimer);
1440    });
1441
1442    if (!words.length) {
1443        list.innerHTML = '<li class="pw-ocean-teaser__empty">ワードはまだ登録されていません。</li>';
1444        return;
1445    }
1446
1447    const reduceMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1448    const pool = words;
1449    const visibleSet = new Set();
1450
1451    list.innerHTML = '';
1452    const slotCount = Math.min(pwGetSlotCount(), pool.length);
1453
1454    for (let i = 0; i < slotCount; i++) {
1455        const slot = pwCreateSlot();
1456        list.appendChild(slot);
1457
1458        const link = slot.querySelector('.pw-ocean-teaser__word');
1459        const w = pwPickWord(pool, visibleSet);
1460        visibleSet.add(pwWordKey(w));
1461        pwSetSlotWord(link, w, base);
1462
1463        if (reduceMotion) {
1464            link.classList.add('pw-ocean-teaser__word--visible');
1465            continue;
1466        }
1467
1468        const initialDelay = Math.random() * 1800;
1469        setTimeout(() => {
1470            link.classList.remove('pw-ocean-teaser__word--fading');
1471            link.classList.add('pw-ocean-teaser__word--visible');
1472            pwScheduleRotation(slot, pool, base, visibleSet);
1473        }, initialDelay);
1474    }
1475
1476    pwDecorateSlots(list);
1477    pwBindWordsHeightLock(list, words, base);
1478}
1479
1480async function loadPowerWords() {
1481    const list = document.getElementById('powerWordLatest');
1482    if (!list || list.dataset.rotatorReady === '1') return;
1483
1484    try {
1485        const basePath = window.BASE_PATH || 'new_toppage/';
1486        const dataUrl = `${basePath}data/power-word.json`;
1487        const response = await fetch(`${dataUrl}?_=${Date.now()}`, { cache: 'no-store' });
1488        if (!response.ok) {
1489            throw new Error(`HTTP ${response.status}`);
1490        }
1491
1492        const data = await response.json();
1493        const words = Array.isArray(data.words) ? data.words : [];
1494        const base = basePath.replace(/\/$/, '');
1495
1496        list.dataset.rotatorReady = '1';
1497        initPowerWordRotator(list, words, base);
1498    } catch (error) {
1499        console.error('PowerWord読み込みエラー:', error);
1500        if (!list.querySelector('.pw-ocean-teaser__word')) {
1501            list.innerHTML = '<li class="pw-ocean-teaser__empty">ワードの読み込みに失敗しました。</li>';
1502        }
1503    }
1504}
1505
1506function initGrovePager(grid) {
1507    if (!grid) return;
1508    const tiles = Array.from(grid.querySelectorAll('.grove-tile, .news-card'));
1509    const nav = grid.parentElement && grid.parentElement.querySelector('.grove-pager');
1510    if (!tiles.length) return;
1511    const base = Number(grid.getAttribute('data-grove-page-size') || 8) || 8;
1512    function size() {
1513        return window.matchMedia('(max-width: 1024px)').matches ? Math.max(1, Math.floor(base / 2)) : base;
1514    }
1515    let page = 0;
1516    const status = nav && nav.querySelector('[data-grove-status]');
1517    const prev = nav && nav.querySelector('[data-grove-prev]');
1518    const next = nav && nav.querySelector('[data-grove-next]');
1519    function lockMinHeight() {
1520        requestAnimationFrame(function () {
1521            const h = grid.scrollHeight;
1522            const cur = parseFloat(grid.style.minHeight) || 0;
1523            if (h > cur) grid.style.minHeight = h + 'px';
1524        });
1525    }
1526    function hydrateImgs(el) {
1527        el.querySelectorAll('img[data-src]').forEach((img) => {
1528            const src = img.getAttribute('data-src');
1529            if (!src) return;
1530            img.removeAttribute('data-src');
1531            img.src = src;
1532        });
1533    }
1534    function show() {
1535        const sz = size();
1536        const pages = Math.max(1, Math.ceil(tiles.length / sz));
1537        if (page >= pages) page = pages - 1;
1538        tiles.forEach((el, i) => {
1539            const on = i >= page * sz && i < (page + 1) * sz;
1540            el.hidden = !on;
1541            if (on) hydrateImgs(el);
1542        });
1543        if (nav) nav.hidden = tiles.length <= sz;
1544        if (status) status.textContent = (page + 1) + ' / ' + pages;
1545        if (prev) prev.disabled = page === 0;
1546        if (next) next.disabled = page >= pages - 1;
1547        if (page === 0) lockMinHeight();
1548    }
1549    tiles.slice(0, size()).forEach(function (el) {
1550        el.querySelectorAll('img').forEach(function (img) {
1551            if (!img.complete) {
1552                img.addEventListener('load', function () { if (page === 0) lockMinHeight(); }, { once: true });
1553            }
1554        });
1555    });
1556    if (grid._onGroveResize) window.removeEventListener('resize', grid._onGroveResize);
1557    grid._onGroveResize = function () {
1558        grid.style.minHeight = '';
1559        show();
1560    };
1561    window.addEventListener('resize', grid._onGroveResize);
1562    if (prev) prev.onclick = function () { if (page > 0) { page -= 1; show(); } };
1563    if (next) next.onclick = function () { if ((page + 1) * size() < tiles.length) { page += 1; show(); } };
1564    show();
1565    paintCardBars(grid);
1566}
1567function paintCardBars(root) {
1568    const colors = ['#38bdf8', '#5dade2', '#14b8a6', '#2dd4bf', '#a78bfa', '#7c3aed', '#f59e0b', '#fb7185', '#f472b6', '#34d399', '#f97316', '#0ea5e9'];
1569    const scope = root && root.querySelectorAll ? root : document;
1570    const list = scope.matches && scope.matches('.news-card, .grove-tile, .briefing-card')
1571        ? [scope]
1572        : Array.from(scope.querySelectorAll('.news-card, .grove-tile, .briefing-card'));
1573    list.forEach((el) => {
1574        el.style.setProperty('--card-bar', colors[Math.floor(Math.random() * colors.length)]);
1575    });
1576}
1577window.paintCardBars = paintCardBars;
1578window.initGrovePager = initGrovePager;
1579document.querySelectorAll('[data-grove-page-size]').forEach(initGrovePager);
1580paintCardBars();
1581
1582document.addEventListener('click', function (e) {
1583    const btn = e.target && e.target.closest && e.target.closest('[data-topics-more]');
1584    if (!btn) return;
1585    const digest = btn.closest('.topics-digest');
1586    if (!digest) return;
1587    const step = Math.max(1, Number(digest.getAttribute('data-topics-step') || 3) || 3);
1588    const hidden = Array.from(digest.querySelectorAll('.topic-row[hidden]'));
1589    hidden.slice(0, step).forEach(function (el) { el.hidden = false; });
1590    const left = digest.querySelectorAll('.topic-row[hidden]').length;
1591    if (!left) {
1592        btn.remove();
1593        return;
1594    }
1595    const show = Math.min(step, left);
1596    btn.textContent = '次の' + show + '件を見る(残り' + left + ')';
1597});
1598
1599// ===================
1600// エクスポート(グローバルスコープ用)
1601// ===================
1602window.changePage = changePage;
1603window.playVideo = playVideo;
1604window.closeVideoModal = closeVideoModal;
1605window.handleVideoPlay = handleVideoPlay;
1606window.handleVideoPause = handleVideoPause;
1607window.handleRandomVideoError = handleRandomVideoError;
1608window.handleRandomImageError = handleRandomImageError;
1609

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.