編集の要約なし
編集の要約なし
1行目: 1行目:
/* ========================================
/* ========================================
  * 屋台比較
  * 屋台比較
* placement_id 正式版
  *
  *
  * ・最大4店舗
  * 最大4出店
  * ・localStorageにはstall_idだけ保存
  *
  * ・屋台名はCargo APIから取得
  * localStorage:
  * ・画面下部に固定比較トレイを表示
  * matsuriWikiComparePlacements
*
* 1 placement =
* 1 festival + 1 year + 1 venue + 1 stall
  * ======================================== */
  * ======================================== */


16行目: 20行目:
     'use strict';
     'use strict';


     const STORAGE_KEY = 'matsuriWikiCompareStalls';
     const STORAGE_KEY =
        'matsuriWikiComparePlacements';
 
     const MAX_COMPARE = 4;
     const MAX_COMPARE = 4;


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


     /*
     /*
     * このページを表示している間だけ使う
     * ページ表示中だけ使うキャッシュ
    * 屋台名キャッシュ
     */
     */
     const stallNameCache = {};
     const placementInfoCache = {};




32行目: 38行目:
     * ===================================== */
     * ===================================== */


     function getCompareStalls() {
     function getComparePlacements() {


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


         if ( !raw ) {
         if ( !raw ) {
42行目: 51行目:
         try {
         try {


             const ids = JSON.parse( raw );
             const ids =
                JSON.parse( raw );


             if ( !Array.isArray( ids ) ) {
             if ( !Array.isArray( ids ) ) {
48行目: 58行目:
             }
             }


            /*
            * 数字だけ許可
            * localStorage改変対策
            */
             return [ ...new Set(
             return [ ...new Set(
                 ids
                 ids
                     .map( String )
                     .map( String )
                     .filter( function ( id ) {
                     .filter(
                        return /^\d+$/.test( id );
                        function ( id ) {
                     } )
                            return /^\d+$/.test( id );
             ) ].slice( 0, MAX_COMPARE );
                        }
                     )
             ) ].slice(
                0,
                MAX_COMPARE
            );


         } catch ( e ) {
         } catch ( e ) {
69行目: 80行目:




     function saveCompareStalls( ids ) {
     function saveComparePlacements( ids ) {


         mw.storage.set(
         mw.storage.set(
80行目: 91行目:


     /* =====================================
     /* =====================================
     * Cargoから屋台名取得
     * 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 fetchStallNames( ids ) {
     function fetchPlacementInfo( ids ) {


         if ( ids.length === 0 ) {
         if ( ids.length === 0 ) {
89行目: 214行目:
         }
         }


        /*
        * すでに取得済みのID
        */
         const result = {};
         const result = {};
         const missingIds = [];
         const missingIds = [];


         ids.forEach( function ( id ) {
         ids.forEach(
            function ( id ) {


            if ( stallNameCache[ id ] ) {
                if (
                    placementInfoCache[ id ]
                ) {


                result[ id ] =
                    result[ id ] =
                    stallNameCache[ id ];
                        placementInfoCache[ id ];


            } else {
                } else {


                missingIds.push( id );
                    missingIds.push( id );
 
                }


             }
             }
        );


        } );


        if ( missingIds.length === 0 ) {
            return Promise.resolve(
                result
            );


        /*
        * 全部キャッシュ済み
        */
        if ( missingIds.length === 0 ) {
            return Promise.resolve( result );
         }
         }




         /*
         return cargoQuery(
        * 数字だけにしてあるため
 
        * IN (1,2,3) として安全にCargoへ渡す
            'FestivalStallPlacements',
        */
 
        const where =
            'placement_id=placement_id,' +
             'stall_id IN (' +
            'stall_id=stall_id,' +
             missingIds.join( ',' ) +
            '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;
                            }
                        )
                    );


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


            action: 'cargoquery',


            tables: 'Stalls',
                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,


            fields:
                            stalls:
                'stall_id=stall_id,' +
                                related[ 0 ],
                'name=stall_name,' +
                '_pageName=page_name',


            where: where,
                            festivals:
                                related[ 1 ],


            limit: MAX_COMPARE,
                            venues:
                                related[ 2 ]


            format: 'json'
                        };


        } ).then( function ( data ) {
                    }
                );


            if (
                !data ||
                !Array.isArray( data.cargoquery )
            ) {
                return result;
             }
             }
        ).then(
            function ( data ) {


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


            data.cargoquery.forEach( function ( item ) {
                const festivalMap =
                    mapBy(
                        data.festivals,
                        'festival_id'
                    );


                 /*
                 const venueMap =
                * Cargo APIは通常
                    mapBy(
                *
                        data.venues,
                * {
                        'venue_id'
                *  title: {
                     );
                *    stall_id: "...",
                *    stall_name: "..."
                *  }
                * }
                *
                * の形式
                */
                const row =
                     item.title || item;


                if (
                    !row ||
                    row.stall_id === undefined
                ) {
                    return;
                }


                 const id =
                 data.placements.forEach(
                     String( row.stall_id );
                     function ( placement ) {


                const info = {
                        const placementId =
                            String(
                                placement.placement_id
                            );


                    id: id,
                        const info = {


                    name:
                            placement:
                        row.stall_name ||
                                placement,
                        '屋台ID ' + id,


                    page:
                            stall:
                        row.page_name || ''
                                stallMap[
                                    String(
                                        placement.stall_id
                                    )
                                ] || null,


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


                stallNameCache[ id ] = info;
                            venue:
                result[ id ] = info;
                                venueMap[
                                    String(
                                        placement.venue_id
                                    )
                                ] || null


            } );
                        };




            return result;
                        placementInfoCache[
                            placementId
                        ] = info;


        } ).catch( function () {
                        result[
                            placementId
                        ] = info;


            /*
                    }
            * APIエラーでも
                );
            * 比較機能そのものは壊さない
            */
            missingIds.forEach( function ( id ) {


                result[ id ] = {
                    id: id,
                    name: '屋台ID ' + id,
                    page: ''
                };


            } );
                return result;


             return result;
             }
 
         );
         } );


     }
     }
229行目: 463行目:


     /* =====================================
     /* =====================================
     * 詳細ページ側メッセージ
     * メッセージ
     * ===================================== */
     * ===================================== */


259行目: 493行目:


     /* =====================================
     /* =====================================
     * 詳細ページ側button生成
     * Placement比較ボタン生成
     * ===================================== */
     * ===================================== */


268行目: 502行目:
                 '.stall-compare-placeholder'
                 '.stall-compare-placeholder'
             )
             )
             .forEach( function ( placeholder ) {
             .forEach(
                function ( placeholder ) {


                if (
                    if (
                    placeholder.dataset.initialized ===
                        placeholder.dataset.initialized ===
                    '1'
                        '1'
                ) {
                    ) {
                     return;
                        return;
                }
                     }
 
 
                    const prefix =
                        'stall-compare-placeholder-';
 
 
                    if (
                        !placeholder.id.startsWith(
                            prefix
                        )
                    ) {
                        return;
                    }
 
 
                    const placementId =
                        placeholder.id.substring(
                            prefix.length
                        );


                const prefix =
                    'stall-compare-placeholder-';


                if (
                    if (
                    !placeholder.id.startsWith(
                        !/^\d+$/.test(
                         prefix
                            placementId
                     )
                         ) ||
                ) {
                        placementId === '0'
                    return;
                     ) {
                }
                        return;
                    }


                const stallId =
                    placeholder.id.substring(
                        prefix.length
                    );


                if (
                    const button =
                    !/^\d+$/.test( stallId )
                        document.createElement(
                ) {
                            'button'
                    return;
                        );
                }


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


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


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


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


                button.setAttribute(
                    button.textContent =
                    'aria-pressed',
                        '比較に追加';
                    'false'
                );


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


                placeholder.appendChild(
                    placeholder.appendChild(
                    button
                        button
                );
                    );


                placeholder.dataset.initialized =
                    placeholder.dataset.initialized =
                    '1';
                        '1';


             } );
                }
             );


     }
     }
