編集の要約なし
タグ: 手動差し戻し
編集の要約なし
1,223行目: 1,223行目:


                 updateCompareButtons();
                 updateCompareButtons();
                updateMapCompareLinks();
                 updateCompareTray();
                 updateCompareTray();


1,275行目: 1,276行目:


             updateCompareButtons();
             updateCompareButtons();
            updateMapCompareLinks();
             updateCompareTray();
             updateCompareTray();


1,340行目: 1,342行目:


             updateCompareButtons();
             updateCompareButtons();
            updateMapCompareLinks();
             updateCompareTray();
             updateCompareTray();


1,373行目: 1,376行目:


             updateCompareButtons();
             updateCompareButtons();
            updateMapCompareLinks();
             updateCompareTray();
             updateCompareTray();


1,406行目: 1,410行目:
     * ===================================== */
     * ===================================== */


    function initPlacementCompare() {
function initPlacementCompare() {


        createCompareButtons();
    createCompareButtons();


        createCompareTray();
    createCompareTray();


        updateCompareButtons();
    updateCompareButtons();


        updateCompareTray();
    updateMapCompareLinks();


     }
     updateCompareTray();


}


    initPlacementCompare();


initPlacementCompare();


    mw.hook(
        'wikipage.content'
    ).add(
        function () {


            initPlacementCompare();
mw.hook(
    'wikipage.content'
).add(
    function () {


         }
         initPlacementCompare();
    );
   


} );
    }
);


/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */


mw.loader.using( [
/* =====================================
    'mediawiki.storage',
* 地図popup 比較リンク
    'mediawiki.api',
* ===================================== */
    'mediawiki.util'
] ).then( function () {


    'use strict';
function getMapComparePlacementId( link ) {


    if ( !link ) {
        return '';
    }


     const compareRoot =
     const href =
         document.getElementById(
         link.getAttribute( 'href' ) || '';
             'stall-compare-page'
 
    const match =
        href.match(
             /#compare-placement-(\d+)$/
         );
         );


 
     if ( !match ) {
    /*
         return '';
    * 屋台比較ページ以外では終了
    */
     if ( !compareRoot ) {
         return;
     }
     }


    return match[ 1 ];
}


    const STORAGE_KEY =
        'matsuriWikiComparePlacements';


    const MIN_COMPARE = 2;
function updateMapCompareLinks() {
    const MAX_COMPARE = 4;


     const api =
     const ids =
         new mw.Api();
         getComparePlacements();


    document
        .querySelectorAll(
            'a[href*="#compare-placement-"]'
        )
        .forEach(
            function ( link ) {


    /* =====================================
                const placementId =
    * localStorage
                    getMapComparePlacementId(
    * ===================================== */
                        link
                    );


    function getPlacementIds() {
                if (
                    !placementId ||
                    placementId === '0'
                ) {
                    return;
                }


        const raw =
                const selected =
            mw.storage.get(
                    ids.includes(
                STORAGE_KEY
                        placementId
            );
                    );


                link.classList.add(
                    'stall-map-compare-link'
                );


        if ( !raw ) {
                link.dataset.placementId =
            return [];
                    placementId;
        }


                if ( selected ) {


        try {
                    link.textContent =
                        '比較から外す';
 
                    link.classList.add(
                        'stall-map-compare-link-selected'
                    );


            const ids =
                    link.setAttribute(
                JSON.parse(
                        'aria-pressed',
                     raw
                        'true'
                );
                     );


                } else {


            if (
                    link.textContent =
                !Array.isArray(
                        '比較に追加';
                    ids
                )
            ) {
                return [];
            }


                    link.classList.remove(
                        'stall-map-compare-link-selected'
                    );


            return [ ...new Set(
                     link.setAttribute(
                ids
                         'aria-pressed',
                    .map( String )
                         'false'
                     .filter(
                     );
                         function ( id ) {
                            return /^\d+$/.test(
                                id
                            );
                         }
                     )
            ) ].slice(
                0,
                MAX_COMPARE
            );


                }


         } catch ( e ) {
            }
         );


            return [];
}


        }


    }
/* =====================================
* 地図popupクリック
* ===================================== */


document.addEventListener(
    'click',
    function ( event ) {


    /* =====================================
        const link =
    * Cargo
            event.target.closest(
    * ===================================== */
                'a[href*="#compare-placement-"]'
            );


    function cargoQuery(
        if ( !link ) {
        table,
            return;
         fields,
         }
        where,
        limit
    ) {


         const params = {
         const placementId =
            getMapComparePlacementId(
                link
            );


             action: 'cargoquery',
        if (
             !placementId ||
            placementId === '0'
        ) {
            return;
        }


            tables: table,
        event.preventDefault();


             fields: fields,
        let ids =
             getComparePlacements();


             limit: limit || 100,
        const index =
             ids.indexOf(
                placementId
            );
 
        if ( index !== -1 ) {


             format: 'json'
             ids.splice(
                index,
                1
            );


         };
         } else {


            if (
                ids.length >=
                MAX_COMPARE
            ) {


        if ( where ) {
                link.textContent =
            params.where = where;
                    '最大4件までです';
        }


                return;
            }


        return api.get(
            ids.push(
            params
                placementId
        ).then(
             );
             function ( data ) {


                if (
        }
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }




                return data.cargoquery.map(
        saveComparePlacements(
                    function ( item ) {
            ids
        );


                        return (
        updateCompareButtons();
                            item.title ||
        updateMapCompareLinks();
                            item
         updateCompareTray();
                        );
 
                    }
                );
 
            }
         );


     }
     }
);




    function makeInClause( ids ) {
/* =====================================
* Leaflet popup生成監視
* ===================================== */


        return ids
const mapCompareLinkObserver =
            .map( String )
    new MutationObserver(
            .filter(
        function ( mutations ) {
                function ( id ) {
                    return /^\d+$/.test(
                        id
                    );
                }
            )
            .join( ',' );


    }
            let needsUpdate =
                false;


            mutations.forEach(
                function ( mutation ) {


    function uniqueIds( values ) {
                    mutation.addedNodes.forEach(
                        function ( node ) {


        return [
                            if (
            ...new Set(
                                node.nodeType !== 1
                values
                            ) {
                    .map( String )
                                return;
                    .filter(
                            }
                        function ( id ) {


                             return (
                             if (
                                 id &&
                                 node.matches &&
                                 /^\d+$/.test(
                                 node.matches(
                                     id
                                     'a[href*="#compare-placement-"]'
                                 )
                                 )
                             );
                             ) {
 
                                needsUpdate =
                                    true;


                        }
                                return;
                    )
                            }
            )
        ];


    }
                            if (
                                node.querySelector &&
                                node.querySelector(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {


                                needsUpdate =
                                    true;


    function mapBy(
                            }
        rows,
        key
    ) {


        const result = {};
                        }
                    );


                }
            );


        rows.forEach(
            function ( row ) {


                if (
            if ( needsUpdate ) {
                    row[ key ] ===
                    undefined
                ) {
                    return;
                }


 
                 updateMapCompareLinks();
                 result[
                    String(
                        row[ key ]
                    )
                ] = row;


             }
             }
        );


        }
    );


        return result;


     }
mapCompareLinkObserver.observe(
    document.body,
    {
        childList: true,
        subtree: true
     }
);
 


/*
* ★ この } ); が
* placement比較用mw.loaderの終了
*/
} );


    /* =====================================
/* ========================================
    * 表示ヘルパー
* 屋台比較ページ
    * ===================================== */
