Yahoo Transit Timetable Filter

Yahoo!乗換案内の時刻表にフィルター機能を提供します。

// ==UserScript==
// @name         Yahoo Transit Timetable Filter
// @namespace    dev.kawaidainf.userscript
// @version      1.0
// @description  Yahoo!乗換案内の時刻表にフィルター機能を提供します。
// @author       kawaida
// @match        https://transit.yahoo.co.jp/timetable/*/*
// @grant        none
// @license      3-clause BSD License
// ==/UserScript==

(function() {
    'use strict';

    /**
     * 時刻表の行き先・経由の略称と正式名称、およびクラス名サフィックスの対応表を取得する関数。
     * HTMLの .tblDiaNote テーブルから情報を解析します。
     * クラス名サフィックスは、各ユニークな行き先に対して連番を割り振ります。
     * @returns {{destinationMap: Object, uniqueFullNamesMap: Map<string, string>}} 略称(キー)と {fullName: string, classNameSuffix: string} のマップ、および正式名称とクラス名サフィックスのマップ
     */
    function getDestinationInfo() {
        const destinationMap = {}; // 略称をキーとする行き先情報を格納するオブジェクト
        const uniqueFullNamesMap = new Map(); // 正式名称をキーとし、クラス名サフィックスを値とするマップ
        let currentIndex = 0; // クラス名サフィックスのための現在のインデックス

        const destinationList = document.querySelector('#timeNotice2 ul');

        if (destinationList) {
            // li要素を全て取得し、それぞれを処理します
            Array.from(destinationList.children).forEach(li => {
                const textContent = li.textContent.trim(); // テキストコンテンツを取得し、前後の空白を除去
                const parts = textContent.split(':'); // 「:」で分割

                // 分割が成功し、2つの部分があることを確認
                if (parts.length === 2) {
                    let abbreviation = parts[0].trim(); // 略称を取得し、空白を除去
                    const fullName = parts[1].trim(); // 正式名称を取得し、空白を除去

                    // 「無印」の場合は空文字列を略称として使用
                    if (abbreviation === '無印') {
                        abbreviation = '';
                    }

                    let classNameSuffix;
                    // ユニークな正式名称に対してのみ新しいインデックスを割り振る
                    if (!uniqueFullNamesMap.has(fullName)) {
                        classNameSuffix = `dest-${currentIndex}`; // 例: "dest-0", "dest-1"
                        uniqueFullNamesMap.set(fullName, classNameSuffix);
                        currentIndex++;
                    } else {
                        classNameSuffix = uniqueFullNamesMap.get(fullName);
                    }

                    destinationMap[abbreviation] = {
                        fullName: fullName,
                        classNameSuffix: classNameSuffix
                    };
                }
            });
        }
        return { destinationMap: destinationMap, uniqueFullNamesMap: uniqueFullNamesMap };
    }

    /**
     * 時刻表の列車種別の略称と正式名称、およびクラス名サフィックスの対応表を取得する関数。
     * HTMLの .tblDiaNote テーブルから情報を解析します。
     * クラス名サフィックスは、各ユニークな列車種別に対して連番を割り振ります。
     * @returns {{trainTypeMap: Object, uniqueTrainTypesMap: Map<string, string>}} 略称(キー)と {fullName: string, classNameSuffix: string} のマップ、および正式名称とクラス名サフィックスのマップ
     */
    function getTrainTypeInfo() {
        const trainTypeMap = {}; // 略称をキーとする列車種別情報を格納するオブジェクト
        const uniqueFullNamesMap = new Map(); // 正式名称をキーとし、クラス名サフィックスを値とするマップ
        let currentIndex = 0; // クラス名サフィックスのための現在のインデックス

        const trainTypeList = document.querySelector('#timeNotice1 ul');

        if (trainTypeList) {
            // li要素を全て取得し、それぞれを処理します
            Array.from(trainTypeList.children).forEach(li => {
                const textContent = li.textContent.trim(); // テキストコンテンツを取得し、前後の空白を除去
                const parts = textContent.split(':'); // 「:」で分割

                // 分割が成功し、2つの部分があることを確認
                if (parts.length === 2) {
                    let abbreviation = parts[0].trim(); // 略称を取得し、空白を除去
                    const fullName = parts[1].trim(); // 正式名称を取得し、空白を除去

                    // 「無印」の場合は空文字列を略称として使用
                    if (abbreviation === '無印') {
                        abbreviation = ''; // 無印は空文字列として扱う
                    }

                    let classNameSuffix;
                    // ユニークな正式名称に対してのみ新しいインデックスを割り振る
                    if (!uniqueFullNamesMap.has(fullName)) {
                        classNameSuffix = `type-${currentIndex}`; // 例: "type-0", "type-1"
                        uniqueFullNamesMap.set(fullName, classNameSuffix);
                        currentIndex++;
                    } else {
                        classNameSuffix = uniqueFullNamesMap.get(fullName);
                    }

                    trainTypeMap[abbreviation] = {
                        fullName: fullName,
                        classNameSuffix: classNameSuffix
                    };
                }
            });
        }
        return { trainTypeMap: trainTypeMap, uniqueFullNamesMap: uniqueFullNamesMap };
    }

    /**
     * 時刻表の始発マークに関する静的な情報(識別子と正式名称・クラス名サフィックスのマップ)を取得する関数。
     * @returns {Object} 識別子(キー)と {fullName: string, classNameSuffix: string} のマップ
     */
    function getStaticMarkInfoMap() {
        const markInfoMap = {};
        markInfoMap['hasMark'] = { fullName: '始発', classNameSuffix: 'mark-0' };
        markInfoMap['noMark'] = { fullName: 'その他', classNameSuffix: 'mark-1' };
        return markInfoMap;
    }

    /**
     * 現在のフィルター設定に基づいて時刻表の表示を更新する関数。
     * 行き先フィルター、列車種別フィルター、始発フィルターのAND条件で絞り込みます。
     */
    function applyFilters() {
        const allTimeNumbElements = document.querySelectorAll('.tblDiaDetail .timeNumb');

        // 各フィルターUIの存在チェック
        const destinationFilterExists = document.getElementById('destination-filter-container') !== null;
        const trainTypeFilterExists = document.getElementById('train-type-filter-container') !== null;
        const markTypeFilterExists = document.getElementById('mark-type-filter-container') !== null;

        // チェックされている行き先フィルターのサフィックスを取得
        const checkedDestinationSuffixes = Array.from(document.querySelectorAll('.destination-filter-checkbox:checked'))
                                            .map(cb => cb.getAttribute('data-target-suffix'));

        // チェックされている列車種別フィルターのサフィックスを取得
        const checkedTrainTypeSuffixes = Array.from(document.querySelectorAll('.train-type-filter-checkbox:checked'))
                                          .map(cb => cb.getAttribute('data-target-suffix'));

        // チェックされている始発フィルターのサフィックスを取得
        const checkedMarkTypeSuffixes = Array.from(document.querySelectorAll('.mark-type-filter-checkbox:checked'))
                                         .map(cb => cb.getAttribute('data-target-suffix'));

        allTimeNumbElements.forEach(timeNumbLi => {
            // 列車の行き先クラスサフィックスを取得
            const trainDestinationClassList = Array.from(timeNumbLi.classList).filter(cls => cls.startsWith('destination-'));
            const trainDestinationSuffix = trainDestinationClassList.length > 0 ? trainDestinationClassList[0].replace('destination-', '') : '';

            // 列車の列車種別クラスサfiックスを取得
            const trainTypeClassList = Array.from(timeNumbLi.classList).filter(cls => cls.startsWith('train-type-'));
            const trainTypeSuffix = trainTypeClassList.length > 0 ? trainTypeClassList[0].replace('train-type-', '') : '';

            // 列車の始発マーククラスサフィックスを取得
            const trainMarkTypeClassList = Array.from(timeNumbLi.classList).filter(cls => cls.startsWith('mark-type-'));
            const trainMarkTypeSuffix = trainMarkTypeClassList.length > 0 ? trainMarkTypeClassList[0].replace('mark-type-', '') : '';


            // 各フィルター条件を評価
            let isDestinationMatch = true;
            if (destinationFilterExists) {
                // 行き先フィルターUIが存在する場合、チェックされた項目がなければ非表示
                isDestinationMatch = checkedDestinationSuffixes.includes(trainDestinationSuffix);
            }

            let isTrainTypeMatch = true;
            if (trainTypeFilterExists) {
                // 列車種別フィルターUIが存在する場合、チェックされた項目がなければ非表示
                isTrainTypeMatch = checkedTrainTypeSuffixes.includes(trainTypeSuffix);
            }

            let isMarkTypeMatch = true;
            if (markTypeFilterExists) {
                // 始発フィルターUIが存在する場合、チェックされた項目がなければ非表示
                isMarkTypeMatch = checkedMarkTypeSuffixes.includes(trainMarkTypeSuffix);
            }

            // すべての条件を満たす場合のみ表示
            if (isDestinationMatch && isTrainTypeMatch && isMarkTypeMatch) {
                timeNumbLi.classList.remove('hidden-by-filter');
            } else {
                timeNumbLi.classList.add('hidden-by-filter');
            }
        });
    }

    /**
     * フィルターUIを作成し、挿入する共通関数。
     * ユニークな項目が1つ以下の場合、フィルターUIは作成しません。
     * @param {string} containerId - フィルターコンテナのID
     * @param {string} titleText - フィルターセクションのタイトルテキスト
     * @param {Map<string, string>} uniqueNamesMap - 正式名称とクラス名サフィックスのマップ
     * @param {string} filterCheckboxClass - 個別フィルターチェックボックスに付与するクラス名
     * @param {string} targetPrefix - 絞り込み対象要素のクラス名のプレフィックス (例: 'destination', 'train-type', 'mark-type')
     */
    function createFilterUI(containerId, titleText, uniqueNamesMap, filterCheckboxClass, targetPrefix) {
        // ユニークな項目が1つ以下の場合、フィルターUIは作成しない
        if (uniqueNamesMap.size <= 1) {
            console.log(`フィルターUI (${titleText}) は、ユニークな項目が1つ以下であるため作成されません。`);
            return;
        }

        const filterContainer = document.createElement('div');
        filterContainer.id = containerId;
        filterContainer.classList.add('filter-section'); // 共通スタイルクラスを適用
        filterContainer.innerHTML = `<p>${titleText}</p>`;

        // 「すべて選択/解除」チェックボックスのグループを作成
        const selectAllCheckboxGroup = document.createElement('span');
        selectAllCheckboxGroup.classList.add('checkbox-group');

        const selectAllCheckbox = document.createElement('input');
        selectAllCheckbox.type = 'checkbox';
        selectAllCheckbox.id = `${containerId}-select-all`;
        selectAllCheckbox.checked = true; // デフォルトでチェック済み

        const selectAllLabel = document.createElement('label');
        selectAllLabel.htmlFor = `${containerId}-select-all`;
        selectAllLabel.textContent = 'すべて選択/解除';
        selectAllLabel.style.cursor = 'pointer';

        selectAllCheckboxGroup.appendChild(selectAllCheckbox);
        selectAllCheckboxGroup.appendChild(selectAllLabel);
        filterContainer.appendChild(selectAllCheckboxGroup);

        // ユニークな項目ごとにチェックボックスを作成し、コンテナに追加
        uniqueNamesMap.forEach((classNameSuffix, fullName) => {
            const checkboxId = `${containerId}-${classNameSuffix}`;
            const checkboxContainer = document.createElement('span');
            checkboxContainer.classList.add('checkbox-group');

            const checkbox = document.createElement('input');
            checkbox.type = 'checkbox';
            checkbox.id = checkboxId;
            checkbox.checked = true;
            checkbox.setAttribute('data-target-suffix', classNameSuffix);
            checkbox.classList.add(filterCheckboxClass); // 個別フィルターのクラスを追加

            const label = document.createElement('label');
            label.htmlFor = checkboxId;
            label.textContent = fullName;
            label.style.cursor = 'pointer';

            checkboxContainer.appendChild(checkbox);
            checkboxContainer.appendChild(label);
            filterContainer.appendChild(checkboxContainer);
        });

        // フィルターUIを時刻表の前に挿入
        const mdStaLineDia = document.getElementById('mdStaLineDia');
        if (mdStaLineDia) {
            mdStaLineDia.parentNode.insertBefore(filterContainer, mdStaLineDia);
        }

        // 「すべて選択/解除」チェックボックスのイベントリスナー
        selectAllCheckbox.addEventListener('change', (event) => {
            const isChecked = event.target.checked;
            const individualCheckboxes = document.querySelectorAll(`.${filterCheckboxClass}`);

            individualCheckboxes.forEach(checkbox => {
                checkbox.checked = isChecked;
            });
            applyFilters(); // フィルターを再適用
        });

        // 個別フィルターチェックボックスのイベントリスナー
        filterContainer.addEventListener('change', (event) => {
            const targetCheckbox = event.target;

            // 「すべて選択/解除」チェックボックスがクリックされた場合は、このリスナーでは処理しない
            if (targetCheckbox.id === `${containerId}-select-all`) {
                return;
            }

            // ターゲットが個別フィルターチェックボックスであることを確認
            if (targetCheckbox.type === 'checkbox' && targetCheckbox.classList.contains(filterCheckboxClass)) {
                // すべての個別チェックボックスがチェックされているか確認し、「すべて選択/解除」の状態を更新
                const allIndividualCheckboxes = document.querySelectorAll(`.${filterCheckboxClass}`);
                const allChecked = Array.from(allIndividualCheckboxes).every(cb => cb.checked);
                selectAllCheckbox.checked = allChecked;
                applyFilters(); // フィルターを再適用
            }
        });
    }

    /**
     * 時刻テーブルの各列車要素に行き先情報と列車種別情報を付与し、フィルターUIを作成する関数。
     * data属性とクラスを追加し、チェックボックスによる表示/非表示機能を提供します。
     */
    function initializeTimetableFilters() {
        // 行き先情報を取得
        const { destinationMap, uniqueFullNamesMap: uniqueDestinationNamesMap } = getDestinationInfo();
        console.log('行き先・経由の対応表:', destinationMap); // デバッグ用
        console.log('ユニークな行き先とクラス名サフィックスのマップ:', uniqueDestinationNamesMap); // デバッグ用

        // 列車種別情報を取得
        const { trainTypeMap, uniqueFullNamesMap: uniqueTrainTypeNamesMap } = getTrainTypeInfo();
        console.log('列車種別の対応表:', trainTypeMap); // デバッグ用
        console.log('ユニークな列車種別とクラス名サフィックスのマップ:', uniqueTrainTypeNamesMap); // デバッグ用

        // 始発マークの静的な情報マップを取得
        const staticMarkInfoMap = getStaticMarkInfoMap();
        // 実際に存在する始発マークの識別子を収集するSet
        const actualMarkIdentifiersPresent = new Set();


        // 時刻表内のすべての列車要素(.timeNumbクラスを持つli要素)を取得
        const timeNumbElements = document.querySelectorAll('.tblDiaDetail .timeNumb');

        // 各列車要素に対して処理を実行
        timeNumbElements.forEach(timeNumbLi => {
            // 行き先略称の処理
            const trainForDd = timeNumbLi.querySelector('dl dd.trainFor');
            let destinationAbbreviation = '';
            if (trainForDd) {
                destinationAbbreviation = trainForDd.textContent.trim();
            }
            const destData = destinationMap[destinationAbbreviation];
            if (destData) {
                timeNumbLi.setAttribute('data-destination-full', destData.fullName);
                timeNumbLi.classList.add(`destination-${destData.classNameSuffix}`);
            } else {
                console.warn(`不明な行き先略称が見つかりました: "${destinationAbbreviation}"`);
            }

            // 列車種別略称の処理
            const trainTypeDd = timeNumbLi.querySelector('dl dd.trainType');
            let trainTypeAbbreviation = '';
            if (trainTypeDd) {
                // 例: "[準]" から "準" を抽出
                trainTypeAbbreviation = trainTypeDd.textContent.trim().replace(/[\[\]]/g, '');
            }
            const typeData = trainTypeMap[trainTypeAbbreviation];
            if (typeData) {
                timeNumbLi.setAttribute('data-train-type-full', typeData.fullName);
                timeNumbLi.classList.add(`train-type-${typeData.classNameSuffix}`);
            } else {
                // 無印(普通)の場合
                const isNormalTrain = !trainTypeDd && !trainTypeAbbreviation;
                const normalTrainData = trainTypeMap['']; // 無印のデータ
                if (isNormalTrain && normalTrainData) {
                    timeNumbLi.setAttribute('data-train-type-full', normalTrainData.fullName);
                    timeNumbLi.classList.add(`train-type-${normalTrainData.classNameSuffix}`);
                } else {
                    console.warn(`不明な列車種別略称が見つかりました: "${trainTypeAbbreviation}"`);
                }
            }

            // 始発マークの処理
            const markSpan = timeNumbLi.querySelector('dl dt .mark');
            const markIdentifier = markSpan ? 'hasMark' : 'noMark'; // .mark要素の有無で識別
            actualMarkIdentifiersPresent.add(markIdentifier); // 実際に存在するマーク識別子をSetに追加

            const markData = staticMarkInfoMap[markIdentifier];
            if (markData) {
                timeNumbLi.setAttribute('data-mark-type-full', markData.fullName);
                timeNumbLi.classList.add(`mark-type-${markData.classNameSuffix}`);
            } else {
                console.warn(`不明な始発マーク識別子が見つかりました: "${markIdentifier}"`);
            }
        });

        console.log('時刻テーブルに行き先情報、列車種別情報、始発マーク情報を付与しました。'); // デバッグ用

        // 動的にuniqueMarkTypeNamesMapを作成(実際に存在するマークタイプのみ)
        const uniqueMarkTypeNamesMap = new Map();
        actualMarkIdentifiersPresent.forEach(identifier => {
            const data = staticMarkInfoMap[identifier];
            if (data) {
                uniqueMarkTypeNamesMap.set(data.fullName, data.classNameSuffix);
            }
        });
        console.log('ユニークな始発マークとクラス名サフィックスのマップ:', uniqueMarkTypeNamesMap); // デバッグ用


        // フィルターで非表示にするためのCSSルールを動的に追加
        const style = document.createElement('style');
        style.textContent = `
            .hidden-by-filter {
                display: none !important; /* フィルターによって非表示にするためのスタイル */
            }
            .filter-section {
                margin-bottom: 15px;
                padding: 10px;
                border: 1px solid #ccc;
                border-radius: 8px;
                background-color: #f9f9f9;
                display: flex;
                flex-wrap: wrap;
                align-items: center;
            }
            .filter-section > p {
                font-weight: bold;
                color: #333;
                margin-right: 15px; /* タイトルとチェックボックスの間の余白 */
            }
            .checkbox-group {
                display: flex;
                align-items: center;
                margin-right: 15px;
                white-space: nowrap; /* チェックボックスとラベルが改行されないようにする */
            }
            .checkbox-group input[type="checkbox"] {
                margin-right: 5px;
            }
        `;
        document.head.appendChild(style); // head要素にスタイルを追加

        // 行き先フィルターUIを作成
        createFilterUI(
            'destination-filter-container',
            '行き先で絞り込む:',
            uniqueDestinationNamesMap,
            'destination-filter-checkbox',
            'destination'
        );

        // 列車種別フィルターUIを作成
        createFilterUI(
            'train-type-filter-container',
            '列車種別で絞り込む:',
            uniqueTrainTypeNamesMap,
            'train-type-filter-checkbox',
            'train-type'
        );

        // 始発フィルターUIを作成
        createFilterUI(
            'mark-type-filter-container',
            '始発で絞り込む:',
            uniqueMarkTypeNamesMap, // 実際に存在するマークタイプのみを含むマップ
            'mark-type-filter-checkbox',
            'mark-type'
        );

        // 初期表示のためにフィルターを適用
        applyFilters();
    }

    // DOMが完全に読み込まれた後に処理を実行
    window.addEventListener('load', initializeTimetableFilters);

})();