334行目: 579行目:


     /* =====================================
     /* =====================================
     * 詳細ページ側button状態
     * ボタン状態更新
     * ===================================== */
     * ===================================== */


340行目: 585行目:


         const ids =
         const ids =
             getCompareStalls();
             getComparePlacements();
 


         document
         document
346行目: 592行目:
                 '.stall-compare-button'
                 '.stall-compare-button'
             )
             )
             .forEach( function ( button ) {
             .forEach(
                function ( button ) {
 
                    const placementId =
                        String(
                            button.dataset
                                .placementId || ''
                        );
 
 
                    const selected =
                        ids.includes(
                            placementId
                        );


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


                const selected =
                     if ( selected ) {
                     ids.includes( stallId );


                if ( selected ) {
                        button.textContent =
                            '比較から外す';


                    button.textContent =
                        button.classList.add(
                        '比較から外す';
                            'stall-compare-button-selected'
                        );


                    button.classList.add(
                        button.setAttribute(
                        'stall-compare-button-selected'
                            'aria-pressed',
                    );
                            'true'
                        );


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


                } else {
                        button.textContent =
                            '比較に追加';


                    button.textContent =
                        button.classList.remove(
                        '比較に追加';
                            'stall-compare-button-selected'
                        );


                    button.classList.remove(
                        button.setAttribute(
                        'stall-compare-button-selected'
                            'aria-pressed',
                    );
                            'false'
                        );


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


                 }
                 }
 
             );
             } );


     }
     }