* placement_id 正式版
* ======================================== */


     function textOrDash(
mw.loader.using( [
        value
     'mediawiki.storage',
     ) {
    'mediawiki.api',
     'mediawiki.util'
] ).then( function () {


        if (
    'use strict';
            value === undefined ||
            value === null ||
            value === ''
        ) {
            return '―';
        }


         return String(
 
             value
    const compareRoot =
         document.getElementById(
             'stall-compare-page'
         );
         );


    /*
    * 屋台比較ページ以外では終了
    */
    if ( !compareRoot ) {
        return;
     }
     }




     function cleanNumber(
     const STORAGE_KEY =
         value
         'matsuriWikiComparePlacements';
    ) {


        const number =
    const MIN_COMPARE = 2;
            Number(
    const MAX_COMPARE = 4;
                value
            );


    const api =
        new mw.Api();


        if (
            !Number.isFinite(
                number
            )
        ) {
            return '';
        }


    /* =====================================
    * localStorage
    * ===================================== */


        if (
    function getPlacementIds() {
            Number.isInteger(
                number
            )
        ) {


             return String(
        const raw =
                 number
             mw.storage.get(
                 STORAGE_KEY
             );
             );


        if ( !raw ) {
            return [];
         }
         }




         return String(
         try {
            Math.round(
                number * 100
            ) / 100
        );


    }
            const ids =
                JSON.parse(
                    raw
                );




    function formatHours(
            if (
        placement
                !Array.isArray(
    ) {
                    ids
                )
            ) {
                return [];
            }


        if ( !placement ) {
            return '―';
        }


            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test(
                                id
                            );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );


        const open =
            placement.opening_time || '';


         const close =
         } catch ( e ) {
            placement.closing_time || '';


            return [];


         if (
         }
            open &&
            close
        ) {


            return (
    }
                open +
                '~' +
                close
            );


        }


    /* =====================================
    * Cargo
    * ===================================== */


         if ( open ) {
    function cargoQuery(
         table,
        fields,
        where,
        limit
    ) {


            return (
        const params = {
                open +
                '~'
            );


        }
            action: 'cargoquery',


            tables: table,


        if ( close ) {
            fields: fields,


             return (
             limit: limit || 100,
                '~' +
                close
            );


        }
            format: 'json'


        };


        if (
            placement.hours_note
        ) {
            return placement.hours_note;


        if ( where ) {
            params.where = where;
         }
         }




         return '未確認';
         return api.get(
            params
        ).then(
            function ( data ) {


    }
                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }




    function formatPositionStatus(
                return data.cargoquery.map(
        status
                    function ( item ) {
    ) {


        switch ( status ) {
                        return (
                            item.title ||
                            item
                        );


            case 'exact':
                    }
                 return '正確な位置';
                 );


             case 'approximate':
             }
                return 'おおよその位置';
        );
 
            case 'test':
                return 'テスト位置';
 
            default:
                return '位置未確認';
 
        }


     }
     }




     function formatVerification(
     function makeInClause( ids ) {
        status
    ) {


         switch ( status ) {
         return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test(
                        id
                    );
                }
            )
            .join( ',' );


            case 'verified':
    }
                return '確認済み';


            case 'partially_verified':
                return '一部確認済み';


            case 'outdated':
    function uniqueIds( values ) {
                return '情報が古い';


             default:
        return [
                 return '未確認';
             ...new Set(
                 values
                    .map( String )
                    .filter(
                        function ( id ) {


         }
                            return (
                                id &&
                                /^\d+$/.test(
                                    id
                                )
                            );
 
                        }
                    )
            )
         ];


     }
     }




     function formatAvailability(
     function mapBy(
         status
         rows,
        key
     ) {
     ) {


         switch ( status ) {
         const result = {};


            case 'available':
                return '販売あり';


             case 'unavailable':
        rows.forEach(
                return '販売なし';
             function ( row ) {


            default:
                if (
                 return '未確認';
                    row[ key ] ===
                    undefined
                 ) {
                    return;
                }


         }
 
 
                result[
     }
                    String(
                        row[ key ]
                    )
                ] = row;
 
            }
         );
 
 
        return result;
 
     }




     /* =====================================
     /* =====================================
     * 単位価格
     * 表示ヘルパー
     * ===================================== */
     * ===================================== */


     function getUnitPrice(
     function textOrDash(
         offering
         value
     ) {
     ) {


         const price =
         if (
             Number(
            value === undefined ||
                offering.price
             value === null ||
             );
            value === ''
        ) {
             return '―';
        }


         const quantity =
        return String(
             Number(
            value
                 offering.serving_quantity
        );
 
    }
 
 
    function cleanNumber(
        value
    ) {
 
         const number =
             Number(
                 value
             );
             );


1,884行目: 1,964行目:
         if (
         if (
             !Number.isFinite(
             !Number.isFinite(
                 price
                 number
             ) ||
             )
            !Number.isFinite(
                quantity
            ) ||
            quantity <= 0
         ) {
         ) {
 
             return '';
             return '';
 
         }
         }




         const unitPrice =
         if (
             Math.round(
             Number.isInteger(
                 (
                 number
                    price /
            )
                    quantity
        ) {
                ) *
                100
            ) /
            100;


            return String(
                number
            );


         const unit =
         }
            offering.serving_unit ||
            '単位';




         return (
         return String(
             unitPrice +
             Math.round(
             '円/' +
                number * 100
            unit
             ) / 100
         );
         );


1,922行目: 1,993行目:




     /* =====================================
     function formatHours(
    * DOM
        placement
    * ===================================== */
    ) {


    function createTextCell(
        if ( !placement ) {
        tagName,
            return '―';
         text
         }
    ) {


        const cell =
            document.createElement(
                tagName
            );


         cell.textContent =
         const open =
             text;
             placement.opening_time || '';


         return cell;
         const close =
            placement.closing_time || '';


    }


        if (
            open &&
            close
        ) {


    /* =====================================
            return (
    * メニュー
                open +
    * ===================================== */
                '~' +
                close
            );


    function createMenuList(
        }
         menus
 
    ) {
 
         if ( open ) {


        const container =
             return (
             document.createElement(
                open +
                 'div'
                 ''
             );
             );


         container.className =
         }
            'stall-compare-menu-list';




         if (
         if ( close ) {
            !menus ||
            menus.length === 0
        ) {


             container.textContent =
             return (
                 'メニュー未登録';
                 '' +
 
                close
             return container;
             );


         }
         }




         menus.forEach(
         if (
             function ( item ) {
             placement.hours_note
        ) {


                const menu =
            return placement.hours_note;
                    document.createElement(
                        'div'
                    );


                menu.className =
        }
                    'stall-compare-menu-item';




                const name =
        return '未確認';
                    document.createElement(
                        'strong'
                    );


                name.className =
    }
                    'stall-compare-menu-name';


                name.textContent =
                    item.menuName ||
                    '商品';


    function formatPositionStatus(
        status
    ) {


                const price =
        switch ( status ) {
                    document.createElement(
                        'div'
                    );


                price.textContent =
            case 'exact':
                    item.price
                return '正確な位置';
                        ? item.price +
                          ''
                        : '価格未確認';


            case 'approximate':
                return 'おおよその位置';


                 const serving =
            case 'test':
                    document.createElement(
                 return 'テスト位置';
                        'div'
                    );


            default:
                return '位置未確認';


                if (
        }
                    item.servingQuantity
                ) {


                    serving.textContent =
    }
                        '内容量:' +
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );


                } else {


                    serving.textContent =
    function formatVerification(
                        '内容量:未確認';
        status
    ) {


                }
        switch ( status ) {


            case 'verified':
                return '確認済み';


                 const unit =
            case 'partially_verified':
                    document.createElement(
                 return '一部確認済み';
                        'div'
                    );


                 unit.textContent =
            case 'outdated':
                    '1単位あたり:' +
                 return '情報が古い';
                    item.unitPrice;


            default:
                return '未確認';


                const availability =
        }
                    document.createElement(
                        'div'
                    );


                availability.textContent =
    }
                    '販売状況:' +
                    item.availability;




                menu.appendChild(
    function formatAvailability(
                    name
        status
                );
    ) {


                menu.appendChild(
        switch ( status ) {
                    price
                );


                menu.appendChild(
            case 'available':
                    serving
                 return '販売あり';
                 );


                menu.appendChild(
            case 'unavailable':
                    unit
                 return '販売なし';
                 );


                menu.appendChild(
            default:
                    availability
                 return '未確認';
                 );


        }


                container.appendChild(
    }
                    menu
                );


            }
        );


 
     /* =====================================
        return container;
     * 単位価格
 
    }
 
 
     /* =====================================
     * 比較表
     * ===================================== */
     * ===================================== */


     function renderComparison(
     function getUnitPrice(
         compareData
         offering
     ) {
     ) {


         compareRoot.innerHTML =
         const price =
             '';
            Number(
                offering.price
             );


 
         const quantity =
         const heading =
             Number(
             document.createElement(
                 offering.serving_quantity
                 'h2'
             );
             );


        heading.textContent =
            '屋台比較';


        if (
            !Number.isFinite(
                price
            ) ||
            !Number.isFinite(
                quantity
            ) ||
            quantity <= 0
        ) {


        compareRoot.appendChild(
             return '―';
             heading
        );
 


         const wrapper =
         }
            document.createElement(
                'div'
            );


        wrapper.className =
            'stall-compare-table-wrapper';


        const unitPrice =
            Math.round(
                (
                    price /
                    quantity
                ) *
                100
            ) /
            100;


        const table =
            document.createElement(
                'table'
            );


         table.className =
         const unit =
             'stall-compare-table';
            offering.serving_unit ||
             '単位';
 


        return (
            unitPrice +
            '円/' +
            unit
        );


        /* ------------------------------
    }
        * thead
        * ------------------------------ */


        const thead =
            document.createElement(
                'thead'
            );


        const headerRow =
    /* =====================================
            document.createElement(
    * DOM
                'tr'
    * ===================================== */
            );


    function createTextCell(
        tagName,
        text
    ) {


         headerRow.appendChild(
         const cell =
             createTextCell(
             document.createElement(
                 'th',
                 tagName
                '比較項目'
             );
             )
        );


        cell.textContent =
            text;


         compareData.forEach(
         return cell;
            function ( data ) {


                const th =
    }
                    document.createElement(
                        'th'
                    );




                if (
    /* =====================================
                    data.stall &&
    * メニュー
                    data.stall.page_name
    * ===================================== */
                ) {


                    const link =
    function createMenuList(
                        document.createElement(
        menus
                            'a'
    ) {
                        );
 
        const container =
            document.createElement(
                'div'
            );


                    link.href =
        container.className =
                        mw.util.getUrl(
            'stall-compare-menu-list';
                            data.stall
                                .page_name
                        );


                    link.textContent =
                        data.stall
                            .stall_name ||
                        '屋台';


        if (
            !menus ||
            menus.length === 0
        ) {


                    th.appendChild(
            container.textContent =
                        link
                'メニュー未登録';
                    );


                } else {
            return container;


                    th.textContent =
        }
                        data.stall
                            ? data.stall.stall_name
                            : '屋台';


                }


        menus.forEach(
            function ( item ) {


                 headerRow.appendChild(
                 const menu =
                     th
                    document.createElement(
                );
                        'div'
                     );


            }
                menu.className =
        );
                    'stall-compare-menu-item';




        thead.appendChild(
                const name =
            headerRow
                    document.createElement(
        );
                        'strong'
                    );


        table.appendChild(
                name.className =
            thead
                    'stall-compare-menu-name';
        );


 
                name.textContent =
        const tbody =
                    item.menuName ||
            document.createElement(
                    '商品';
                'tbody'
            );




        function addRow(
                const price =
            label,
                    document.createElement(
            getter
                        'div'
        ) {
                    );


            const tr =
                price.textContent =
                document.createElement(
                    item.price
                    'tr'
                        ? item.price +
                );
                          ''
                        : '価格未確認';




            const labelCell =
                const serving =
                createTextCell(
                    document.createElement(
                    'th',
                        'div'
                     label
                     );
                 );
 
 
                if (
                    item.servingQuantity
                 ) {


            labelCell.scope =
                    serving.textContent =
                'row';
                        '内容量:' +
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );


                } else {


            tr.appendChild(
                    serving.textContent =
                labelCell
                        '内容量:未確認';
            );


                }


            compareData.forEach(
                function ( data ) {


                     tr.appendChild(
                const unit =
                         createTextCell(
                     document.createElement(
                            'td',
                         'div'
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                     );
                     );


                 }
                 unit.textContent =
            );
                    '1単位あたり:' +
                    item.unitPrice;




            tbody.appendChild(
                const availability =
                tr
                    document.createElement(
            );
                        'div'
                    );


        }
                availability.textContent =
                    '販売状況:' +
                    item.availability;




        /* =================================
                menu.appendChild(
        * Placement情報
                    name
        * ================================= */
                );


        addRow(
                menu.appendChild(
            '開催年',
                    price
            function ( data ) {
                );


                 return data.placement
                 menu.appendChild(
                     ? data.placement.year +
                     serving
                      '年'
                );
                    : '―';


            }
                menu.appendChild(
        );
                    unit
                );


                menu.appendChild(
                    availability
                );


        addRow(
            '祭り',
            function ( data ) {


                 return data.festival
                 container.appendChild(
                     ? data.festival
                     menu
                        .festival_name
                );
                    : '―';


             }
             }
2,307行目: 2,346行目:




         addRow(
         return container;
            '会場',
            function ( data ) {


                return data.venue
    }
                    ? data.venue
                        .venue_name
                    : '―';


            }
        );


    /* =====================================
    * 比較表
    * ===================================== */


        addRow(
    function renderComparison(
            '地域',
        compareData
            function ( data ) {
    ) {


                return data.area
        compareRoot.innerHTML =
                    ? data.area
            '';
                        .area_name
                    : '';


            }
        );


        const heading =
            document.createElement(
                'h2'
            );


         addRow(
         heading.textContent =
             'カテゴリ',
             '屋台比較';
            function ( data ) {


                return data.stall
                    ? data.stall
                        .category
                    : '―';


             }
        compareRoot.appendChild(
             heading
         );
         );




         addRow(
         const wrapper =
            '出店場所',
            document.createElement(
             function ( data ) {
                'div'
             );


                return data.placement
        wrapper.className =
                    ? data.placement
            'stall-compare-table-wrapper';
                        .location_note
                    : '';


            }
        );


        const table =
            document.createElement(
                'table'
            );


         addRow(
         table.className =
             '営業時間',
            'stall-compare-table';
             function ( data ) {
 
 
        /* ------------------------------
        * thead
        * ------------------------------ */
 
        const thead =
            document.createElement(
                'thead'
            );
 
        const headerRow =
             document.createElement(
                'tr'
             );


                return formatHours(
                    data.placement
                );


             }
        headerRow.appendChild(
             createTextCell(
                'th',
                '比較項目'
            )
         );
         );




         addRow(
         compareData.forEach(
            '位置情報',
             function ( data ) {
             function ( data ) {


                 return data.placement
                 const th =
                     ? formatPositionStatus(
                     document.createElement(
                         data.placement
                         'th'
                            .position_status
                     );
                     )
                    : '―';


            }
        );


                if (
                    data.stall &&
                    data.stall.page_name
                ) {


        addRow(
                    const link =
            '確認状態',
                        document.createElement(
            function ( data ) {
                            'a'
                        );


                return data.placement
                    link.href =
                    ? formatVerification(
                        mw.util.getUrl(
                        data.placement
                            data.stall
                            .verification_status
                                .page_name
                    )
                        );
                    : '―';


            }
                    link.textContent =
        );
                        data.stall
                            .stall_name ||
                        '屋台';




        /* =================================
                    th.appendChild(
        * メニュー
                        link
        * ================================= */
                    );


        const menuRow =
                 } else {
            document.createElement(
                 'tr'
            );


                    th.textContent =
                        data.stall
                            ? data.stall.stall_name
                            : '屋台';


        const menuLabel =
                 }
            createTextCell(
                 'th',
                'メニュー'
            );


        menuLabel.scope =
            'row';


                headerRow.appendChild(
                    th
                );


        menuRow.appendChild(
             }
             menuLabel
         );
         );




         compareData.forEach(
         thead.appendChild(
             function ( data ) {
             headerRow
        );


                const td =
        table.appendChild(
                    document.createElement(
            thead
                        'td'
        );
                    );




                td.appendChild(
        const tbody =
                    createMenuList(
            document.createElement(
                        data.menus
                'tbody'
                    )
            );
                );




                menuRow.appendChild(
        function addRow(
                    td
            label,
                );
            getter
        ) {


             }
             const tr =
        );
                document.createElement(
                    'tr'
                );




        tbody.appendChild(
            const labelCell =
            menuRow
                createTextCell(
        );
                    'th',
                    label
                );


            labelCell.scope =
                'row';


        table.appendChild(
            tbody
        );


        wrapper.appendChild(
            tr.appendChild(
             table
                labelCell
        );
             );


        compareRoot.appendChild(
            wrapper
        );


            compareData.forEach(
                function ( data ) {
                    tr.appendChild(
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );


        const note =
                 }
            document.createElement(
                 'p'
             );
             );


        note.className =
            'stall-compare-note';


        note.textContent =
            tbody.appendChild(
             'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';
                tr
             );


        }


        compareRoot.appendChild(
            note
        );


    }
        /* =================================
        * Placement情報
        * ================================= */


        addRow(
            '開催年',
            function ( data ) {


    function renderMessage(
                return data.placement
        message
                    ? data.placement.year +
    ) {
                      '年'
                    : '―';


         compareRoot.innerHTML =
            }
            '';
         );




         const p =
         addRow(
             document.createElement(
             '祭り',
                'p'
             function ( data ) {
             );


        p.className =
                return data.festival
            'stall-compare-page-message';
                    ? data.festival
                        .festival_name
                    : '';


         p.textContent =
            }
            message;
         );




         compareRoot.appendChild(
         addRow(
             p
            '会場',
            function ( data ) {
 
                return data.venue
                    ? data.venue
                        .venue_name
                    : '―';
 
             }
         );
         );


    }


        addRow(
            '地域',
            function ( data ) {


    /* =====================================
                return data.area
    * データ取得
                    ? data.area
    * ===================================== */
                        .area_name
                    : '―';


    const placementIds =
            }
         getPlacementIds();
         );




    if (
        addRow(
        placementIds.length <
            'カテゴリ',
        MIN_COMPARE
            function ( data ) {
    ) {
 
                return data.stall
                    ? data.stall
                        .category
                    : '―';


        renderMessage(
             }
             '比較する出店を2件以上選択してください。'
         );
         );


        return;


    }
        addRow(
            '出店場所',
            function ( data ) {


                return data.placement
                    ? data.placement
                        .location_note
                    : '―';


    /*
            }
    * STEP 1
        );
    * Placementを直接取得
    */
    cargoQuery(


        'FestivalStallPlacements',


         'placement_id=placement_id,' +
         addRow(
        'stall_id=stall_id,' +
            '営業時間',
        'festival_id=festival_id,' +
            function ( data ) {
        'venue_id=venue_id,' +
        'year=year,' +
        'latitude=latitude,' +
        'longitude=longitude,' +
        'location_note=location_note,' +
        'opening_time=opening_time,' +
        'closing_time=closing_time,' +
        'hours_note=hours_note,' +
        'position_status=position_status,' +
        'verification_status=verification_status',


        'placement_id IN (' +
                return formatHours(
        makeInClause(
                    data.placement
            placementIds
                );
        ) +
        ')',


         100
            }
         );


    ).then(
        function ( placements ) {


            const stallIds =
        addRow(
                uniqueIds(
            '位置情報',
                    placements.map(
            function ( data ) {
                        function ( row ) {
 
                            return row.stall_id;
                return data.placement
                         }
                    ? formatPositionStatus(
                         data.placement
                            .position_status
                     )
                     )
                );
                    : '―';


            }
        );


            const festivalIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.festival_id;
                        }
                    )
                );


        addRow(
            '確認状態',
            function ( data ) {


            const venueIds =
                 return data.placement
                 uniqueIds(
                     ? formatVerification(
                     placements.map(
                         data.placement
                         function ( row ) {
                             .verification_status
                             return row.venue_id;
                        }
                     )
                     )
                );
                    : '―';


            }
        );


            /*
            * STEP 2
            */
            return Promise.all( [


                stallIds.length
        /* =================================
                    ? cargoQuery(
        * メニュー
        * ================================= */


                        'Stalls',
        const menuRow =
            document.createElement(
                'tr'
            );


                        'stall_id=stall_id,' +
                        'name=stall_name,' +
                        'category=category,' +
                        '_pageName=page_name',


                        'stall_id IN (' +
        const menuLabel =
                        makeInClause(
            createTextCell(
                            stallIds
                'th',
                        ) +
                'メニュー'
                        ')',
            );


                        100
        menuLabel.scope =
            'row';


                    )
                    : Promise.resolve(
                        []
                    ),


        menuRow.appendChild(
            menuLabel
        );


                festivalIds.length
                    ? cargoQuery(


                        'Festivals',
        compareData.forEach(
            function ( data ) {


                        'festival_id=festival_id,' +
                const td =
                         'name=festival_name,' +
                    document.createElement(
                        '_pageName=page_name',
                         'td'
                    );


                        'festival_id IN (' +
                        makeInClause(
                            festivalIds
                        ) +
                        ')',
                        100


                td.appendChild(
                    createMenuList(
                        data.menus
                     )
                     )
                    : Promise.resolve(
                );
                        []
                    ),




                 venueIds.length
                 menuRow.appendChild(
                     ? cargoQuery(
                     td
                );


                        'Venues',
            }
        );


                        'venue_id=venue_id,' +
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',


                        'venue_id IN (' +
        tbody.appendChild(
                        makeInClause(
            menuRow
                            venueIds
        );
                        ) +
                        ')',


                        100


                    )
        table.appendChild(
                    : Promise.resolve(
            tbody
                        []
        );
                    ),


        wrapper.appendChild(
            table
        );


                cargoQuery(
        compareRoot.appendChild(
            wrapper
        );


                    'FestivalStallMenuOfferings',


                    'offering_id=offering_id,' +
        const note =
                    'placement_id=placement_id,' +
            document.createElement(
                    'menu_item_id=menu_item_id,' +
                'p'
                    'price=price,' +
            );
                    'serving_quantity=serving_quantity,' +
                    'serving_unit=serving_unit,' +
                    'serving_note=serving_note,' +
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',


                    'placement_id IN (' +
        note.className =
                    makeInClause(
            'stall-compare-note';
                        placementIds
                    ) +
                    ')',


                    100
        note.textContent =
            'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';


                )


            ] ).then(
        compareRoot.appendChild(
                function ( results ) {
            note
        );


                    return {
    }


                        placements:
                            placements,


                        stalls:
    function renderMessage(
                            results[ 0 ],
        message
    ) {


                        festivals:
        compareRoot.innerHTML =
                            results[ 1 ],
            '';


                        venues:
                            results[ 2 ],


                        offerings:
        const p =
                            results[ 3 ]
            document.createElement(
                'p'
            );
 
        p.className =
            'stall-compare-page-message';


                    };
        p.textContent =
            message;


                }
            );


         }
         compareRoot.appendChild(
    ).then(
            p
         function ( data ) {
         );


            const areaIds =
    }
                uniqueIds(
                    data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                    )
                );




            const menuItemIds =
    /* =====================================
                uniqueIds(
    * データ取得
                    data.offerings.map(
    * ===================================== */
                        function ( row ) {
 
                            return row.menu_item_id;
    const placementIds =
                        }
        getPlacementIds();
                    )
 
                );


    if (
        placementIds.length <
        MIN_COMPARE
    ) {


             /*
        renderMessage(
            * STEP 3
             '比較する出店を2件以上選択してください。'
            */
        );
            return Promise.all( [


                areaIds.length
        return;
                    ? cargoQuery(


                        'Areas',
    }


                        'area_id=area_id,' +
                        'name=area_name,' +
                        '_pageName=page_name',


                        'area_id IN (' +
    /*
                        makeInClause(
    * STEP 1
                            areaIds
    * Placementを直接取得
                        ) +
    */
                        ')',
    cargoQuery(


                        100
        'FestivalStallPlacements',


                    )
        'placement_id=placement_id,' +
                    : Promise.resolve(
        'stall_id=stall_id,' +
                        []
        'festival_id=festival_id,' +
                    ),
        'venue_id=venue_id,' +
        'year=year,' +
        'latitude=latitude,' +
        'longitude=longitude,' +
        'location_note=location_note,' +
        'opening_time=opening_time,' +
        'closing_time=closing_time,' +
        'hours_note=hours_note,' +
        'position_status=position_status,' +
        'verification_status=verification_status',


        'placement_id IN (' +
        makeInClause(
            placementIds
        ) +
        ')',


                menuItemIds.length
        100
                    ? cargoQuery(
 
                        'StallMenuItems',


                        'menu_item_id=menu_item_id,' +
    ).then(
                        'stall_id=stall_id,' +
        function ( placements ) {
                        'name=menu_name,' +
                        'item_category=item_category,' +
                        'sort_order=sort_order',


                        'menu_item_id IN (' +
            const stallIds =
                         makeInClause(
                uniqueIds(
                             menuItemIds
                    placements.map(
                         ) +
                         function ( row ) {
                        ')',
                             return row.stall_id;
                         }
                    )
                );


                        100


            const festivalIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.festival_id;
                        }
                     )
                     )
                    : Promise.resolve(
                );
                        []
                    )


            ] ).then(
                function ( results ) {


                     data.areas =
            const venueIds =
                         results[ 0 ];
                uniqueIds(
                     placements.map(
                        function ( row ) {
                            return row.venue_id;
                         }
                    )
                );


                    data.menuItems =
                        results[ 1 ];


                    return data;
            /*
            * STEP 2
            */
            return Promise.all( [


                 }
                 stallIds.length
            );
                    ? cargoQuery(


        }
                        'Stalls',
    ).then(
        function ( data ) {


            const placementMap =
                        'stall_id=stall_id,' +
                mapBy(
                        'name=stall_name,' +
                    data.placements,
                        'category=category,' +
                    'placement_id'
                        '_pageName=page_name',
                );


                        'stall_id IN (' +
                        makeInClause(
                            stallIds
                        ) +
                        ')',


            const stallMap =
                        100
                mapBy(
                    data.stalls,
                    'stall_id'
                );


                    )
                    : Promise.resolve(
                        []
                    ),


            const festivalMap =
                mapBy(
                    data.festivals,
                    'festival_id'
                );


                festivalIds.length
                    ? cargoQuery(


            const venueMap =
                        'Festivals',
                mapBy(
                    data.venues,
                    'venue_id'
                );


                        'festival_id=festival_id,' +
                        'name=festival_name,' +
                        '_pageName=page_name',


            const areaMap =
                        'festival_id IN (' +
                mapBy(
                        makeInClause(
                    data.areas,
                            festivalIds
                    'area_id'
                        ) +
                );
                        ')',


                        100


            const menuMap =
                    )
                mapBy(
                    : Promise.resolve(
                    data.menuItems,
                        []
                     'menu_item_id'
                     ),
                );




            /*
                venueIds.length
            * Offering
                    ? cargoQuery(
            * placement単位
            */
            const offeringsByPlacement =
                {};


                        'Venues',


            data.offerings
                        'venue_id=venue_id,' +
                .slice()
                        'name=venue_name,' +
                .sort(
                        'area_id=area_id,' +
                    function ( a, b ) {
                        '_pageName=page_name',


                         return (
                         'venue_id IN (' +
                            Number(
                        makeInClause(
                                a.sort_order || 0
                             venueIds
                             ) -
                        ) +
                            Number(
                         ')',
                                b.sort_order || 0
                            )
                         );


                    }
                        100
                )
                .forEach(
                    function ( offering ) {


                        const placementId =
                    )
                            String(
                    : Promise.resolve(
                                offering
                        []
                                    .placement_id
                    ),
                            );




                        if (
                cargoQuery(
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {


                            offeringsByPlacement[
                    'FestivalStallMenuOfferings',
                                placementId
                            ] = [];


                        }
                    'offering_id=offering_id,' +
 
                    'placement_id=placement_id,' +
 
                    'menu_item_id=menu_item_id,' +
                        offeringsByPlacement[
                    'price=price,' +
                            placementId
                    'serving_quantity=serving_quantity,' +
                        ].push(
                    'serving_unit=serving_unit,' +
                            offering
                    'serving_note=serving_note,' +
                        );
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',


                     }
                     'placement_id IN (' +
                );
                    makeInClause(
                        placementIds
                    ) +
                    ')',


                    100


            /*
                 )
            * localStorage順を維持
            */
            const compareData =
                 placementIds.map(
                    function (
                        placementId
                    ) {


                        const placement =
            ] ).then(
                            placementMap[
                function ( results ) {
                                placementId
                            ] || null;


                    return {


                         if ( !placement ) {
                         placements:
                            placements,


                             return {
                        stalls:
                             results[ 0 ],


                                placementId:
                        festivals:
                                    placementId,
                            results[ 1 ],


                                placement:
                        venues:
                                    null,
                            results[ 2 ],


                                stall:
                        offerings:
                                    null,
                            results[ 3 ]


                                festival:
                    };
                                    null,


                                venue:
                }
                                    null,
            );


                                area:
        }
                                    null,
    ).then(
        function ( data ) {


                                menus:
            const areaIds =
                                    []
                uniqueIds(
                    data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                    )
                );


                            };


            const menuItemIds =
                uniqueIds(
                    data.offerings.map(
                        function ( row ) {
                            return row.menu_item_id;
                         }
                         }
                    )
                );




                        const stall =
            /*
                            stallMap[
            * STEP 3
                                String(
            */
                                    placement.stall_id
            return Promise.all( [
                                )
                            ] || null;


                areaIds.length
                    ? cargoQuery(


                         const festival =
                         'Areas',
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        'area_id=area_id,' +
                        'name=area_name,' +
                        '_pageName=page_name',


                         const venue =
                         'area_id IN (' +
                            venueMap[
                        makeInClause(
                                String(
                            areaIds
                                    placement.venue_id
                        ) +
                                )
                        ')',
                            ] || null;


                        100


                         let area = null;
                    )
                    : Promise.resolve(
                         []
                    ),




                        if (
                menuItemIds.length
                            venue &&
                    ? cargoQuery(
                            venue.area_id
                        ) {


                            area =
                        'StallMenuItems',
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;


                         }
                         'menu_item_id=menu_item_id,' +
                        'stall_id=stall_id,' +
                        'name=menu_name,' +
                        'item_category=item_category,' +
                        'sort_order=sort_order',


                        'menu_item_id IN (' +
                        makeInClause(
                            menuItemIds
                        ) +
                        ')',


                         const offerings =
                         100
                            offeringsByPlacement[
                                placementId
                            ] || [];


                    )
                    : Promise.resolve(
                        []
                    )


                        const menus =
            ] ).then(
                            offerings.map(
                function ( results ) {
                                function (
                                    offering
                                ) {


                                    const menu =
                    data.areas =
                                        menuMap[
                        results[ 0 ];
                                            String(
                                                offering
                                                    .menu_item_id
                                            )
                                        ] || {};


                    data.menuItems =
                        results[ 1 ];


                                    return {
                    return data;


                                        menuName:
                }
                                            menu.menu_name ||
            );
                                            '商品',


                                        category:
        }
                                            menu.item_category ||
    ).then(
                                            '',
        function ( data ) {


                                        price:
            const placementMap =
                                            cleanNumber(
                mapBy(
                                                offering.price
                    data.placements,
                                            ),
                    'placement_id'
                );


                                        servingQuantity:
                                            cleanNumber(
                                                offering
                                                    .serving_quantity
                                            ),


                                        servingUnit:
            const stallMap =
                                            offering
                mapBy(
                                                .serving_unit ||
                    data.stalls,
                                            '',
                    'stall_id'
                );


                                        servingNote:
                                            offering
                                                .serving_note ||
                                            '',


                                        unitPrice:
            const festivalMap =
                                            getUnitPrice(
                mapBy(
                                                offering
                    data.festivals,
                                            ),
                    'festival_id'
                );


                                        availability:
                                            formatAvailability(
                                                offering
                                                    .availability
                                            ),


                                        verification:
            const venueMap =
                                            formatVerification(
                mapBy(
                                                offering
                    data.venues,
                                                    .verification_status
                    'venue_id'
                                            )
                );


                                    };


                                }
            const areaMap =
                            );
                mapBy(
                    data.areas,
                    'area_id'
                );




                        return {
            const menuMap =
 
                mapBy(
                            placementId:
                    data.menuItems,
                                placementId,
                    'menu_item_id'
                );


                            placement:
                                placement,


                            stall:
            /*
                                stall,
            * Offering
            * placement単位
            */
            const offeringsByPlacement =
                {};


                            festival:
                                festival,


                            venue:
            data.offerings
                                venue,
                .slice()
                .sort(
                    function ( a, b ) {


                             area:
                        return (
                                 area,
                             Number(
 
                                 a.sort_order || 0
                             menus:
                            ) -
                                 menus
                             Number(
 
                                 b.sort_order || 0
                         };
                            )
                         );


                     }
                     }
                 );
                 )
                .forEach(
                    function ( offering ) {


                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


            renderComparison(
                compareData
            );


        }
                        if (
    ).catch(
                            !offeringsByPlacement[
        function ( error ) {
                                placementId
                            ]
                        ) {


            console.error(
                            offeringsByPlacement[
                'Placement比較データ取得エラー:',
                                placementId
                error
                            ] = [];
            );


                        }


            renderMessage(
                '比較データの取得中にエラーが発生しました。'
            );


        }
                        offeringsByPlacement[
    );
                            placementId
                        ].push(
                            offering
                        );


