編集の要約なし
編集の要約なし
1,011行目: 1,011行目:


             initStallCompare();
             initStallCompare();
        }
    );
} );
/* ========================================
* 屋台比較ページ
*
* 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(
                '比較データの取得中にエラーが発生しました。'
            );


         }
         }

2026年8月11日 (火) 21:53時点における版

/* ========================================
 * 屋台比較
 *
 * ・最大4店舗
 * ・localStorageにはstall_idだけ保存
 * ・屋台名はCargo APIから取得
 * ・画面下部に固定比較トレイを表示
 * ======================================== */

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

    'use strict';

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

    const api = new mw.Api();

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


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

    function getCompareStalls() {

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

        if ( !raw ) {
            return [];
        }

        try {

            const ids = JSON.parse( raw );

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

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

        } catch ( e ) {

            return [];

        }

    }


    function saveCompareStalls( ids ) {

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

    }


    /* =====================================
     * Cargoから屋台名取得
     * ===================================== */

    function fetchStallNames( ids ) {

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

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

        const missingIds = [];

        ids.forEach( function ( id ) {

            if ( stallNameCache[ id ] ) {

                result[ id ] =
                    stallNameCache[ id ];

            } else {

                missingIds.push( id );

            }

        } );


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


        /*
         * 数字だけにしてあるため
         * IN (1,2,3) として安全にCargoへ渡す
         */
        const where =
            'stall_id IN (' +
            missingIds.join( ',' ) +
            ')';


        return api.get( {

            action: 'cargoquery',

            tables: 'Stalls',

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

            where: where,

            limit: MAX_COMPARE,

            format: 'json'

        } ).then( function ( data ) {

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


            data.cargoquery.forEach( function ( item ) {

                /*
                 * Cargo APIは通常
                 *
                 * {
                 *   title: {
                 *     stall_id: "...",
                 *     stall_name: "..."
                 *   }
                 * }
                 *
                 * の形式
                 */
                const row =
                    item.title || item;

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

                const id =
                    String( row.stall_id );

                const info = {

                    id: id,

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

                    page:
                        row.page_name || ''

                };

                stallNameCache[ id ] = info;
                result[ id ] = info;

            } );


            return result;

        } ).catch( function () {

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

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

            } );

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

    }


    /* =====================================
     * 詳細ページ側button生成
     * ===================================== */

    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 stallId =
                    placeholder.id.substring(
                        prefix.length
                    );

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

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

                button.type =
                    'button';

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

                button.dataset.stallId =
                    stallId;

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

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

                placeholder.appendChild(
                    button
                );

                placeholder.dataset.initialized =
                    '1';

            } );

    }


    /* =====================================
     * 詳細ページ側button状態
     * ===================================== */

    function updateCompareButtons() {

        const ids =
            getCompareStalls();

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

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

                const selected =
                    ids.includes( stallId );

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


        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;

        }


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

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

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

            return;
        }


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


        /*
         * 2件未満では比較ページを無効化
         */
        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 =
            '屋台情報を読み込み中…';


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

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

            items.innerHTML =
                '';


            currentIds.forEach(
                function ( id ) {

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


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

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


                    /*
                     * 屋台名
                     */
                    let nameElement;

                    if ( info.page ) {

                        nameElement =
                            document.createElement(
                                'a'
                            );

                        nameElement.href =
                            mw.util.getUrl(
                                info.page
                            );

                    } else {

                        nameElement =
                            document.createElement(
                                'span'
                            );

                    }

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

                    nameElement.textContent =
                        info.name;


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

                    removeButton.type =
                        'button';

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

                    removeButton.dataset.stallId =
                        id;

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

                    removeButton.textContent =
                        '×';


                    item.appendChild(
                        nameElement
                    );

                    item.appendChild(
                        removeButton
                    );

                    items.appendChild(
                        item
                    );

                }
            );

        } );

    }


    /* =====================================
     * 詳細ページ比較button
     * ===================================== */

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

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

            if ( !button ) {
                return;
            }

            event.preventDefault();

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

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


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


            let ids =
                getCompareStalls();


            const index =
                ids.indexOf(
                    stallId
                );


            /*
             * すでに選択済み
             * → 外す
             */
            if ( index !== -1 ) {

                ids.splice(
                    index,
                    1
                );

                saveCompareStalls(
                    ids
                );

                updateCompareButtons();
                updateCompareTray();

                if ( control ) {

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

                }

                return;

            }


            /*
             * 4件上限
             */
            if (
                ids.length >=
                MAX_COMPARE
            ) {

                if ( control ) {

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

                }

                return;

            }


            ids.push(
                stallId
            );

            saveCompareStalls(
                ids
            );

            updateCompareButtons();
            updateCompareTray();


            if ( control ) {

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

            }

        }
    );


    /* =====================================
     * トレイ × button
     * ===================================== */

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

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

            if ( !button ) {
                return;
            }

            event.preventDefault();

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

            let ids =
                getCompareStalls();

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

            saveCompareStalls(
                ids
            );

            updateCompareButtons();
            updateCompareTray();

        }
    );


    /* =====================================
     * 「すべて外す」
     * ===================================== */

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

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

            if ( !button ) {
                return;
            }

            event.preventDefault();

            saveCompareStalls(
                []
            );

            updateCompareButtons();
            updateCompareTray();

        }
    );


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

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

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

            if ( !link ) {
                return;
            }

            event.preventDefault();

        }
    );


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

    function initStallCompare() {

        createCompareButtons();

        createCompareTray();

        updateCompareButtons();

        updateCompareTray();

    }


    initStallCompare();


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

            initStallCompare();

        }
    );

} );

/* ========================================
 * 屋台比較ページ
 *
 * 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(
                '比較データの取得中にエラーが発生しました。'
            );

        }
    );

} );