392行目: 645行目:


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


404行目: 657行目:
             return;
             return;
         }
         }


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


         tray.id =
         tray.id =
420行目: 676行目:




        /*
        * ヘッダー
        */
         const header =
         const header =
             document.createElement( 'div' );
             document.createElement(
                'div'
            );


         header.className =
         header.className =
431行目: 686行目:


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


         title.className =
         title.className =
441行目: 698行目:


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


         count.className =
         count.className =
447行目: 706行目:




         header.appendChild( title );
         header.appendChild(
         header.appendChild( count );
            title
        );
 
         header.appendChild(
            count
        );




        /*
        * 屋台一覧
        */
         const items =
         const items =
             document.createElement( 'div' );
             document.createElement(
                'div'
            );


         items.className =
         items.className =
461行目: 724行目:




        /*
        * アクション部分
        */
         const actions =
         const actions =
             document.createElement( 'div' );
             document.createElement(
                'div'
            );


         actions.className =
         actions.className =
472行目: 734行目:


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


         clearButton.type =
         clearButton.type =
485行目: 749行目:


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


         compareLink.className =
         compareLink.className =
491行目: 757行目:


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


         compareLink.textContent =
         compareLink.textContent =
527行目: 795行目:


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


533行目: 801行目:


         createCompareTray();
         createCompareTray();


         const tray =
         const tray =
545行目: 814行目:


         const ids =
         const ids =
             getCompareStalls();
             getComparePlacements();




574行目: 843行目:




        /*
        * 0件ならトレイ非表示
        */
         if ( ids.length === 0 ) {
         if ( ids.length === 0 ) {


588行目: 854行目:


             return;
             return;
         }
         }


596行目: 863行目:




        /*
        * 2件未満では比較ページを無効化
        */
         if ( compareLink ) {
         if ( compareLink ) {


633行目: 897行目:




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




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


            /*
                const currentIds =
            * 更新待ち中に選択内容が
                    getComparePlacements();
            * 変わった場合も現在値を優先
            */
            const currentIds =
                getCompareStalls();


            items.innerHTML =
                items.innerHTML =
                '';
                    '';




            currentIds.forEach(
                currentIds.forEach(
                function ( id ) {
                    function ( placementId ) {


                    const info =
                        const info =
                        stallInfo[ id ] || {
                            placementInfo[
                            id: id,
                                 placementId
                            name:
                             ];
                                 '屋台ID ' + id,
                             page: ''
                        };




                    const item =
                         if ( !info ) {
                         document.createElement(
                             return;
                             'div'
                         }
                         );


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


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


                    /*
                        item.className =
                    * 屋台名
                            'stall-compare-tray-item';
                    */
                    let nameElement;


                    if ( info.page ) {


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


                         nameElement.href =
 
                             mw.util.getUrl(
                         const name =
                                 info.page
                             document.createElement(
                                info.stall &&
                                 info.stall.page_name
                                    ? 'a'
                                    : 'span'
                             );
                             );


                    } else {


                         nameElement =
                         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(
                             document.createElement(
                                 'span'
                                 '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
                            );


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


                    nameElement.textContent =
                        info.name;


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


                    /*
 
                    * × ボタン
                        text.appendChild(
                    */
                            name
                    const removeButton =
                        );
                         document.createElement(
 
                             'button'
                         text.appendChild(
                             context
                         );
                         );


                    removeButton.type =
                        'button';


                    removeButton.className =
                        const removeButton =
                        'stall-compare-tray-remove';
                            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.dataset.stallId =
                        removeButton.textContent =
                        id;
                            '×';


                    removeButton.setAttribute(
                        'aria-label',
                        info.name +
                        'を比較候補から外す'
                    );


                    removeButton.textContent =
                        item.appendChild(
                         '×';
                            text
                         );


                        item.appendChild(
                            removeButton
                        );


                    item.appendChild(
                        items.appendChild(
                         nameElement
                            item
                     );
                         );
 
                     }
                );


                    item.appendChild(
            }
                        removeButton
        ).catch(
                    );
            function ( error ) {


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


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


         } );
            }
         );


     }
     }