/* =====================================
                    }
* 地図popup 比較リンク
                );
* ===================================== */


function getMapComparePlacementId( link ) {


    if ( !link ) {
            /*
        return '';
            * localStorage順を維持
    }
            */
            const compareData =
                placementIds.map(
                    function (
                        placementId
                    ) {


    const href =
                        const placement =
        link.getAttribute( 'href' ) || '';
                            placementMap[
                                placementId
                            ] || null;


    const match =
        href.match(
            /#compare-placement-(\d+)$/
        );


    if ( !match ) {
                        if ( !placement ) {
        return '';
    }


    return match[ 1 ];
                            return {


}
                                placementId:
                                    placementId,


                                placement:
                                    null,


/* =====================================
                                stall:
* 地図popupの選択状態を更新
                                    null,
* ===================================== */


function updateMapCompareLinks() {
                                festival:
                                    null,


    const ids =
                                venue:
        getComparePlacements();
                                    null,


    document
                                area:
        .querySelectorAll(
                                    null,
            'a[href*="#compare-placement-"]'
        )
        .forEach(
            function ( link ) {


                const placementId =
                                menus:
                    getMapComparePlacementId(
                                    []
                        link
                    );


                if (
                            };
                    !placementId ||
                    placementId === '0'
                ) {
                    return;
                }


                const selected =
                         }
                    ids.includes(
                         placementId
                    );


                link.classList.add(
                    'stall-map-compare-link'
                );


                link.dataset.placementId =
                        const stall =
                    placementId;
                            stallMap[
                                String(
                                    placement.stall_id
                                )
                            ] || null;


                if ( selected ) {


                    link.textContent =
                        const festival =
                        '比較から外す';
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                    link.classList.add(
                        'stall-map-compare-link-selected'
                    );


                    link.setAttribute(
                        const venue =
                        'aria-pressed',
                            venueMap[
                        'true'
                                String(
                    );
                                    placement.venue_id
                                )
                            ] || null;


                } else {


                    link.textContent =
                        let area = null;
                        '比較に追加';


                    link.classList.remove(
                        'stall-map-compare-link-selected'
                    );


                    link.setAttribute(
                        if (
                        'aria-pressed',
                            venue &&
                         'false'
                            venue.area_id
                    );
                         ) {


                }
                            area =
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;


            }
                        }
        );


}


                        const offerings =
                            offeringsByPlacement[
                                placementId
                            ] || [];


/* =====================================
* 地図popupから比較追加・解除
* ===================================== */


document.addEventListener(
                        const menus =
    'click',
                            offerings.map(
    function ( event ) {
                                function (
                                    offering
                                ) {


        const link =
                                    const menu =
            event.target.closest(
                                        menuMap[
                'a[href*="#compare-placement-"]'
                                            String(
            );
                                                offering
                                                    .menu_item_id
                                            )
                                        ] || {};


        if ( !link ) {
            return;
        }


        const placementId =
                                    return {
            getMapComparePlacementId(
                link
            );


        if (
                                        menuName:
            !placementId ||
                                            menu.menu_name ||
            placementId === '0'
                                            '商品',
        ) {
            return;
        }


        event.preventDefault();
                                        category:
                                            menu.item_category ||
                                            '',


        let ids =
                                        price:
            getComparePlacements();
                                            cleanNumber(
                                                offering.price
                                            ),


        const index =
                                        servingQuantity:
            ids.indexOf(
                                            cleanNumber(
                placementId
                                                offering
            );
                                                    .serving_quantity
                                            ),


                                        servingUnit:
                                            offering
                                                .serving_unit ||
                                            '',


        /*
                                        servingNote:
        * 登録済みなら解除
                                            offering
        */
                                                .serving_note ||
        if ( index !== -1 ) {
                                            '',
 
            ids.splice(
                index,
                1
            );


        } else {
                                        unitPrice:
                                            getUnitPrice(
                                                offering
                                            ),


            /*
                                        availability:
            * 最大4件
                                            formatAvailability(
            */
                                                offering
            if (
                                                    .availability
                ids.length >=
                                            ),
                MAX_COMPARE
            ) {


                link.textContent =
                                        verification:
                    '最大4件までです';
                                            formatVerification(
                                                offering
                                                    .verification_status
                                            )


                return;
                                    };
            }


            ids.push(
                                }
                placementId
                            );
            );


        }


                        return {


        saveComparePlacements(
                            placementId:
            ids
                                placementId,
        );


                            placement:
                                placement,


        /*
                            stall:
        * 全表示を同期
                                stall,
        */
        updateCompareButtons();
        updateMapCompareLinks();
        updateCompareTray();


    }
                            festival:
);
                                festival,


                            venue:
                                venue,


/* =====================================
                            area:
* Leaflet popup生成監視
                                area,
* ===================================== */
 
                            menus:
                                menus


const mapCompareLinkObserver =
                        };
    new MutationObserver(
        function ( mutations ) {


            let needsUpdate =
                    }
                 false;
                 );


            mutations.forEach(
                function ( mutation ) {


                    mutation.addedNodes.forEach(
            renderComparison(
                        function ( node ) {
                compareData
            );


                            if (
        }
                                node.nodeType !== 1
    ).catch(
                            ) {
        function ( error ) {
                                return;
                            }


                            if (
            console.error(
                                node.matches &&
                'Placement比較データ取得エラー:',
                                node.matches(
                error
                                    'a[href*="#compare-placement-"]'
            );
                                )
                            ) {


                                needsUpdate =
                                    true;
                                return;


                            }
            renderMessage(
 
                '比較データの取得中にエラーが発生しました。'
                            if (
                                node.querySelector &&
                                node.querySelector(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {
 
                                needsUpdate =
                                    true;
 
                            }
 
                        }
                    );
 
                }
             );
             );
            if ( needsUpdate ) {
                updateMapCompareLinks();
            }


         }
         }
3,387行目: 3,391行目:




mapCompareLinkObserver.observe(
    document.body,
    {
        childList: true,
        subtree: true
    }
);


} );
} );

