Yahoo Transit Timetable Filter

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

您需要先安裝使用者腳本管理器擴展,如 TampermonkeyGreasemonkeyViolentmonkey 之後才能安裝該腳本。

You will need to install an extension such as Tampermonkey to install this script.

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyViolentmonkey 後才能安裝該腳本。

您需要先安裝使用者腳本管理器擴充功能,如 TampermonkeyUserscripts 後才能安裝該腳本。

你需要先安裝一款使用者腳本管理器擴展,比如 Tampermonkey,才能安裝此腳本

您需要先安裝使用者腳本管理器擴充功能後才能安裝該腳本。

(我已經安裝了使用者腳本管理器,讓我安裝!)

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展,比如 Stylus,才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

你需要先安裝一款使用者樣式管理器擴展後才能安裝此樣式

(我已經安裝了使用者樣式管理器,讓我安裝!)

// ==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);

})();