757行目: 1,101行目:


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


772行目: 1,117行目:
                 return;
                 return;
             }
             }


             event.preventDefault();
             event.preventDefault();


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


             if (
             if (
                 !/^\d+$/.test( stallId )
                 !/^\d+$/.test(
                    placementId
                )
             ) {
             ) {
                 return;
                 return;
794行目: 1,145行目:


             let ids =
             let ids =
                 getCompareStalls();
                 getComparePlacements();




             const index =
             const index =
                 ids.indexOf(
                 ids.indexOf(
                     stallId
                     placementId
                 );
                 );




             /*
             /*
             * すでに選択済み
             * すでに選択中
            * → 外す
             */
             */
             if ( index !== -1 ) {
             if ( index !== -1 ) {
814行目: 1,164行目:
                 );
                 );


                 saveCompareStalls(
                 saveComparePlacements(
                     ids
                     ids
                 );
                 );
820行目: 1,170行目:
                 updateCompareButtons();
                 updateCompareButtons();
                 updateCompareTray();
                 updateCompareTray();


                 if ( control ) {
                 if ( control ) {
837行目: 1,188行目:


             /*
             /*
             * 4件上限
             * 最大4件
             */
             */
             if (
             if (
848行目: 1,199行目:
                     showMessage(
                     showMessage(
                         control,
                         control,
                         '比較できる屋台は最大4店舗です。',
                         '比較できる出店は最大4件です。',
                         true
                         true
                     );
                     );
860行目: 1,211行目:


             ids.push(
             ids.push(
                 stallId
                 placementId
             );
             );


             saveCompareStalls(
 
             saveComparePlacements(
                 ids
                 ids
             );
             );


             updateCompareButtons();
             updateCompareButtons();
876行目: 1,229行目:
                     control,
                     control,
                     '比較候補に追加しました(' +
                     '比較候補に追加しました(' +
                        ids.length +
                    ids.length +
                        '/4)。',
                    '/4)。',
                     false
                     false
                 );
                 );
888行目: 1,241行目:


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


903行目: 1,256行目:
                 return;
                 return;
             }
             }


             event.preventDefault();
             event.preventDefault();


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


             let ids =
             let ids =
                 getCompareStalls();
                 getComparePlacements();
 


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


             saveCompareStalls(
 
             saveComparePlacements(
                 ids
                 ids
             );
             );


             updateCompareButtons();
             updateCompareButtons();
933行目: 1,293行目:


     /* =====================================
     /* =====================================
     * 「すべて外す」
     * 全削除
     * ===================================== */
     * ===================================== */


948行目: 1,308行目:
                 return;
                 return;
             }
             }


             event.preventDefault();
             event.preventDefault();


             saveCompareStalls(
 
             saveComparePlacements(
                 []
                 []
             );
             );


             updateCompareButtons();
             updateCompareButtons();
963行目: 1,326行目:


     /* =====================================
     /* =====================================
     * 1件時の「比較する」クリック防止
     * 1件時の比較リンク無効
     * ===================================== */
     * ===================================== */