2026年8月12日 (水) 13:17時点における版

/* ========================================
 * 屋台比較
 * placement_id 正式版
 *
 * 最大4出店
 *
 * localStorage:
 * matsuriWikiComparePlacements
 *
 * 1 placement =
 * 1 festival + 1 year + 1 venue + 1 stall
 * ======================================== */

mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {

    'use strict';

    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MAX_COMPARE = 4;

    const api =
        new mw.Api();

    /*
     * ページ表示中だけ使うキャッシュ
     */
    const placementInfoCache = {};


    /* =====================================
     * localStorage
     * ===================================== */

    function getComparePlacements() {

        const raw =
            mw.storage.get(
                STORAGE_KEY
            );

        if ( !raw ) {
            return [];
        }

        try {

            const ids =
                JSON.parse( raw );

            if ( !Array.isArray( ids ) ) {
                return [];
            }

            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test( id );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );

        } catch ( e ) {

            return [];

        }

    }


    function saveComparePlacements( ids ) {

        mw.storage.set(
            STORAGE_KEY,
            JSON.stringify( ids )
        );

    }


    /* =====================================
     * Cargo共通処理
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };

        if ( where ) {
            params.where = where;
        }

        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }

                return data.cargoquery.map(
                    function ( item ) {
                        return item.title || item;
                    }
                );

            }
        );

    }


    function makeInClause( ids ) {

        return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test( id );
                }
            )
            .join( ',' );

    }


    function uniqueIds( values ) {

        return [
            ...new Set(
                values
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test( id );
                        }
                    )
            )
        ];

    }


    function mapBy( rows, key ) {

        const result = {};

        rows.forEach(
            function ( row ) {

                if (
                    row[ key ] === undefined ||
                    row[ key ] === null
                ) {
                    return;
                }

                result[
                    String(
                        row[ key ]
                    )
                ] = row;

            }
        );

        return result;

    }


    /* =====================================
     * Placement情報取得
     * ===================================== */

    function fetchPlacementInfo( ids ) {

        if ( ids.length === 0 ) {
            return Promise.resolve( {} );
        }

        const result = {};
        const missingIds = [];

        ids.forEach(
            function ( id ) {

                if (
                    placementInfoCache[ id ]
                ) {

                    result[ id ] =
                        placementInfoCache[ id ];

                } else {

                    missingIds.push( id );

                }

            }
        );


        if ( missingIds.length === 0 ) {

            return Promise.resolve(
                result
            );

        }


        return cargoQuery(

            'FestivalStallPlacements',

            'placement_id=placement_id,' +
            'stall_id=stall_id,' +
            'festival_id=festival_id,' +
            'venue_id=venue_id,' +
            'year=year,' +
            'location_note=location_note',

            'placement_id IN (' +
            makeInClause(
                missingIds
            ) +
            ')',

            100

        ).then(
            function ( placements ) {

                const stallIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.stall_id;
                            }
                        )
                    );

                const festivalIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.festival_id;
                            }
                        )
                    );

                const venueIds =
                    uniqueIds(
                        placements.map(
                            function ( row ) {
                                return row.venue_id;
                            }
                        )
                    );


                return Promise.all( [

                    stallIds.length
                        ? cargoQuery(

                            'Stalls',

                            'stall_id=stall_id,' +
                            'name=stall_name,' +
                            '_pageName=page_name',

                            'stall_id IN (' +
                            makeInClause(
                                stallIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] ),


                    festivalIds.length
                        ? cargoQuery(

                            'Festivals',

                            'festival_id=festival_id,' +
                            'name=festival_name,' +
                            '_pageName=page_name',

                            'festival_id IN (' +
                            makeInClause(
                                festivalIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] ),


                    venueIds.length
                        ? cargoQuery(

                            'Venues',

                            'venue_id=venue_id,' +
                            'name=venue_name,' +
                            '_pageName=page_name',

                            'venue_id IN (' +
                            makeInClause(
                                venueIds
                            ) +
                            ')',

                            100

                        )
                        : Promise.resolve( [] )

                ] ).then(
                    function ( related ) {

                        return {

                            placements:
                                placements,

                            stalls:
                                related[ 0 ],

                            festivals:
                                related[ 1 ],

                            venues:
                                related[ 2 ]

                        };

                    }
                );

            }
        ).then(
            function ( data ) {

                const stallMap =
                    mapBy(
                        data.stalls,
                        'stall_id'
                    );

                const festivalMap =
                    mapBy(
                        data.festivals,
                        'festival_id'
                    );

                const venueMap =
                    mapBy(
                        data.venues,
                        'venue_id'
                    );


                data.placements.forEach(
                    function ( placement ) {

                        const placementId =
                            String(
                                placement.placement_id
                            );

                        const info = {

                            placement:
                                placement,

                            stall:
                                stallMap[
                                    String(
                                        placement.stall_id
                                    )
                                ] || null,

                            festival:
                                festivalMap[
                                    String(
                                        placement.festival_id
                                    )
                                ] || null,

                            venue:
                                venueMap[
                                    String(
                                        placement.venue_id
                                    )
                                ] || null

                        };


                        placementInfoCache[
                            placementId
                        ] = info;

                        result[
                            placementId
                        ] = info;

                    }
                );


                return result;

            }
        );

    }


    /* =====================================
     * メッセージ
     * ===================================== */

    function showMessage(
        control,
        message,
        isError
    ) {

        const element =
            control.querySelector(
                '.stall-compare-message'
            );

        if ( !element ) {
            return;
        }

        element.textContent =
            message;

        element.classList.toggle(
            'stall-compare-message-error',
            Boolean( isError )
        );

    }