989行目: 1,352行目:
     * ===================================== */
     * ===================================== */


     function initStallCompare() {
     function initPlacementCompare() {


         createCompareButtons();
         createCompareButtons();
1,002行目: 1,365行目:




     initStallCompare();
     initPlacementCompare();




1,010行目: 1,373行目:
         function () {
         function () {


             initStallCompare();
             initPlacementCompare();


         }
         }

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

/* ========================================
 * 屋台比較
 * 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 )
        );

    }


    /* =====================================
     * Placement比較ボタン生成
     * ===================================== */

    function createCompareButtons() {

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

                    if (
                        placeholder.dataset.initialized ===
                        '1'
                    ) {
                        return;
                    }


                    const prefix =
                        'stall-compare-placeholder-';


                    if (
                        !placeholder.id.startsWith(
                            prefix
                        )
                    ) {
                        return;
                    }


                    const placementId =
                        placeholder.id.substring(
                            prefix.length
                        );


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


                    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();
                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();
            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();
            updateCompareTray();

        }
    );


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

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

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

            if ( !button ) {
                return;
            }


            event.preventDefault();


            saveComparePlacements(
                []
            );


            updateCompareButtons();
            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();

        updateCompareTray();

    }


    initPlacementCompare();


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

            initPlacementCompare();

        }
    );

} );