/* =====================================
 * 比較ボタン生成
 *
 * 通常ページ
 *   stall-compare-placeholder-1
 *
 * 地図popup
 *   stall-map-compare-placeholder-1
 *
 * の両方に対応
 * ===================================== */

function createCompareButtons() {

    document
        .querySelectorAll(
            '.stall-compare-placeholder, ' +
            '.stall-map-compare-placeholder'
        )
        .forEach(
            function ( placeholder ) {

                /*
                 * すでにbutton生成済み
                 */
                if (
                    placeholder.dataset.initialized ===
                    '1'
                ) {
                    return;
                }


                let placementId = '';


                /*
                 * 通常ページ
                 */
                const normalPrefix =
                    'stall-compare-placeholder-';


                /*
                 * 地図popup
                 */
                const mapPrefix =
                    'stall-map-compare-placeholder-';


                if (
                    placeholder.id.startsWith(
                        normalPrefix
                    )
                ) {

                    placementId =
                        placeholder.id.substring(
                            normalPrefix.length
                        );

                } else if (
                    placeholder.id.startsWith(
                        mapPrefix
                    )
                ) {

                    placementId =
                        placeholder.id.substring(
                            mapPrefix.length
                        );

                } else {

                    return;

                }


                /*
                 * placement_idチェック
                 */
                if (
                    !/^\d+$/.test(
                        placementId
                    ) ||
                    placementId === '0'
                ) {
                    return;
                }


                /*
                 * 本物のbuttonを
                 * JavaScript側で作る
                 */
                const button =
                    document.createElement(
                        'button'
                    );


                button.type =
                    'button';


                button.className =
                    'stall-compare-button';


                button.dataset.placementId =
                    placementId;


                button.setAttribute(
                    'aria-pressed',
                    'false'
                );


                button.textContent =
                    '比較に追加';


                placeholder.appendChild(
                    button
                );


                /*
                 * 二重生成防止
                 */
                placeholder.dataset.initialized =
                    '1';

            }
        );

}

    /* =====================================
     * ボタン状態更新
     * ===================================== */

    function updateCompareButtons() {

        const ids =
            getComparePlacements();


        document
            .querySelectorAll(
                '.stall-compare-button'
            )
            .forEach(
                function ( button ) {

                    const placementId =
                        String(
                            button.dataset
                                .placementId || ''
                        );


                    const selected =
                        ids.includes(
                            placementId
                        );


                    if ( selected ) {

                        button.textContent =
                            '比較から外す';

                        button.classList.add(
                            'stall-compare-button-selected'
                        );

                        button.setAttribute(
                            'aria-pressed',
                            'true'
                        );

                    } else {

                        button.textContent =
                            '比較に追加';

                        button.classList.remove(
                            'stall-compare-button-selected'
                        );

                        button.setAttribute(
                            'aria-pressed',
                            'false'
                        );

                    }

                }
            );

    }


    /* =====================================
     * 固定比較トレイ生成
     * ===================================== */

    function createCompareTray() {

        if (
            document.getElementById(
                'stall-compare-tray'
            )
        ) {
            return;
        }


        const tray =
            document.createElement(
                'div'
            );

        tray.id =
            'stall-compare-tray';

        tray.className =
            'stall-compare-tray';

        tray.setAttribute(
            'aria-live',
            'polite'
        );


        const header =
            document.createElement(
                'div'
            );

        header.className =
            'stall-compare-tray-header';


        const title =
            document.createElement(
                'strong'
            );

        title.className =
            'stall-compare-tray-title';

        title.textContent =
            '比較候補';


        const count =
            document.createElement(
                'span'
            );

        count.className =
            'stall-compare-tray-count';


        header.appendChild(
            title
        );

        header.appendChild(
            count
        );


        const items =
            document.createElement(
                'div'
            );

        items.className =
            'stall-compare-tray-items';


        const actions =
            document.createElement(
                'div'
            );

        actions.className =
            'stall-compare-tray-actions';


        const clearButton =
            document.createElement(
                'button'
            );

        clearButton.type =
            'button';

        clearButton.className =
            'stall-compare-clear';

        clearButton.textContent =
            'すべて外す';


        const compareLink =
            document.createElement(
                'a'
            );

        compareLink.className =
            'stall-compare-open';

        compareLink.href =
            mw.util.getUrl(
                '屋台比較'
            );

        compareLink.textContent =
            '比較する';


        actions.appendChild(
            clearButton
        );

        actions.appendChild(
            compareLink
        );


        tray.appendChild(
            header
        );

        tray.appendChild(
            items
        );

        tray.appendChild(
            actions
        );


        document.body.appendChild(
            tray
        );

    }


    /* =====================================
     * 比較トレイ更新
     * ===================================== */

    function updateCompareTray() {

        createCompareTray();


        const tray =
            document.getElementById(
                'stall-compare-tray'
            );

        if ( !tray ) {
            return;
        }


        const ids =
            getComparePlacements();


        const count =
            tray.querySelector(
                '.stall-compare-tray-count'
            );

        const items =
            tray.querySelector(
                '.stall-compare-tray-items'
            );

        const compareLink =
            tray.querySelector(
                '.stall-compare-open'
            );


        if ( count ) {

            count.textContent =
                ids.length +
                ' / ' +
                MAX_COMPARE;

        }


        if ( ids.length === 0 ) {

            tray.classList.remove(
                'stall-compare-tray-visible'
            );

            if ( items ) {
                items.innerHTML = '';
            }

            return;

        }


        tray.classList.add(
            'stall-compare-tray-visible'
        );


        if ( compareLink ) {

            if ( ids.length >= 2 ) {

                compareLink.classList.remove(
                    'stall-compare-open-disabled'
                );

                compareLink.setAttribute(
                    'aria-disabled',
                    'false'
                );

            } else {

                compareLink.classList.add(
                    'stall-compare-open-disabled'
                );

                compareLink.setAttribute(
                    'aria-disabled',
                    'true'
                );

            }

        }


        if ( !items ) {
            return;
        }


        items.textContent =
            '屋台情報を読み込み中…';


        fetchPlacementInfo(
            ids
        ).then(
            function ( placementInfo ) {

                const currentIds =
                    getComparePlacements();

                items.innerHTML =
                    '';


                currentIds.forEach(
                    function ( placementId ) {

                        const info =
                            placementInfo[
                                placementId
                            ];


                        if ( !info ) {
                            return;
                        }


                        const item =
                            document.createElement(
                                'div'
                            );

                        item.className =
                            'stall-compare-tray-item';


                        const text =
                            document.createElement(
                                'div'
                            );


                        const name =
                            document.createElement(
                                info.stall &&
                                info.stall.page_name
                                    ? 'a'
                                    : 'span'
                            );


                        if (
                            info.stall &&
                            info.stall.page_name
                        ) {

                            name.href =
                                mw.util.getUrl(
                                    info.stall.page_name
                                );

                        }


                        name.className =
                            'stall-compare-tray-item-name';

                        name.textContent =
                            info.stall
                                ? info.stall.stall_name
                                : '屋台';


                        const context =
                            document.createElement(
                                'div'
                            );

                        context.className =
                            'stall-compare-tray-item-context';


                        const parts = [];


                        if (
                            info.placement &&
                            info.placement.year
                        ) {

                            parts.push(
                                info.placement.year +
                                '年'
                            );

                        }


                        if (
                            info.festival &&
                            info.festival.festival_name
                        ) {

                            parts.push(
                                info.festival
                                    .festival_name
                            );

                        }


                        if (
                            info.venue &&
                            info.venue.venue_name
                        ) {

                            parts.push(
                                info.venue
                                    .venue_name
                            );

                        }


                        context.textContent =
                            parts.join(
                                ' / '
                            );


                        text.appendChild(
                            name
                        );

                        text.appendChild(
                            context
                        );


                        const removeButton =
                            document.createElement(
                                'button'
                            );

                        removeButton.type =
                            'button';

                        removeButton.className =
                            'stall-compare-tray-remove';

                        removeButton.dataset.placementId =
                            placementId;

                        removeButton.setAttribute(
                            'aria-label',
                            (
                                info.stall
                                    ? info.stall.stall_name
                                    : '屋台'
                            ) +
                            'を比較候補から外す'
                        );

                        removeButton.textContent =
                            '×';


                        item.appendChild(
                            text
                        );

                        item.appendChild(
                            removeButton
                        );

                        items.appendChild(
                            item
                        );

                    }
                );

            }
        ).catch(
            function ( error ) {

                console.error(
                    '比較トレイ取得エラー:',
                    error
                );

                items.textContent =
                    '比較候補を読み込めませんでした。';

            }
        );

    }


    /* =====================================
     * 詳細ページ
     * 「比較に追加」
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-button'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            const placementId =
                String(
                    button.dataset
                        .placementId || ''
                );


            if (
                !/^\d+$/.test(
                    placementId
                )
            ) {
                return;
            }


            const control =
                button.closest(
                    '.stall-compare-control'
                );


            let ids =
                getComparePlacements();


            const index =
                ids.indexOf(
                    placementId
                );


            /*
             * すでに選択中
             */
            if ( index !== -1 ) {

                ids.splice(
                    index,
                    1
                );

                saveComparePlacements(
                    ids
                );

                updateCompareButtons();
                updateMapCompareLinks();
                updateCompareTray();


                if ( control ) {

                    showMessage(
                        control,
                        '比較候補から外しました。',
                        false
                    );

                }

                return;

            }


            /*
             * 最大4件
             */
            if (
                ids.length >=
                MAX_COMPARE
            ) {

                if ( control ) {

                    showMessage(
                        control,
                        '比較できる出店は最大4件です。',
                        true
                    );

                }

                return;

            }


            ids.push(
                placementId
            );


            saveComparePlacements(
                ids
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();


            if ( control ) {

                showMessage(
                    control,
                    '比較候補に追加しました(' +
                    ids.length +
                    '/4)。',
                    false
                );

            }

        }
    );


    /* =====================================
     * トレイから1件削除
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-tray-remove'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            const placementId =
                String(
                    button.dataset
                        .placementId || ''
                );


            let ids =
                getComparePlacements();


            ids =
                ids.filter(
                    function ( id ) {
                        return id !== placementId;
                    }
                );


            saveComparePlacements(
                ids
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();

        }
    );


    /* =====================================
     * 全削除
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const button =
                event.target.closest(
                    '.stall-compare-clear'
                );

            if ( !button ) {
                return;
            }


            event.preventDefault();


            saveComparePlacements(
                []
            );


            updateCompareButtons();
            updateMapCompareLinks();
            updateCompareTray();

        }
    );


    /* =====================================
     * 1件時の比較リンク無効
     * ===================================== */

    document.addEventListener(
        'click',
        function ( event ) {

            const link =
                event.target.closest(
                    '.stall-compare-open-disabled'
                );

            if ( !link ) {
                return;
            }

            event.preventDefault();

        }
    );


    /* =====================================
     * 初期化
     * ===================================== */

function initPlacementCompare() {

    createCompareButtons();

    createCompareTray();

    updateCompareButtons();

    updateMapCompareLinks();

    updateCompareTray();

}


initPlacementCompare();


mw.hook(
    'wikipage.content'
).add(
    function () {

        initPlacementCompare();

    }
);


/* =====================================
 * 地図popup 比較リンク
 * ===================================== */

function getMapComparePlacementId( link ) {

    if ( !link ) {
        return '';
    }

    const href =
        link.getAttribute( 'href' ) || '';

    const match =
        href.match(
            /#compare-placement-(\d+)$/
        );

    if ( !match ) {
        return '';
    }

    return match[ 1 ];
}


function updateMapCompareLinks() {

    const ids =
        getComparePlacements();

    document
        .querySelectorAll(
            'a[href*="#compare-placement-"]'
        )
        .forEach(
            function ( link ) {

                const placementId =
                    getMapComparePlacementId(
                        link
                    );

                if (
                    !placementId ||
                    placementId === '0'
                ) {
                    return;
                }

                const selected =
                    ids.includes(
                        placementId
                    );

                link.classList.add(
                    'stall-map-compare-link'
                );

                link.dataset.placementId =
                    placementId;

                if ( selected ) {

                    link.textContent =
                        '比較から外す';

                    link.classList.add(
                        'stall-map-compare-link-selected'
                    );

                    link.setAttribute(
                        'aria-pressed',
                        'true'
                    );

                } else {

                    link.textContent =
                        '比較に追加';

                    link.classList.remove(
                        'stall-map-compare-link-selected'
                    );

                    link.setAttribute(
                        'aria-pressed',
                        'false'
                    );

                }

            }
        );

}