/* ========================================
 * 屋台比較ページ
 *
 * localStorage:
 * matsuriWikiCompareStalls
 *
 * 2~4店舗をCargoから取得して
 * 横並び比較表を自動生成
 * ======================================== */

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 =
        'matsuriWikiCompareStalls';

    const MIN_COMPARE = 2;
    const MAX_COMPARE = 4;

    const api =
        new mw.Api();


    /* =====================================
     * 比較年
     *
     * ?year=2026 があれば使用
     * 無ければ現在年
     * ===================================== */

    function getCompareYear() {

        const params =
            new URLSearchParams(
                window.location.search
            );

        const yearParam =
            params.get( 'year' );

        if (
            yearParam &&
            /^\d{4}$/.test( yearParam )
        ) {

            const year =
                Number( yearParam );

            if (
                year >= 2000 &&
                year <= 2100
            ) {
                return year;
            }
        }

        return new Date().getFullYear();
    }


    const compareYear =
        getCompareYear();


    /* =====================================
     * localStorageからstall_id取得
     * ===================================== */

    function getCompareStallIds() {

        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 API共通処理
     * ===================================== */

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

                    }
                );

            }
        );

    }


    /* =====================================
     * ID配列をCargo IN句にする
     * ===================================== */

    function makeInClause( ids ) {

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

    }


    /* =====================================
     * 重複削除
     * ===================================== */

    function uniqueIds( values ) {

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

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

                        }
                    )
            )
        ];

    }


    /* =====================================
     * Object Map作成
     * ===================================== */

    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 '未確認';

        }

    }


    /* =====================================
     * 1単位あたり価格
     * ===================================== */

    function getUnitPrice(
        offering
    ) {

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

        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 helper
     * ===================================== */

    function createTextCell(
        tagName,
        text
    ) {

        const cell =
            document.createElement(
                tagName
            );

        cell.textContent =
            text;

        return cell;

    }


    /* =====================================
     * Menu表示
     * ===================================== */

    function createMenuList(
        menuData
    ) {

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

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


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

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

            return container;

        }


        menuData.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 perUnit =
                    document.createElement(
                        'div'
                    );

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


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

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


                menu.appendChild(
                    name
                );

                menu.appendChild(
                    price
                );

                menu.appendChild(
                    serving
                );

                menu.appendChild(
                    perUnit
                );

                menu.appendChild(
                    availability
                );


                container.appendChild(
                    menu
                );

            }
        );


        return container;

    }


    /* =====================================
     * 比較表生成
     * ===================================== */

    function renderComparison(
        compareData
    ) {

        compareRoot.innerHTML = '';


        /*
         * 年表示
         */
        const heading =
            document.createElement(
                'h2'
            );

        heading.textContent =
            compareYear +
            '年の屋台比較';

        compareRoot.appendChild(
            heading
        );


        /*
         * 横スクロール用
         */
        const wrapper =
            document.createElement(
                'div'
            );

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


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

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


        /* ==============================
         * ヘッダー
         * ============================== */

        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 ) {

                    const td =
                        createTextCell(
                            'td',
                            textOrDash(
                                getter(
                                    data
                                )
                            )
                        );

                    tr.appendChild(
                        td
                    );

                }
            );


            tbody.appendChild(
                tr
            );

        }


        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
                    )
                    : '―';

            }
        );


        /* ==============================
         * Menu行
         * ============================== */

        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 =
            '価格・営業時間・出店位置は開催年によって変わる場合があります。確認状態もあわせて参照してください。';

        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 stallIds =
        getCompareStallIds();


    if (
        stallIds.length <
        MIN_COMPARE
    ) {

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

        return;

    }


    /*
     * STEP 1
     *
     * Stalls
     * FestivalStallPlacements
     */
    Promise.all( [

        cargoQuery(

            'Stalls',

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

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

            MAX_COMPARE

        ),


        cargoQuery(

            'FestivalStallPlacements',

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

            'stall_id IN (' +
            makeInClause(
                stallIds
            ) +
            ') AND year=' +
            compareYear,

            100

        )

    ] ).then(
        function ( firstResults ) {

            const stalls =
                firstResults[ 0 ];

            const placements =
                firstResults[ 1 ];


            /*
             * 同じstallに複数Placementがある場合
             * sort_orderの小さいものを
             * 今回の比較対象にする
             *
             * 後でfestival_idまで比較条件に
             * 含める設計へ拡張可能
             */
            const placementByStall = {};

            placements
                .slice()
                .sort(
                    function ( a, b ) {

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

                    }
                )
                .forEach(
                    function (
                        placement
                    ) {

                        const id =
                            String(
                                placement.stall_id
                            );

                        if (
                            !placementByStall[
                                id
                            ]
                        ) {

                            placementByStall[
                                id
                            ] =
                                placement;

                        }

                    }
                );


            const chosenPlacements =
                stallIds
                    .map(
                        function ( id ) {

                            return (
                                placementByStall[
                                    id
                                ] ||
                                null
                            );

                        }
                    )
                    .filter(
                        Boolean
                    );


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


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


            const placementIds =
                uniqueIds(
                    chosenPlacements.map(
                        function ( row ) {
                            return row.placement_id;
                        }
                    )
                );


            /*
             * STEP 2
             *
             * Festivals
             * Venues
             * Offerings
             */
            return Promise.all( [

                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(
                        []
                    ),


                placementIds.length
                    ? 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

                    )
                    : Promise.resolve(
                        []
                    )

            ] ).then(
                function (
                    secondResults
                ) {

                    return {

                        stalls: stalls,

                        placementByStall:
                            placementByStall,

                        festivals:
                            secondResults[ 0 ],

                        venues:
                            secondResults[ 1 ],

                        offerings:
                            secondResults[ 2 ]

                    };

                }
            );

        }
    ).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
             *
             * Areas
             * StallMenuItems
             */
            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 (
                    thirdResults
                ) {

                    data.areas =
                        thirdResults[ 0 ];

                    data.menuItems =
                        thirdResults[ 1 ];

                    return data;

                }
            );

        }
    ).then(
        function ( data ) {

            /* ============================
             * Map化
             * ============================ */

            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 =
                stallIds.map(
                    function ( stallId ) {

                        const stall =
                            stallMap[
                                stallId
                            ] || null;


                        const placement =
                            data
                                .placementByStall[
                                    stallId
                                ] || null;


                        let festival = null;
                        let venue = null;
                        let area = null;

                        let menus = [];


                        if ( placement ) {

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


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


                            if (
                                venue &&
                                venue.area_id
                            ) {

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

                            }


                            const offerings =
                                offeringsByPlacement[
                                    String(
                                        placement
                                            .placement_id
                                    )
                                ] || [];


                            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 {

                            stallId: stallId,

                            stall: stall,

                            placement:
                                placement,

                            festival:
                                festival,

                            venue:
                                venue,

                            area:
                                area,

                            menus:
                                menus

                        };

                    }
                );


            renderComparison(
                compareData
            );

        }
    ).catch(
        function ( error ) {

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

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

        }
    );

} );