/* =====================================
 * 地図popupクリック
 * ===================================== */

document.addEventListener(
    'click',
    function ( event ) {

        const link =
            event.target.closest(
                'a[href*="#compare-placement-"]'
            );

        if ( !link ) {
            return;
        }

        const placementId =
            getMapComparePlacementId(
                link
            );

        if (
            !placementId ||
            placementId === '0'
        ) {
            return;
        }

        event.preventDefault();

        let ids =
            getComparePlacements();

        const index =
            ids.indexOf(
                placementId
            );

        if ( index !== -1 ) {

            ids.splice(
                index,
                1
            );

        } else {

            if (
                ids.length >=
                MAX_COMPARE
            ) {

                link.textContent =
                    '最大4件までです';

                return;
            }

            ids.push(
                placementId
            );

        }


        saveComparePlacements(
            ids
        );

        updateCompareButtons();
        updateMapCompareLinks();
        updateCompareTray();

    }
);


/* =====================================
 * Leaflet popup生成監視
 * ===================================== */

const mapCompareLinkObserver =
    new MutationObserver(
        function ( mutations ) {

            let needsUpdate =
                false;

            mutations.forEach(
                function ( mutation ) {

                    mutation.addedNodes.forEach(
                        function ( node ) {

                            if (
                                node.nodeType !== 1
                            ) {
                                return;
                            }

                            if (
                                node.matches &&
                                node.matches(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {

                                needsUpdate =
                                    true;

                                return;
                            }

                            if (
                                node.querySelector &&
                                node.querySelector(
                                    'a[href*="#compare-placement-"]'
                                )
                            ) {

                                needsUpdate =
                                    true;

                            }

                        }
                    );

                }
            );


            if ( needsUpdate ) {

                updateMapCompareLinks();

            }

        }
    );


mapCompareLinkObserver.observe(
    document.body,
    {
        childList: true,
        subtree: true
    }
);


/*
 * ★ この } ); が
 * placement比較用mw.loaderの終了
 */
} );

/* ========================================
 * 屋台比較ページ
 * placement_id 正式版
 * ======================================== */

mw.loader.using( [
    'mediawiki.storage',
    'mediawiki.api',
    'mediawiki.util'
] ).then( function () {

    'use strict';


    const compareRoot =
        document.getElementById(
            'stall-compare-page'
        );


    /*
     * 屋台比較ページ以外では終了
     */
    if ( !compareRoot ) {
        return;
    }


    const STORAGE_KEY =
        'matsuriWikiComparePlacements';

    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;

    const api =
        new mw.Api();


    /* =====================================
     * localStorage
     * ===================================== */

    function getPlacementIds() {

        const raw =
            mw.storage.get(
                STORAGE_KEY
            );


        if ( !raw ) {
            return [];
        }


        try {

            const ids =
                JSON.parse(
                    raw
                );


            if (
                !Array.isArray(
                    ids
                )
            ) {
                return [];
            }


            return [ ...new Set(
                ids
                    .map( String )
                    .filter(
                        function ( id ) {
                            return /^\d+$/.test(
                                id
                            );
                        }
                    )
            ) ].slice(
                0,
                MAX_COMPARE
            );


        } catch ( e ) {

            return [];

        }

    }


    /* =====================================
     * Cargo
     * ===================================== */

    function cargoQuery(
        table,
        fields,
        where,
        limit
    ) {

        const params = {

            action: 'cargoquery',

            tables: table,

            fields: fields,

            limit: limit || 100,

            format: 'json'

        };


        if ( where ) {
            params.where = where;
        }


        return api.get(
            params
        ).then(
            function ( data ) {

                if (
                    !data ||
                    !Array.isArray(
                        data.cargoquery
                    )
                ) {
                    return [];
                }


                return data.cargoquery.map(
                    function ( item ) {

                        return (
                            item.title ||
                            item
                        );

                    }
                );

            }
        );

    }


    function makeInClause( ids ) {

        return ids
            .map( String )
            .filter(
                function ( id ) {
                    return /^\d+$/.test(
                        id
                    );
                }
            )
            .join( ',' );

    }


    function uniqueIds( values ) {

        return [
            ...new Set(
                values
                    .map( String )
                    .filter(
                        function ( id ) {

                            return (
                                id &&
                                /^\d+$/.test(
                                    id
                                )
                            );

                        }
                    )
            )
        ];

    }


    function mapBy(
        rows,
        key
    ) {

        const result = {};


        rows.forEach(
            function ( row ) {

                if (
                    row[ key ] ===
                    undefined
                ) {
                    return;
                }


                result[
                    String(
                        row[ key ]
                    )
                ] = row;

            }
        );


        return result;

    }


    /* =====================================
     * 表示ヘルパー
     * ===================================== */

    function textOrDash(
        value
    ) {

        if (
            value === undefined ||
            value === null ||
            value === ''
        ) {
            return '―';
        }

        return String(
            value
        );

    }


    function cleanNumber(
        value
    ) {

        const number =
            Number(
                value
            );


        if (
            !Number.isFinite(
                number
            )
        ) {
            return '';
        }


        if (
            Number.isInteger(
                number
            )
        ) {

            return String(
                number
            );

        }


        return String(
            Math.round(
                number * 100
            ) / 100
        );

    }


    function formatHours(
        placement
    ) {

        if ( !placement ) {
            return '―';
        }


        const open =
            placement.opening_time || '';

        const close =
            placement.closing_time || '';


        if (
            open &&
            close
        ) {

            return (
                open +
                '~' +
                close
            );

        }


        if ( open ) {

            return (
                open +
                '~'
            );

        }


        if ( close ) {

            return (
                '~' +
                close
            );

        }


        if (
            placement.hours_note
        ) {

            return placement.hours_note;

        }


        return '未確認';

    }


    function formatPositionStatus(
        status
    ) {

        switch ( status ) {

            case 'exact':
                return '正確な位置';

            case 'approximate':
                return 'おおよその位置';

            case 'test':
                return 'テスト位置';

            default:
                return '位置未確認';

        }

    }


    function formatVerification(
        status
    ) {

        switch ( status ) {

            case 'verified':
                return '確認済み';

            case 'partially_verified':
                return '一部確認済み';

            case 'outdated':
                return '情報が古い';

            default:
                return '未確認';

        }

    }


    function formatAvailability(
        status
    ) {

        switch ( status ) {

            case 'available':
                return '販売あり';

            case 'unavailable':
                return '販売なし';

            default:
                return '未確認';

        }

    }


    /* =====================================
     * 単位価格
     * ===================================== */

    function getUnitPrice(
        offering
    ) {

        const price =
            Number(
                offering.price
            );

        const quantity =
            Number(
                offering.serving_quantity
            );


        if (
            !Number.isFinite(
                price
            ) ||
            !Number.isFinite(
                quantity
            ) ||
            quantity <= 0
        ) {

            return '―';

        }


        const unitPrice =
            Math.round(
                (
                    price /
                    quantity
                ) *
                100
            ) /
            100;


        const unit =
            offering.serving_unit ||
            '単位';


        return (
            unitPrice +
            '円/' +
            unit
        );

    }


    /* =====================================
     * DOM
     * ===================================== */

    function createTextCell(
        tagName,
        text
    ) {

        const cell =
            document.createElement(
                tagName
            );

        cell.textContent =
            text;

        return cell;

    }


    /* =====================================
     * メニュー
     * ===================================== */

    function createMenuList(
        menus
    ) {

        const container =
            document.createElement(
                'div'
            );

        container.className =
            'stall-compare-menu-list';


        if (
            !menus ||
            menus.length === 0
        ) {

            container.textContent =
                'メニュー未登録';

            return container;

        }


        menus.forEach(
            function ( item ) {

                const menu =
                    document.createElement(
                        'div'
                    );

                menu.className =
                    'stall-compare-menu-item';


                const name =
                    document.createElement(
                        'strong'
                    );

                name.className =
                    'stall-compare-menu-name';

                name.textContent =
                    item.menuName ||
                    '商品';


                const price =
                    document.createElement(
                        'div'
                    );

                price.textContent =
                    item.price
                        ? item.price +
                          '円'
                        : '価格未確認';


                const serving =
                    document.createElement(
                        'div'
                    );


                if (
                    item.servingQuantity
                ) {

                    serving.textContent =
                        '内容量:' +
                        item.servingQuantity +
                        (
                            item.servingUnit ||
                            ''
                        );

                } else {

                    serving.textContent =
                        '内容量:未確認';

                }


                const unit =
                    document.createElement(
                        'div'
                    );

                unit.textContent =
                    '1単位あたり:' +
                    item.unitPrice;


                const availability =
                    document.createElement(
                        'div'
                    );

                availability.textContent =
                    '販売状況:' +
                    item.availability;


                menu.appendChild(
                    name
                );

                menu.appendChild(
                    price
                );

                menu.appendChild(
                    serving
                );

                menu.appendChild(
                    unit
                );

                menu.appendChild(
                    availability
                );


                container.appendChild(
                    menu
                );

            }
        );


        return container;

    }


    /* =====================================
     * 比較表
     * ===================================== */

    function renderComparison(
        compareData
    ) {

        compareRoot.innerHTML =
            '';


        const heading =
            document.createElement(
                'h2'
            );

        heading.textContent =
            '屋台比較';


        compareRoot.appendChild(
            heading
        );


        const wrapper =
            document.createElement(
                'div'
            );

        wrapper.className =
            'stall-compare-table-wrapper';


        const table =
            document.createElement(
                'table'
            );

        table.className =
            'stall-compare-table';


        /* ------------------------------
         * thead
         * ------------------------------ */

        const thead =
            document.createElement(
                'thead'
            );

        const headerRow =
            document.createElement(
                'tr'
            );


        headerRow.appendChild(
            createTextCell(
                'th',
                '比較項目'
            )
        );


        compareData.forEach(
            function ( data ) {

                const th =
                    document.createElement(
                        'th'
                    );


                if (
                    data.stall &&
                    data.stall.page_name
                ) {

                    const link =
                        document.createElement(
                            'a'
                        );

                    link.href =
                        mw.util.getUrl(
                            data.stall
                                .page_name
                        );

                    link.textContent =
                        data.stall
                            .stall_name ||
                        '屋台';


                    th.appendChild(
                        link
                    );

                } else {

                    th.textContent =
                        data.stall
                            ? data.stall.stall_name
                            : '屋台';

                }


                headerRow.appendChild(
                    th
                );

            }
        );


        thead.appendChild(
            headerRow
        );

        table.appendChild(
            thead
        );


        const tbody =
            document.createElement(
                'tbody'
            );


        function addRow(
            label,
            getter
        ) {

            const tr =
                document.createElement(
                    'tr'
                );


            const labelCell =
                createTextCell(
                    'th',
                    label
                );

            labelCell.scope =
                'row';


            tr.appendChild(
                labelCell
            );


            compareData.forEach(
                function ( data ) {

                    tr.appendChild(
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        )
                    );

                }
            );


            tbody.appendChild(
                tr
            );

        }


        /* =================================
         * Placement情報
         * ================================= */

        addRow(
            '開催年',
            function ( data ) {

                return data.placement
                    ? data.placement.year +
                      '年'
                    : '―';

            }
        );


        addRow(
            '祭り',
            function ( data ) {

                return data.festival
                    ? data.festival
                        .festival_name
                    : '―';

            }
        );


        addRow(
            '会場',
            function ( data ) {

                return data.venue
                    ? data.venue
                        .venue_name
                    : '―';

            }
        );


        addRow(
            '地域',
            function ( data ) {

                return data.area
                    ? data.area
                        .area_name
                    : '―';

            }
        );


        addRow(
            'カテゴリ',
            function ( data ) {

                return data.stall
                    ? data.stall
                        .category
                    : '―';

            }
        );


        addRow(
            '出店場所',
            function ( data ) {

                return data.placement
                    ? data.placement
                        .location_note
                    : '―';

            }
        );


        addRow(
            '営業時間',
            function ( data ) {

                return formatHours(
                    data.placement
                );

            }
        );


        addRow(
            '位置情報',
            function ( data ) {

                return data.placement
                    ? formatPositionStatus(
                        data.placement
                            .position_status
                    )
                    : '―';

            }
        );


        addRow(
            '確認状態',
            function ( data ) {

                return data.placement
                    ? formatVerification(
                        data.placement
                            .verification_status
                    )
                    : '―';

            }
        );


        /* =================================
         * メニュー
         * ================================= */

        const menuRow =
            document.createElement(
                'tr'
            );


        const menuLabel =
            createTextCell(
                'th',
                'メニュー'
            );

        menuLabel.scope =
            'row';


        menuRow.appendChild(
            menuLabel
        );


        compareData.forEach(
            function ( data ) {

                const td =
                    document.createElement(
                        'td'
                    );


                td.appendChild(
                    createMenuList(
                        data.menus
                    )
                );


                menuRow.appendChild(
                    td
                );

            }
        );


        tbody.appendChild(
            menuRow
        );


        table.appendChild(
            tbody
        );

        wrapper.appendChild(
            table
        );

        compareRoot.appendChild(
            wrapper
        );


        const note =
            document.createElement(
                'p'
            );

        note.className =
            'stall-compare-note';

        note.textContent =
            'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';


        compareRoot.appendChild(
            note
        );

    }


    function renderMessage(
        message
    ) {

        compareRoot.innerHTML =
            '';


        const p =
            document.createElement(
                'p'
            );

        p.className =
            'stall-compare-page-message';

        p.textContent =
            message;


        compareRoot.appendChild(
            p
        );

    }


    /* =====================================
     * データ取得
     * ===================================== */

    const placementIds =
        getPlacementIds();


    if (
        placementIds.length <
        MIN_COMPARE
    ) {

        renderMessage(
            '比較する出店を2件以上選択してください。'
        );

        return;

    }


    /*
     * STEP 1
     * Placementを直接取得
     */
    cargoQuery(

        'FestivalStallPlacements',

        'placement_id=placement_id,' +
        'stall_id=stall_id,' +
        'festival_id=festival_id,' +
        'venue_id=venue_id,' +
        'year=year,' +
        'latitude=latitude,' +
        'longitude=longitude,' +
        'location_note=location_note,' +
        'opening_time=opening_time,' +
        'closing_time=closing_time,' +
        'hours_note=hours_note,' +
        'position_status=position_status,' +
        'verification_status=verification_status',

        'placement_id IN (' +
        makeInClause(
            placementIds
        ) +
        ')',

        100

    ).then(
        function ( placements ) {

            const stallIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.stall_id;
                        }
                    )
                );


            const festivalIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.festival_id;
                        }
                    )
                );


            const venueIds =
                uniqueIds(
                    placements.map(
                        function ( row ) {
                            return row.venue_id;
                        }
                    )
                );


            /*
             * STEP 2
             */
            return Promise.all( [

                stallIds.length
                    ? cargoQuery(

                        'Stalls',

                        'stall_id=stall_id,' +
                        'name=stall_name,' +
                        'category=category,' +
                        '_pageName=page_name',

                        'stall_id IN (' +
                        makeInClause(
                            stallIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                festivalIds.length
                    ? cargoQuery(

                        'Festivals',

                        'festival_id=festival_id,' +
                        'name=festival_name,' +
                        '_pageName=page_name',

                        'festival_id IN (' +
                        makeInClause(
                            festivalIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                venueIds.length
                    ? cargoQuery(

                        'Venues',

                        'venue_id=venue_id,' +
                        'name=venue_name,' +
                        'area_id=area_id,' +
                        '_pageName=page_name',

                        'venue_id IN (' +
                        makeInClause(
                            venueIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                cargoQuery(

                    'FestivalStallMenuOfferings',

                    'offering_id=offering_id,' +
                    'placement_id=placement_id,' +
                    'menu_item_id=menu_item_id,' +
                    'price=price,' +
                    'serving_quantity=serving_quantity,' +
                    'serving_unit=serving_unit,' +
                    'serving_note=serving_note,' +
                    'availability=availability,' +
                    'verification_status=verification_status,' +
                    'sort_order=sort_order',

                    'placement_id IN (' +
                    makeInClause(
                        placementIds
                    ) +
                    ')',

                    100

                )

            ] ).then(
                function ( results ) {

                    return {

                        placements:
                            placements,

                        stalls:
                            results[ 0 ],

                        festivals:
                            results[ 1 ],

                        venues:
                            results[ 2 ],

                        offerings:
                            results[ 3 ]

                    };

                }
            );

        }
    ).then(
        function ( data ) {

            const areaIds =
                uniqueIds(
                    data.venues.map(
                        function ( row ) {
                            return row.area_id;
                        }
                    )
                );


            const menuItemIds =
                uniqueIds(
                    data.offerings.map(
                        function ( row ) {
                            return row.menu_item_id;
                        }
                    )
                );


            /*
             * STEP 3
             */
            return Promise.all( [

                areaIds.length
                    ? cargoQuery(

                        'Areas',

                        'area_id=area_id,' +
                        'name=area_name,' +
                        '_pageName=page_name',

                        'area_id IN (' +
                        makeInClause(
                            areaIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    ),


                menuItemIds.length
                    ? cargoQuery(

                        'StallMenuItems',

                        'menu_item_id=menu_item_id,' +
                        'stall_id=stall_id,' +
                        'name=menu_name,' +
                        'item_category=item_category,' +
                        'sort_order=sort_order',

                        'menu_item_id IN (' +
                        makeInClause(
                            menuItemIds
                        ) +
                        ')',

                        100

                    )
                    : Promise.resolve(
                        []
                    )

            ] ).then(
                function ( results ) {

                    data.areas =
                        results[ 0 ];

                    data.menuItems =
                        results[ 1 ];

                    return data;

                }
            );

        }
    ).then(
        function ( data ) {

            const placementMap =
                mapBy(
                    data.placements,
                    'placement_id'
                );


            const stallMap =
                mapBy(
                    data.stalls,
                    'stall_id'
                );


            const festivalMap =
                mapBy(
                    data.festivals,
                    'festival_id'
                );


            const venueMap =
                mapBy(
                    data.venues,
                    'venue_id'
                );


            const areaMap =
                mapBy(
                    data.areas,
                    'area_id'
                );


            const menuMap =
                mapBy(
                    data.menuItems,
                    'menu_item_id'
                );


            /*
             * Offering
             * placement単位
             */
            const offeringsByPlacement =
                {};


            data.offerings
                .slice()
                .sort(
                    function ( a, b ) {

                        return (
                            Number(
                                a.sort_order || 0
                            ) -
                            Number(
                                b.sort_order || 0
                            )
                        );

                    }
                )
                .forEach(
                    function ( offering ) {

                        const placementId =
                            String(
                                offering
                                    .placement_id
                            );


                        if (
                            !offeringsByPlacement[
                                placementId
                            ]
                        ) {

                            offeringsByPlacement[
                                placementId
                            ] = [];

                        }


                        offeringsByPlacement[
                            placementId
                        ].push(
                            offering
                        );

                    }
                );


            /*
             * localStorage順を維持
             */
            const compareData =
                placementIds.map(
                    function (
                        placementId
                    ) {

                        const placement =
                            placementMap[
                                placementId
                            ] || null;


                        if ( !placement ) {

                            return {

                                placementId:
                                    placementId,

                                placement:
                                    null,

                                stall:
                                    null,

                                festival:
                                    null,

                                venue:
                                    null,

                                area:
                                    null,

                                menus:
                                    []

                            };

                        }


                        const stall =
                            stallMap[
                                String(
                                    placement.stall_id
                                )
                            ] || null;


                        const festival =
                            festivalMap[
                                String(
                                    placement.festival_id
                                )
                            ] || null;


                        const venue =
                            venueMap[
                                String(
                                    placement.venue_id
                                )
                            ] || null;


                        let area = null;


                        if (
                            venue &&
                            venue.area_id
                        ) {

                            area =
                                areaMap[
                                    String(
                                        venue.area_id
                                    )
                                ] || null;

                        }


                        const offerings =
                            offeringsByPlacement[
                                placementId
                            ] || [];


                        const menus =
                            offerings.map(
                                function (
                                    offering
                                ) {

                                    const menu =
                                        menuMap[
                                            String(
                                                offering
                                                    .menu_item_id
                                            )
                                        ] || {};


                                    return {

                                        menuName:
                                            menu.menu_name ||
                                            '商品',

                                        category:
                                            menu.item_category ||
                                            '',

                                        price:
                                            cleanNumber(
                                                offering.price
                                            ),

                                        servingQuantity:
                                            cleanNumber(
                                                offering
                                                    .serving_quantity
                                            ),

                                        servingUnit:
                                            offering
                                                .serving_unit ||
                                            '',

                                        servingNote:
                                            offering
                                                .serving_note ||
                                            '',

                                        unitPrice:
                                            getUnitPrice(
                                                offering
                                            ),

                                        availability:
                                            formatAvailability(
                                                offering
                                                    .availability
                                            ),

                                        verification:
                                            formatVerification(
                                                offering
                                                    .verification_status
                                            )

                                    };

                                }
                            );


                        return {

                            placementId:
                                placementId,

                            placement:
                                placement,

                            stall:
                                stall,

                            festival:
                                festival,

                            venue:
                                venue,

                            area:
                                area,

                            menus:
                                menus

                        };

                    }
                );


            renderComparison(
                compareData
            );

        }
    ).catch(
        function ( error ) {

            console.error(
                'Placement比較データ取得エラー:',
                error
            );


            renderMessage(
                '比較データの取得中にエラーが発生しました。'
            );

        }
    );



} );