編集の要約なし |
編集の要約なし |
||
| 2,376行目: | 2,376行目: | ||
visiblePlacementIds | visiblePlacementIds | ||
) { | ) { | ||
pendingVisiblePlacementIds = | |||
visiblePlacementIds | |||
.map( | |||
String | |||
); | |||
if ( | |||
buildFestivalMapMarkerIndex() | |||
) { | |||
applyMapMarkerFilter( | |||
pendingVisiblePlacementIds | |||
); | |||
return; | |||
} | |||
/* | |||
* Maps側がまだ初期化されていれば待つ | |||
*/ | |||
scheduleMapMarkerIndex(); | |||
} | |||
/* ===================================== | /* ===================================== | ||
| 2,704行目: | 2,730行目: | ||
); | ); | ||
/* ===================================== | /* ===================================== | ||
2026年8月13日 (木) 10:50時点における版
/* ========================================
* 屋台比較
* placement_id 正式版
*
* 最大4出店
*
* localStorage:
* matsuriWikiComparePlacements
*
* 1 placement =
* 1 festival + 1 year + 1 venue + 1 stall
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MAX_COMPARE = 4;
const api =
new mw.Api();
/*
* ページ表示中だけ使うキャッシュ
*/
const placementInfoCache = {};
/* =====================================
* localStorage
* ===================================== */
function getComparePlacements() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse( raw );
if ( !Array.isArray( ids ) ) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
function saveComparePlacements( ids ) {
mw.storage.set(
STORAGE_KEY,
JSON.stringify( ids )
);
}
/* =====================================
* Cargo共通処理
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return item.title || item;
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
)
];
}
function mapBy( rows, key ) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] === undefined ||
row[ key ] === null
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* Placement情報取得
* ===================================== */
function fetchPlacementInfo( ids ) {
if ( ids.length === 0 ) {
return Promise.resolve( {} );
}
const result = {};
const missingIds = [];
ids.forEach(
function ( id ) {
if (
placementInfoCache[ id ]
) {
result[ id ] =
placementInfoCache[ id ];
} else {
missingIds.push( id );
}
}
);
if ( missingIds.length === 0 ) {
return Promise.resolve(
result
);
}
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'location_note=location_note',
'placement_id IN (' +
makeInClause(
missingIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve( [] ),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve( [] ),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve( [] )
] ).then(
function ( related ) {
return {
placements:
placements,
stalls:
related[ 0 ],
festivals:
related[ 1 ],
venues:
related[ 2 ]
};
}
);
}
).then(
function ( data ) {
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
data.placements.forEach(
function ( placement ) {
const placementId =
String(
placement.placement_id
);
const info = {
placement:
placement,
stall:
stallMap[
String(
placement.stall_id
)
] || null,
festival:
festivalMap[
String(
placement.festival_id
)
] || null,
venue:
venueMap[
String(
placement.venue_id
)
] || null
};
placementInfoCache[
placementId
] = info;
result[
placementId
] = info;
}
);
return result;
}
);
}
/* =====================================
* メッセージ
* ===================================== */
function showMessage(
control,
message,
isError
) {
const element =
control.querySelector(
'.stall-compare-message'
);
if ( !element ) {
return;
}
element.textContent =
message;
element.classList.toggle(
'stall-compare-message-error',
Boolean( isError )
);
}
/* =====================================
* 比較ボタン生成
*
* 通常ページ
* stall-compare-placeholder-1
*
* 地図popup
* stall-map-compare-placeholder-1
*
* の両方に対応
* ===================================== */
function createCompareButtons() {
document
.querySelectorAll(
'.stall-compare-placeholder, ' +
'.stall-map-compare-placeholder'
)
.forEach(
function ( placeholder ) {
/*
* すでにbutton生成済み
*/
if (
placeholder.dataset.initialized ===
'1'
) {
return;
}
let placementId = '';
/*
* 通常ページ
*/
const normalPrefix =
'stall-compare-placeholder-';
/*
* 地図popup
*/
const mapPrefix =
'stall-map-compare-placeholder-';
if (
placeholder.id.startsWith(
normalPrefix
)
) {
placementId =
placeholder.id.substring(
normalPrefix.length
);
} else if (
placeholder.id.startsWith(
mapPrefix
)
) {
placementId =
placeholder.id.substring(
mapPrefix.length
);
} else {
return;
}
/*
* placement_idチェック
*/
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
/*
* 本物のbuttonを
* JavaScript側で作る
*/
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'stall-compare-button';
button.dataset.placementId =
placementId;
button.setAttribute(
'aria-pressed',
'false'
);
button.textContent =
'比較に追加';
placeholder.appendChild(
button
);
/*
* 二重生成防止
*/
placeholder.dataset.initialized =
'1';
}
);
}
/* =====================================
* ボタン状態更新
* ===================================== */
function updateCompareButtons() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'.stall-compare-button'
)
.forEach(
function ( button ) {
const placementId =
String(
button.dataset
.placementId || ''
);
const selected =
ids.includes(
placementId
);
if ( selected ) {
button.textContent =
'比較から外す';
button.classList.add(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'true'
);
} else {
button.textContent =
'比較に追加';
button.classList.remove(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 固定比較トレイ生成
* ===================================== */
function createCompareTray() {
if (
document.getElementById(
'stall-compare-tray'
)
) {
return;
}
const tray =
document.createElement(
'div'
);
tray.id =
'stall-compare-tray';
tray.className =
'stall-compare-tray';
tray.setAttribute(
'aria-live',
'polite'
);
const header =
document.createElement(
'div'
);
header.className =
'stall-compare-tray-header';
const title =
document.createElement(
'strong'
);
title.className =
'stall-compare-tray-title';
title.textContent =
'比較候補';
const count =
document.createElement(
'span'
);
count.className =
'stall-compare-tray-count';
header.appendChild(
title
);
header.appendChild(
count
);
const items =
document.createElement(
'div'
);
items.className =
'stall-compare-tray-items';
const actions =
document.createElement(
'div'
);
actions.className =
'stall-compare-tray-actions';
const clearButton =
document.createElement(
'button'
);
clearButton.type =
'button';
clearButton.className =
'stall-compare-clear';
clearButton.textContent =
'すべて外す';
const compareLink =
document.createElement(
'a'
);
compareLink.className =
'stall-compare-open';
compareLink.href =
mw.util.getUrl(
'屋台比較'
);
compareLink.textContent =
'比較する';
actions.appendChild(
clearButton
);
actions.appendChild(
compareLink
);
tray.appendChild(
header
);
tray.appendChild(
items
);
tray.appendChild(
actions
);
document.body.appendChild(
tray
);
}
/* =====================================
* 比較トレイ更新
* ===================================== */
function updateCompareTray() {
createCompareTray();
const tray =
document.getElementById(
'stall-compare-tray'
);
if ( !tray ) {
return;
}
const ids =
getComparePlacements();
const count =
tray.querySelector(
'.stall-compare-tray-count'
);
const items =
tray.querySelector(
'.stall-compare-tray-items'
);
const compareLink =
tray.querySelector(
'.stall-compare-open'
);
if ( count ) {
count.textContent =
ids.length +
' / ' +
MAX_COMPARE;
}
if ( ids.length === 0 ) {
tray.classList.remove(
'stall-compare-tray-visible'
);
if ( items ) {
items.innerHTML = '';
}
return;
}
tray.classList.add(
'stall-compare-tray-visible'
);
if ( compareLink ) {
if ( ids.length >= 2 ) {
compareLink.classList.remove(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'false'
);
} else {
compareLink.classList.add(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'true'
);
}
}
if ( !items ) {
return;
}
items.textContent =
'屋台情報を読み込み中…';
fetchPlacementInfo(
ids
).then(
function ( placementInfo ) {
const currentIds =
getComparePlacements();
items.innerHTML =
'';
currentIds.forEach(
function ( placementId ) {
const info =
placementInfo[
placementId
];
if ( !info ) {
return;
}
const item =
document.createElement(
'div'
);
item.className =
'stall-compare-tray-item';
const text =
document.createElement(
'div'
);
const name =
document.createElement(
info.stall &&
info.stall.page_name
? 'a'
: 'span'
);
if (
info.stall &&
info.stall.page_name
) {
name.href =
mw.util.getUrl(
info.stall.page_name
);
}
name.className =
'stall-compare-tray-item-name';
name.textContent =
info.stall
? info.stall.stall_name
: '屋台';
const context =
document.createElement(
'div'
);
context.className =
'stall-compare-tray-item-context';
const parts = [];
if (
info.placement &&
info.placement.year
) {
parts.push(
info.placement.year +
'年'
);
}
if (
info.festival &&
info.festival.festival_name
) {
parts.push(
info.festival
.festival_name
);
}
if (
info.venue &&
info.venue.venue_name
) {
parts.push(
info.venue
.venue_name
);
}
context.textContent =
parts.join(
' / '
);
text.appendChild(
name
);
text.appendChild(
context
);
const removeButton =
document.createElement(
'button'
);
removeButton.type =
'button';
removeButton.className =
'stall-compare-tray-remove';
removeButton.dataset.placementId =
placementId;
removeButton.setAttribute(
'aria-label',
(
info.stall
? info.stall.stall_name
: '屋台'
) +
'を比較候補から外す'
);
removeButton.textContent =
'×';
item.appendChild(
text
);
item.appendChild(
removeButton
);
items.appendChild(
item
);
}
);
}
).catch(
function ( error ) {
console.error(
'比較トレイ取得エラー:',
error
);
items.textContent =
'比較候補を読み込めませんでした。';
}
);
}
/* =====================================
* 詳細ページ
* 「比較に追加」
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-button'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
if (
!/^\d+$/.test(
placementId
)
) {
return;
}
const control =
button.closest(
'.stall-compare-control'
);
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
/*
* すでに選択中
*/
if ( index !== -1 ) {
ids.splice(
index,
1
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補から外しました。',
false
);
}
return;
}
/*
* 最大4件
*/
if (
ids.length >=
MAX_COMPARE
) {
if ( control ) {
showMessage(
control,
'比較できる出店は最大4件です。',
true
);
}
return;
}
ids.push(
placementId
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補に追加しました(' +
ids.length +
'/4)。',
false
);
}
}
);
/* =====================================
* トレイから1件削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-tray-remove'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
let ids =
getComparePlacements();
ids =
ids.filter(
function ( id ) {
return id !== placementId;
}
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 全削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-clear'
);
if ( !button ) {
return;
}
event.preventDefault();
saveComparePlacements(
[]
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 1件時の比較リンク無効
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'.stall-compare-open-disabled'
);
if ( !link ) {
return;
}
event.preventDefault();
}
);
/* =====================================
* 初期化
* ===================================== */
function initPlacementCompare() {
createCompareButtons();
createCompareTray();
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
initPlacementCompare();
mw.hook(
'wikipage.content'
).add(
function () {
initPlacementCompare();
}
);
/* =====================================
* 地図popup 比較リンク
* ===================================== */
function getMapComparePlacementId( link ) {
if ( !link ) {
return '';
}
const href =
link.getAttribute( 'href' ) || '';
const match =
href.match(
/#compare-placement-(\d+)$/
);
if ( !match ) {
return '';
}
return match[ 1 ];
}
function updateMapCompareLinks() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'a[href*="#compare-placement-"]'
)
.forEach(
function ( link ) {
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
const selected =
ids.includes(
placementId
);
link.classList.add(
'stall-map-compare-link'
);
link.dataset.placementId =
placementId;
if ( selected ) {
link.textContent =
'比較から外す';
link.classList.add(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'true'
);
} else {
link.textContent =
'比較に追加';
link.classList.remove(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 地図popupクリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'a[href*="#compare-placement-"]'
);
if ( !link ) {
return;
}
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
event.preventDefault();
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
if ( index !== -1 ) {
ids.splice(
index,
1
);
} else {
if (
ids.length >=
MAX_COMPARE
) {
link.textContent =
'最大4件までです';
return;
}
ids.push(
placementId
);
}
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* Leaflet popup生成監視
* ===================================== */
const mapCompareLinkObserver =
new MutationObserver(
function ( mutations ) {
let needsUpdate =
false;
mutations.forEach(
function ( mutation ) {
mutation.addedNodes.forEach(
function ( node ) {
if (
node.nodeType !== 1
) {
return;
}
if (
node.matches &&
node.matches(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
return;
}
if (
node.querySelector &&
node.querySelector(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
}
}
);
}
);
if ( needsUpdate ) {
updateMapCompareLinks();
}
}
);
mapCompareLinkObserver.observe(
document.body,
{
childList: true,
subtree: true
}
);
/*
* ★ この } ); が
* placement比較用mw.loaderの終了
*/
} );
/* ========================================
* 祭りページ
* 屋台名・商品名検索
* ======================================== */
mw.loader.using( [
'mediawiki.api'
] ).then( function () {
'use strict';
const cards =
Array.from(
document.querySelectorAll(
'.festival-stall-search-card'
)
);
/*
* 屋台カードがないページでは終了
*/
if (
cards.length === 0
) {
return;
}
const api =
new mw.Api();
/* =====================================
* 文字列正規化
* ===================================== */
function normalizeSearchText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
/*
* 全角・半角を可能な範囲で統一
*/
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
/*
* 大文字小文字を統一
*/
text =
text.toLowerCase();
/*
* 連続空白を統一
*/
text =
text.replace(
/\s+/g,
' '
);
return text;
}
/* =====================================
* Cargo Query
* ===================================== */
function cargoQuery(
table,
fields,
where
) {
const params = {
action:
'cargoquery',
tables:
table,
fields:
fields,
limit:
500,
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
);
}
);
}
);
}
/* =====================================
* placement_id一覧
* ===================================== */
const placementIds =
cards
.map(
function ( card ) {
return String(
card.dataset
.placementId ||
''
);
}
)
.filter(
function ( id ) {
return (
/^\d+$/.test(
id
) &&
id !== '0'
);
}
);
if (
placementIds.length === 0
) {
return;
}
/* =====================================
* 屋台一覧 ↔ Leaflet地図連動
*
* placement_id を使って
* markerを表示・非表示
* ===================================== */
const festivalMapMarkerIndex =
{};
/*
* 地図markerの登録が完了したか
*/
let festivalMapMarkerIndexReady =
false;
/*
* 地図生成前に検索された場合に備えて
* 最新の表示対象を保持
*/
let pendingVisiblePlacementIds =
placementIds.slice();
/*
* 地図初期化待ち
*/
let mapIndexTimer =
null;
let mapIndexAttempts =
0;
const MAX_MAP_INDEX_ATTEMPTS =
40;
/* =====================================
* marker popupから
* placement_idを取得
*
* popup内には
* #compare-placement-3
* のようなリンクが存在する
* ===================================== */
function getPlacementIdFromMapMarker(
marker
) {
if (
!marker ||
typeof marker.getPopup !==
'function'
) {
return '';
}
const popup =
marker.getPopup();
if (
!popup ||
typeof popup.getContent !==
'function'
) {
return '';
}
const content =
popup.getContent();
let popupText =
'';
/*
* 通常はHTML文字列
*/
if (
typeof content ===
'string'
) {
popupText =
content;
} else if (
content &&
typeof content.outerHTML ===
'string'
) {
popupText =
content.outerHTML;
} else if (
content &&
typeof content.innerHTML ===
'string'
) {
popupText =
content.innerHTML;
}
const match =
popupText.match(
/compare-placement-(\d+)/
);
if (
!match
) {
return '';
}
return match[
1
];
}
/* =====================================
* Maps拡張のmarkerを
* placement_idごとに登録
* ===================================== */
function buildFestivalMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
return true;
}
if (
!Array.isArray(
window.mapsLeafletList
)
) {
return false;
}
const expectedIds =
new Set(
placementIds.map(
String
)
);
window.mapsLeafletList.forEach(
function ( jqueryMap ) {
if (
!jqueryMap ||
!jqueryMap.mapContent ||
!jqueryMap.mapContent
.markerLayer
) {
return;
}
const markerLayer =
jqueryMap
.mapContent
.markerLayer;
if (
typeof markerLayer
.getLayers !==
'function'
) {
return;
}
const markers =
markerLayer.getLayers();
markers.forEach(
function ( marker ) {
const placementId =
getPlacementIdFromMapMarker(
marker
);
/*
* この祭りページの
* placementだけ登録
*/
if (
!placementId ||
!expectedIds.has(
placementId
)
) {
return;
}
festivalMapMarkerIndex[
placementId
] = {
marker:
marker,
markerLayer:
markerLayer,
/*
* このmarkerが所属する地図DOM
*/
mapElement:
jqueryMap &&
jqueryMap[
0
]
? jqueryMap[
0
]
: null
};
}
);
}
);
/*
* 全placementのmarkerが
* 見つかった場合だけ連動開始
*
* 中途半端な状態では
* 地図を変更しない
*/
festivalMapMarkerIndexReady =
Array.from(
expectedIds
).every(
function (
placementId
) {
return Boolean(
festivalMapMarkerIndex[
placementId
]
);
}
);
return (
festivalMapMarkerIndexReady
);
}
/* =====================================
* marker表示状態を変更
* ===================================== */
function applyMapMarkerFilter(
visiblePlacementIds
) {
const visibleIds =
new Set(
visiblePlacementIds.map(
String
)
);
Object.keys(
festivalMapMarkerIndex
).forEach(
function ( placementId ) {
const item =
festivalMapMarkerIndex[
placementId
];
if (
!item ||
!item.marker ||
!item.markerLayer
) {
return;
}
const marker =
item.marker;
const markerLayer =
item.markerLayer;
/*
* markerが現在表示されているか
*/
const isShown =
typeof markerLayer
.hasLayer ===
'function'
? markerLayer.hasLayer(
marker
)
: true;
/*
* 表示対象
*/
if (
visibleIds.has(
placementId
)
) {
if (
!isShown &&
typeof markerLayer
.addLayer ===
'function'
) {
markerLayer.addLayer(
marker
);
}
/*
* 非表示対象
*/
} else {
if (
isShown &&
typeof markerLayer
.removeLayer ===
'function'
) {
markerLayer.removeLayer(
marker
);
}
}
}
);
}
/* =====================================
* 地図初期化待ち
* ===================================== */
function scheduleMapMarkerIndex() {
if (
festivalMapMarkerIndexReady
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* 二重タイマー防止
*/
if (
mapIndexTimer !== null
) {
return;
}
function tryIndex() {
mapIndexTimer =
null;
if (
buildFestivalMapMarkerIndex()
) {
/*
* 地図準備完了後、
* 最新の絞り込み状態を反映
*/
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
mapIndexAttempts +=
1;
if (
mapIndexAttempts >=
MAX_MAP_INDEX_ATTEMPTS
) {
console.warn(
'祭り屋台地図:placement_idとmarkerを対応付けできませんでした。'
);
return;
}
mapIndexTimer =
window.setTimeout(
tryIndex,
100
);
}
tryIndex();
}
/* =====================================
* 一覧の検索結果を
* 地図へ反映
* ===================================== */
function syncMapMarkers(
visiblePlacementIds
) {
pendingVisiblePlacementIds =
visiblePlacementIds
.map(
String
);
if (
buildFestivalMapMarkerIndex()
) {
applyMapMarkerFilter(
pendingVisiblePlacementIds
);
return;
}
/*
* Maps側がまだ初期化されていれば待つ
*/
scheduleMapMarkerIndex();
}
/* =====================================
* placement_idのmarkerを開く
* ===================================== */
function openPlacementOnMap(
placementId
) {
const id =
String(
placementId ||
''
);
if (
!/^\d+$/.test(
id
) ||
id === '0'
) {
return false;
}
/*
* marker index未完成なら
* 一度構築を試す
*/
if (
!festivalMapMarkerIndex[
id
]
) {
buildFestivalMapMarkerIndex();
}
const item =
festivalMapMarkerIndex[
id
];
if (
!item ||
!item.marker
) {
console.warn(
'地図markerが見つかりません:',
id
);
return false;
}
const marker =
item.marker;
/*
* 万一markerが非表示なら
* 地図へ戻す
*/
if (
item.markerLayer &&
typeof item.markerLayer
.hasLayer ===
'function' &&
!item.markerLayer.hasLayer(
marker
) &&
typeof item.markerLayer
.addLayer ===
'function'
) {
item.markerLayer.addLayer(
marker
);
}
/*
* 地図までスクロール
*/
if (
item.mapElement &&
typeof item.mapElement
.scrollIntoView ===
'function'
) {
item.mapElement.scrollIntoView(
{
behavior:
'smooth',
block:
'center'
}
);
}
/*
* 少し待ってpopupを開く
*/
window.setTimeout(
function () {
if (
typeof marker.openPopup ===
'function'
) {
marker.openPopup();
}
},
300
);
return true;
}
/* =====================================
* 各屋台カード
* 「地図で見る」ボタン生成
* ===================================== */
function createMapViewButtons() {
cards.forEach(
function ( card ) {
/*
* 二重生成防止
*/
if (
card.querySelector(
'.festival-stall-map-view'
)
) {
return;
}
const placementId =
String(
card.dataset
.placementId ||
''
);
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'festival-stall-map-view';
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'festival-stall-map-view-button';
button.dataset.placementId =
placementId;
button.textContent =
'地図で見る';
button.setAttribute(
'aria-label',
'この屋台を地図で見る'
);
wrapper.appendChild(
button
);
/*
* 比較ボタンの近くへ配置
*/
const compareControl =
card.querySelector(
'.stall-compare-control'
);
if (
compareControl &&
compareControl.parentNode
) {
compareControl.parentNode
.insertBefore(
wrapper,
compareControl
.nextSibling
);
} else {
/*
* 比較ボタンが見つからない場合は
* カード末尾
*/
card.appendChild(
wrapper
);
}
}
);
}
/* =====================================
* 「地図で見る」クリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.festival-stall-map-view-button'
);
if (
!button
) {
return;
}
const placementId =
String(
button.dataset
.placementId ||
''
);
const opened =
openPlacementOnMap(
placementId
);
/*
* Maps初期化前だった場合
*/
if (
!opened
) {
scheduleMapMarkerIndex();
button.disabled =
true;
button.textContent =
'地図を準備中…';
window.setTimeout(
function () {
button.disabled =
false;
button.textContent =
'地図で見る';
openPlacementOnMap(
placementId
);
},
500
);
}
}
);
/* =====================================
* 検索UI
* ===================================== */
const searchBox =
document.createElement(
'div'
);
searchBox.className =
'festival-stall-search';
const label =
document.createElement(
'label'
);
label.className =
'festival-stall-search-label';
label.textContent =
'屋台を検索';
const input =
document.createElement(
'input'
);
input.type =
'search';
input.className =
'festival-stall-search-input';
input.placeholder =
'屋台名・商品名を入力';
input.setAttribute(
'autocomplete',
'off'
);
input.setAttribute(
'aria-label',
'屋台名または商品名で検索'
);
/* =====================================
* フィルターselect
* ===================================== */
function createFilterSelect(
labelText,
className,
allText
) {
const wrapper =
document.createElement(
'label'
);
wrapper.className =
'festival-stall-filter';
const title =
document.createElement(
'span'
);
title.className =
'festival-stall-filter-label';
title.textContent =
labelText;
const select =
document.createElement(
'select'
);
select.className =
'festival-stall-filter-select ' +
className;
const allOption =
document.createElement(
'option'
);
allOption.value =
'';
allOption.textContent =
allText;
select.appendChild(
allOption
);
wrapper.appendChild(
title
);
wrapper.appendChild(
select
);
return {
wrapper:
wrapper,
select:
select
};
}
/*
* カテゴリ
*/
const categoryFilter =
createFilterSelect(
'カテゴリ',
'festival-stall-category-filter',
'すべて'
);
/*
* 会場
*/
const venueFilter =
createFilterSelect(
'会場',
'festival-stall-venue-filter',
'すべて'
);
const categorySelect =
categoryFilter.select;
const venueSelect =
venueFilter.select;
/*
* フィルター行
*/
const filterRow =
document.createElement(
'div'
);
filterRow.className =
'festival-stall-search-filters';
filterRow.appendChild(
categoryFilter.wrapper
);
filterRow.appendChild(
venueFilter.wrapper
);
const count =
document.createElement(
'div'
);
count.className =
'festival-stall-search-count';
/* =====================================
* 検索結果0件メッセージ
* ===================================== */
const noResults =
document.createElement(
'div'
);
noResults.className =
'festival-stall-search-empty';
noResults.textContent =
'条件に一致する屋台はありません。検索条件を変更してください。';
noResults.hidden =
true;
noResults.setAttribute(
'role',
'status'
);
label.appendChild(
input
);
searchBox.appendChild(
label
);
/*
* カテゴリ・会場
*/
searchBox.appendChild(
filterRow
);
searchBox.appendChild(
count
);
searchBox.appendChild(
noResults
);
/*
* 最初の屋台カードの直前に表示
*/
cards[
0
].parentNode.insertBefore(
searchBox,
cards[
0
]
);
/* =====================================
* カードごとの検索文字列
*
* 最初はカード本文だけ
* ===================================== */
const searchIndex = {};
/*
* select候補
*/
const categoryOptions =
new Map();
const venueOptions =
new Map();
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const category =
String(
card.dataset
.category ||
''
).trim();
const venueName =
String(
card.dataset
.venueName ||
''
).trim();
const normalizedCategory =
normalizeSearchText(
category
);
const normalizedVenue =
normalizeSearchText(
venueName
);
/*
* placementごとの検索情報
*/
searchIndex[
placementId
] = {
text:
normalizeSearchText(
card.textContent
),
category:
normalizedCategory,
venueName:
normalizedVenue
};
/*
* カテゴリselect候補
*/
if (
normalizedCategory &&
!categoryOptions.has(
normalizedCategory
)
) {
categoryOptions.set(
normalizedCategory,
category
);
}
/*
* 会場select候補
*/
if (
normalizedVenue &&
!venueOptions.has(
normalizedVenue
)
) {
venueOptions.set(
normalizedVenue,
venueName
);
}
}
);
/* =====================================
* select option生成
* ===================================== */
function fillFilterOptions(
select,
optionMap
) {
const options =
Array.from(
optionMap.entries()
);
/*
* 表示名で並び替え
*/
options.sort(
function ( a, b ) {
return a[
1
].localeCompare(
b[
1
],
'ja'
);
}
);
options.forEach(
function ( optionData ) {
const value =
optionData[
0
];
const label =
optionData[
1
];
const option =
document.createElement(
'option'
);
option.value =
value;
option.textContent =
label;
select.appendChild(
option
);
}
);
}
fillFilterOptions(
categorySelect,
categoryOptions
);
fillFilterOptions(
venueSelect,
venueOptions
);
/* =====================================
* 件数表示
* ===================================== */
function updateCount(
visible
) {
count.textContent =
'表示:' +
visible +
' / ' +
cards.length +
'件';
}
updateCount(
cards.length
);
/* =====================================
* 検索実行
* ===================================== */
function applySearch() {
/*
* フリーワード
*/
const keyword =
normalizeSearchText(
input.value
);
/*
* カテゴリ
*/
const selectedCategory =
categorySelect.value;
/*
* 会場
*/
const selectedVenue =
venueSelect.value;
let visible =
0;
/*
* 地図に残すplacement_id
*/
const visiblePlacementIds =
[];
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const index =
searchIndex[
placementId
] || {
text:
'',
category:
'',
venueName:
''
};
/* =============================
* フリーワード
* ============================= */
const keywordMatched =
!keyword ||
index.text.includes(
keyword
);
/* =============================
* カテゴリ
* ============================= */
const categoryMatched =
!selectedCategory ||
index.category ===
selectedCategory;
/* =============================
* 会場
* ============================= */
const venueMatched =
!selectedVenue ||
index.venueName ===
selectedVenue;
/* =============================
* AND条件
* ============================= */
const matched =
keywordMatched &&
categoryMatched &&
venueMatched;
if (
matched
) {
card.style.display =
'';
visible +=
1;
/*
* 地図にも残す
*/
visiblePlacementIds.push(
placementId
);
} else {
card.style.display =
'none';
}
}
);
updateCount(
visible
);
/*
* 0件メッセージ
*/
noResults.hidden =
visible !== 0;
/*
* 地図を一覧と同期
*/
syncMapMarkers(
visiblePlacementIds
);
}
/*
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
placementIds
);
input.addEventListener(
'input',
applySearch
);
categorySelect.addEventListener(
'change',
applySearch
);
venueSelect.addEventListener(
'change',
applySearch
);
/* =====================================
* Placement → Offering取得
* ===================================== */
cargoQuery(
'FestivalStallMenuOfferings',
'placement_id=placement_id,' +
'menu_item_id=menu_item_id',
'placement_id IN (' +
placementIds.join(
','
) +
')'
).then(
function ( offerings ) {
const menuItemIds =
[
...new Set(
offerings
.map(
function (
offering
) {
return String(
offering
.menu_item_id ||
''
);
}
)
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
)
];
/*
* メニューが1件も無い
*/
if (
menuItemIds.length === 0
) {
return {
offerings:
offerings,
menus:
[]
};
}
/* =================================
* MenuItem名取得
* ================================= */
return cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'name=menu_name',
'menu_item_id IN (' +
menuItemIds.join(
','
) +
')'
).then(
function ( menus ) {
return {
offerings:
offerings,
menus:
menus
};
}
);
}
).then(
function ( data ) {
if (
!data
) {
return;
}
/* =================================
* menu_item_id → 商品名
* ================================= */
const menuNameMap =
{};
data.menus.forEach(
function ( menu ) {
menuNameMap[
String(
menu.menu_item_id
)
] =
menu.menu_name ||
'';
}
);
/* =================================
* placement_id → 商品名[]
* ================================= */
const placementMenus =
{};
data.offerings.forEach(
function ( offering ) {
const placementId =
String(
offering
.placement_id ||
''
);
const menuItemId =
String(
offering
.menu_item_id ||
''
);
const menuName =
menuNameMap[
menuItemId
] || '';
if (
!menuName
) {
return;
}
if (
!placementMenus[
placementId
]
) {
placementMenus[
placementId
] = [];
}
placementMenus[
placementId
].push(
menuName
);
}
);
/* =================================
* 商品名を検索インデックスへ追加
* ================================= */
cards.forEach(
function ( card ) {
const placementId =
String(
card.dataset
.placementId ||
''
);
const menuNames =
placementMenus[
placementId
] || [];
if (
searchIndex[
placementId
]
) {
searchIndex[
placementId
].text =
normalizeSearchText(
(
searchIndex[
placementId
].text ||
''
) +
' ' +
menuNames.join(
' '
)
);
}
}
);
/*
* 商品データ取得後、
* 入力済み検索を再判定
*/
applySearch();
}
).catch(
function ( error ) {
/*
* 商品データ取得に失敗しても
* 屋台名検索は使えるようにする
*/
console.error(
'屋台商品検索データ取得エラー:',
error
);
}
);
} );
/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const compareRoot =
document.getElementById(
'stall-compare-page'
);
/*
* 屋台比較ページ以外では終了
*/
if ( !compareRoot ) {
return;
}
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MIN_COMPARE = 2;
const MAX_COMPARE = 4;
const api =
new mw.Api();
/* =====================================
* localStorage
* ===================================== */
function getPlacementIds() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse(
raw
);
if (
!Array.isArray(
ids
)
) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
/* =====================================
* Cargo
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return (
id &&
/^\d+$/.test(
id
)
);
}
)
)
];
}
function mapBy(
rows,
key
) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] ===
undefined
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* 表示ヘルパー
* ===================================== */
function textOrDash(
value
) {
if (
value === undefined ||
value === null ||
value === ''
) {
return '―';
}
return String(
value
);
}
function cleanNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return '';
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return '';
}
if (
Number.isInteger(
number
)
) {
return String(
number
);
}
return String(
Math.round(
number * 100
) / 100
);
}
/* =====================================
* 比較計算用数値
* ===================================== */
function toFiniteNumber(
value
) {
if (
value === undefined ||
value === null ||
String( value ).trim() === ''
) {
return null;
}
const number =
Number(
value
);
if (
!Number.isFinite(
number
)
) {
return null;
}
return number;
}
function formatHours(
placement
) {
if ( !placement ) {
return '―';
}
const open =
placement.opening_time || '';
const close =
placement.closing_time || '';
if (
open &&
close
) {
return (
open +
'~' +
close
);
}
if ( open ) {
return (
open +
'~'
);
}
if ( close ) {
return (
'~' +
close
);
}
if (
placement.hours_note
) {
return placement.hours_note;
}
return '未確認';
}
function formatPositionStatus(
status
) {
switch ( status ) {
case 'exact':
return '正確な位置';
case 'approximate':
return 'おおよその位置';
case 'test':
return 'テスト位置';
default:
return '位置未確認';
}
}
function formatVerification(
status
) {
switch ( status ) {
case 'verified':
return '確認済み';
case 'partially_verified':
return '一部確認済み';
case 'outdated':
return '情報が古い';
default:
return '未確認';
}
}
function formatAvailability(
status
) {
switch ( status ) {
case 'available':
return '販売あり';
case 'unavailable':
return '販売なし';
default:
return '未確認';
}
}
/* =====================================
* 単位価格
* 表示用
* ===================================== */
function getUnitPrice(
offering
) {
const price =
toFiniteNumber(
offering.price
);
const quantity =
toFiniteNumber(
offering.serving_quantity
);
if (
price === null ||
quantity === null ||
quantity <= 0
) {
return '―';
}
const unitPrice =
Math.round(
(
price /
quantity
) *
100
) /
100;
const unit =
offering.serving_unit ||
'単位';
return (
unitPrice +
'円/' +
unit
);
}
/* =====================================
* 単位価格
* 比較計算用
* ===================================== */
function getUnitPriceValue(
offering
) {
const price =
toFiniteNumber(
offering.price
);
const quantity =
toFiniteNumber(
offering.serving_quantity
);
if (
price === null ||
quantity === null ||
quantity <= 0
) {
return null;
}
return (
price /
quantity
);
}
/* =====================================
* DOM
* ===================================== */
function createTextCell(
tagName,
text
) {
const cell =
document.createElement(
tagName
);
cell.textContent =
text;
return cell;
}
/* =====================================
* メニュー
* ===================================== */
function createMenuList(
menus
) {
const container =
document.createElement(
'div'
);
container.className =
'stall-compare-menu-list';
if (
!menus ||
menus.length === 0
) {
container.textContent =
'メニュー未登録';
return container;
}
menus.forEach(
function ( item ) {
const menu =
document.createElement(
'div'
);
menu.className =
'stall-compare-menu-item';
const name =
document.createElement(
'strong'
);
name.className =
'stall-compare-menu-name';
name.textContent =
item.menuName ||
'商品';
const price =
document.createElement(
'div'
);
price.className =
'stall-compare-menu-price';
price.textContent =
item.price
? item.price +
'円'
: '価格未確認';
/*
* 最安価格
*/
if (
item.isLowestPrice
) {
const badge =
document.createElement(
'span'
);
badge.className =
'stall-compare-best-badge ' +
'stall-compare-best-price';
badge.textContent =
'最安価格';
price.appendChild(
document.createTextNode(
' '
)
);
price.appendChild(
badge
);
}
const serving =
document.createElement(
'div'
);
if (
item.servingQuantity
) {
serving.textContent =
'内容量:' +
item.servingQuantity +
(
item.servingUnit ||
''
);
} else {
serving.textContent =
'内容量:未確認';
}
const unit =
document.createElement(
'div'
);
unit.className =
'stall-compare-menu-unit-price';
unit.textContent =
'1単位あたり:' +
item.unitPrice;
/*
* 最安単位価格
*/
if (
item.isLowestUnitPrice
) {
const badge =
document.createElement(
'span'
);
badge.className =
'stall-compare-best-badge ' +
'stall-compare-best-unit-price';
badge.textContent =
'最安単位価格';
unit.appendChild(
document.createTextNode(
' '
)
);
unit.appendChild(
badge
);
}
const availability =
document.createElement(
'div'
);
availability.textContent =
'販売状況:' +
item.availability;
menu.appendChild(
name
);
menu.appendChild(
price
);
menu.appendChild(
serving
);
menu.appendChild(
unit
);
menu.appendChild(
availability
);
container.appendChild(
menu
);
}
);
return container;
}
/* =====================================
* 比較表
* ===================================== */
function renderComparison(
compareData
) {
compareRoot.innerHTML =
'';
const heading =
document.createElement(
'h2'
);
heading.textContent =
'屋台比較';
compareRoot.appendChild(
heading
);
const wrapper =
document.createElement(
'div'
);
wrapper.className =
'stall-compare-table-wrapper';
const table =
document.createElement(
'table'
);
table.className =
'stall-compare-table';
/* ------------------------------
* thead
* ------------------------------ */
const thead =
document.createElement(
'thead'
);
const headerRow =
document.createElement(
'tr'
);
headerRow.appendChild(
createTextCell(
'th',
'比較項目'
)
);
compareData.forEach(
function ( data ) {
const th =
document.createElement(
'th'
);
if (
data.stall &&
data.stall.page_name
) {
const link =
document.createElement(
'a'
);
link.href =
mw.util.getUrl(
data.stall
.page_name
);
link.textContent =
data.stall
.stall_name ||
'屋台';
th.appendChild(
link
);
} else {
th.textContent =
data.stall
? data.stall.stall_name
: '屋台';
}
headerRow.appendChild(
th
);
}
);
thead.appendChild(
headerRow
);
table.appendChild(
thead
);
const tbody =
document.createElement(
'tbody'
);
function addRow(
label,
getter
) {
const tr =
document.createElement(
'tr'
);
const labelCell =
createTextCell(
'th',
label
);
labelCell.scope =
'row';
tr.appendChild(
labelCell
);
compareData.forEach(
function ( data ) {
tr.appendChild(
createTextCell(
'td',
textOrDash(
getter(
data
)
)
)
);
}
);
tbody.appendChild(
tr
);
}
/* =================================
* Placement情報
* ================================= */
addRow(
'開催年',
function ( data ) {
return data.placement
? data.placement.year +
'年'
: '―';
}
);
addRow(
'祭り',
function ( data ) {
return data.festival
? data.festival
.festival_name
: '―';
}
);
addRow(
'会場',
function ( data ) {
return data.venue
? data.venue
.venue_name
: '―';
}
);
addRow(
'地域',
function ( data ) {
return data.area
? data.area
.area_name
: '―';
}
);
addRow(
'カテゴリ',
function ( data ) {
return data.stall
? data.stall
.category
: '―';
}
);
addRow(
'出店場所',
function ( data ) {
return data.placement
? data.placement
.location_note
: '―';
}
);
addRow(
'営業時間',
function ( data ) {
return formatHours(
data.placement
);
}
);
addRow(
'位置情報',
function ( data ) {
return data.placement
? formatPositionStatus(
data.placement
.position_status
)
: '―';
}
);
addRow(
'確認状態',
function ( data ) {
return data.placement
? formatVerification(
data.placement
.verification_status
)
: '―';
}
);
/* =================================
* メニュー
* ================================= */
const menuRow =
document.createElement(
'tr'
);
const menuLabel =
createTextCell(
'th',
'メニュー'
);
menuLabel.scope =
'row';
menuRow.appendChild(
menuLabel
);
compareData.forEach(
function ( data ) {
const td =
document.createElement(
'td'
);
td.appendChild(
createMenuList(
data.menus
)
);
menuRow.appendChild(
td
);
}
);
tbody.appendChild(
menuRow
);
table.appendChild(
tbody
);
wrapper.appendChild(
table
);
compareRoot.appendChild(
wrapper
);
const note =
document.createElement(
'p'
);
note.className =
'stall-compare-note';
note.textContent =
'この比較は出店単位(Placement)です。祭り・開催年・会場ごとの価格、営業時間、出店位置を比較しています。';
compareRoot.appendChild(
note
);
}
function renderMessage(
message
) {
compareRoot.innerHTML =
'';
const p =
document.createElement(
'p'
);
p.className =
'stall-compare-page-message';
p.textContent =
message;
compareRoot.appendChild(
p
);
}
/* =====================================
* データ取得
* ===================================== */
const placementIds =
getPlacementIds();
if (
placementIds.length <
MIN_COMPARE
) {
renderMessage(
'比較する出店を2件以上選択してください。'
);
return;
}
/*
* STEP 1
* Placementを直接取得
*/
cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'latitude=latitude,' +
'longitude=longitude,' +
'location_note=location_note,' +
'opening_time=opening_time,' +
'closing_time=closing_time,' +
'hours_note=hours_note,' +
'position_status=position_status,' +
'verification_status=verification_status',
'placement_id IN (' +
makeInClause(
placementIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
/*
* STEP 2
*/
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'category=category,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve(
[]
),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve(
[]
),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'area_id=area_id,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve(
[]
),
cargoQuery(
'FestivalStallMenuOfferings',
'offering_id=offering_id,' +
'placement_id=placement_id,' +
'menu_item_id=menu_item_id,' +
'price=price,' +
'serving_quantity=serving_quantity,' +
'serving_unit=serving_unit,' +
'serving_note=serving_note,' +
'availability=availability,' +
'verification_status=verification_status,' +
'sort_order=sort_order',
'placement_id IN (' +
makeInClause(
placementIds
) +
')',
100
)
] ).then(
function ( results ) {
return {
placements:
placements,
stalls:
results[ 0 ],
festivals:
results[ 1 ],
venues:
results[ 2 ],
offerings:
results[ 3 ]
};
}
);
}
).then(
function ( data ) {
const areaIds =
uniqueIds(
data.venues.map(
function ( row ) {
return row.area_id;
}
)
);
const menuItemIds =
uniqueIds(
data.offerings.map(
function ( row ) {
return row.menu_item_id;
}
)
);
/*
* STEP 3
*/
return Promise.all( [
areaIds.length
? cargoQuery(
'Areas',
'area_id=area_id,' +
'name=area_name,' +
'_pageName=page_name',
'area_id IN (' +
makeInClause(
areaIds
) +
')',
100
)
: Promise.resolve(
[]
),
menuItemIds.length
? cargoQuery(
'StallMenuItems',
'menu_item_id=menu_item_id,' +
'stall_id=stall_id,' +
'name=menu_name,' +
'item_category=item_category,' +
'sort_order=sort_order',
'menu_item_id IN (' +
makeInClause(
menuItemIds
) +
')',
100
)
: Promise.resolve(
[]
)
] ).then(
function ( results ) {
data.areas =
results[ 0 ];
data.menuItems =
results[ 1 ];
return data;
}
);
}
).then(
function ( data ) {
const placementMap =
mapBy(
data.placements,
'placement_id'
);
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
const areaMap =
mapBy(
data.areas,
'area_id'
);
const menuMap =
mapBy(
data.menuItems,
'menu_item_id'
);
/*
* Offering
* placement単位
*/
const offeringsByPlacement =
{};
data.offerings
.slice()
.sort(
function ( a, b ) {
return (
Number(
a.sort_order || 0
) -
Number(
b.sort_order || 0
)
);
}
)
.forEach(
function ( offering ) {
const placementId =
String(
offering
.placement_id
);
if (
!offeringsByPlacement[
placementId
]
) {
offeringsByPlacement[
placementId
] = [];
}
offeringsByPlacement[
placementId
].push(
offering
);
}
);
/*
* localStorage順を維持
*/
const compareData =
placementIds.map(
function (
placementId
) {
const placement =
placementMap[
placementId
] || null;
if ( !placement ) {
return {
placementId:
placementId,
placement:
null,
stall:
null,
festival:
null,
venue:
null,
area:
null,
menus:
[]
};
}
const stall =
stallMap[
String(
placement.stall_id
)
] || null;
const festival =
festivalMap[
String(
placement.festival_id
)
] || null;
const venue =
venueMap[
String(
placement.venue_id
)
] || null;
let area = null;
if (
venue &&
venue.area_id
) {
area =
areaMap[
String(
venue.area_id
)
] || null;
}
const offerings =
offeringsByPlacement[
placementId
] || [];
/*
* Placementに紐づくメニューを生成
*/
const menus =
offerings.map(
function ( offering ) {
const menu =
menuMap[
String(
offering.menu_item_id
)
] || {};
return {
/*
* どの出店の商品か
*/
placementId:
placementId,
menuName:
menu.menu_name ||
'商品',
category:
menu.item_category ||
'',
/*
* 表示価格
*/
price:
cleanNumber(
offering.price
),
/*
* 比較用価格
*/
priceValue:
toFiniteNumber(
offering.price
),
servingQuantity:
cleanNumber(
offering
.serving_quantity
),
servingUnit:
offering
.serving_unit ||
'',
servingNote:
offering
.serving_note ||
'',
/*
* 表示用単位価格
*/
unitPrice:
getUnitPrice(
offering
),
/*
* 比較用単位価格
*/
unitPriceValue:
getUnitPriceValue(
offering
),
availability:
formatAvailability(
offering
.availability
),
verification:
formatVerification(
offering
.verification_status
),
isLowestPrice:
false,
isLowestUnitPrice:
false
};
}
);
return {
placementId:
placementId,
placement:
placement,
stall:
stall,
festival:
festival,
venue:
venue,
area:
area,
menus:
menus
};
}
);
/* =====================================
* 最安価格・最安単位価格
*
* 「同じ商品名 + 同じ単位」
* の商品だけを比較する
* ===================================== */
function markBestPrices(
compareData
) {
const allMenus = [];
/* =================================
* 比較文字列を正規化
*
* 例:
* "たこ焼き"
* " たこ焼き "
*
* を同じものとして扱う
* ================================= */
function normalizeCompareText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
/*
* 全角・半角などを可能な範囲で統一
*/
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
/*
* 連続空白を1つにする
*/
text =
text.replace(
/\s+/g,
' '
);
/*
* 英字商品名にも対応
*/
text =
text.toLowerCase();
return text;
}
/* =================================
* 全メニューを集める
* ================================= */
compareData.forEach(
function ( data ) {
if (
!data.menus ||
!Array.isArray(
data.menus
)
) {
return;
}
data.menus.forEach(
function ( menu ) {
/*
* 毎回初期化
*/
menu.isLowestPrice =
false;
menu.isLowestUnitPrice =
false;
/*
* 比較用の商品名
*/
menu.compareMenuName =
normalizeCompareText(
menu.menuName
);
/*
* 比較用単位
*/
menu.compareUnit =
normalizeCompareText(
menu.servingUnit
);
allMenus.push(
menu
);
}
);
}
);
/* =================================
* 商品名+単位ごとのグループ
*
* 例:
*
* たこ焼き + 個
* 焼きそば + パック
* りんご飴 + 本
* ================================= */
const groups = {};
allMenus.forEach(
function ( menu ) {
/*
* 商品名が無ければ比較しない
*/
if (
!menu.compareMenuName
) {
return;
}
/*
* 単位が無ければ比較しない
*
* 「同じ商品名+同じ単位」
* が条件だから
*/
if (
!menu.compareUnit
) {
return;
}
const groupKey =
menu.compareMenuName +
'||' +
menu.compareUnit;
if (
!groups[
groupKey
]
) {
groups[
groupKey
] = [];
}
groups[
groupKey
].push(
menu
);
}
);
/* =================================
* グループごとに判定
* ================================= */
Object.keys(
groups
).forEach(
function ( groupKey ) {
const menus =
groups[
groupKey
];
/* =============================
* 2出店以上あるか確認
*
* 同じ出店内だけの商品比較は
* 「最安」としない
* ============================= */
const placementIds =
[
...new Set(
menus.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
placementIds.length < 2
) {
return;
}
/* =============================
* 最安価格
*
* 同商品+同単位の
* 販売価格を比較
* ============================= */
const priceCandidates =
menus.filter(
function ( menu ) {
return (
menu.priceValue !==
null &&
Number.isFinite(
menu.priceValue
)
);
}
);
/*
* 価格が登録されている
* 出店が2件以上あるか
*/
const pricePlacementIds =
[
...new Set(
priceCandidates.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
pricePlacementIds.length >= 2
) {
const lowestPrice =
Math.min.apply(
null,
priceCandidates.map(
function ( menu ) {
return menu
.priceValue;
}
)
);
priceCandidates.forEach(
function ( menu ) {
/*
* 円なので通常整数だが
* 小数にも一応対応
*/
if (
Math.abs(
menu.priceValue -
lowestPrice
) <
0.000001
) {
menu.isLowestPrice =
true;
}
}
);
}
/* =============================
* 最安単位価格
*
* 同商品+同単位で
* price / quantity を比較
* ============================= */
const unitPriceCandidates =
menus.filter(
function ( menu ) {
return (
menu.unitPriceValue !==
null &&
Number.isFinite(
menu.unitPriceValue
)
);
}
);
const unitPricePlacementIds =
[
...new Set(
unitPriceCandidates.map(
function ( menu ) {
return String(
menu.placementId
);
}
)
)
];
if (
unitPricePlacementIds.length >= 2
) {
const lowestUnitPrice =
Math.min.apply(
null,
unitPriceCandidates.map(
function ( menu ) {
return menu
.unitPriceValue;
}
)
);
unitPriceCandidates.forEach(
function ( menu ) {
/*
* 割り算による
* 浮動小数誤差対策
*/
if (
Math.abs(
menu.unitPriceValue -
lowestUnitPrice
) <
0.000001
) {
menu.isLowestUnitPrice =
true;
}
}
);
}
}
);
}
/* =====================================
* 商品別比較サマリー
*
* 同じ商品名 + 同じ単位でグループ化
* ===================================== */
function renderProductGroupSummary(
compareData
) {
const comparePage =
document.getElementById(
'stall-compare-page'
);
if (
!comparePage
) {
return;
}
/* =================================
* 文字列正規化
* ================================= */
function normalizeText(
value
) {
if (
value === undefined ||
value === null
) {
return '';
}
let text =
String(
value
).trim();
if (
typeof text.normalize ===
'function'
) {
text =
text.normalize(
'NFKC'
);
}
text =
text.replace(
/\s+/g,
' '
);
return text;
}
/* =================================
* 屋台ページリンクを生成
* ================================= */
function appendStallLinks(
container,
items
) {
const stalls = [];
const seen = {};
items.forEach(
function ( item ) {
/*
* page_nameがある場合は
* page_nameで重複判定
*
* 無い場合は名前で判定
*/
const key =
item.stallPage
? 'page:' +
item.stallPage
: 'name:' +
item.stallName;
if (
seen[
key
]
) {
return;
}
seen[
key
] = true;
stalls.push(
{
name:
item.stallName,
page:
item.stallPage
}
);
}
);
stalls.forEach(
function (
stall,
index
) {
/*
* 2件目以降の区切り
*/
if (
index > 0
) {
container.appendChild(
document.createTextNode(
'・'
)
);
}
/*
* ページが存在する場合
* リンクにする
*/
if (
stall.page
) {
const link =
document.createElement(
'a'
);
link.href =
mw.util.getUrl(
stall.page
);
link.textContent =
stall.name;
link.className =
'stall-product-group-stall-link';
container.appendChild(
link
);
} else {
/*
* page_nameが取得できない場合
* 普通の文字として表示
*/
container.appendChild(
document.createTextNode(
stall.name
)
);
}
}
);
}
/* =================================
* 商品グループ作成
* ================================= */
const groups = {};
compareData.forEach(
function ( data ) {
if (
!data.menus ||
!Array.isArray(
data.menus
)
) {
return;
}
data.menus.forEach(
function ( menu ) {
const menuName =
normalizeText(
menu.menuName
);
const unit =
normalizeText(
menu.servingUnit
);
/*
* 商品名または単位が無いものは
* 商品比較サマリーから除外
*/
if (
!menuName ||
!unit
) {
return;
}
const key =
menuName.toLowerCase() +
'||' +
unit.toLowerCase();
if (
!groups[
key
]
) {
groups[
key
] = {
menuName:
menuName,
unit:
unit,
items:
[]
};
}
groups[
key
].items.push(
{
placementId:
String(
menu.placementId
),
stallName:
(
data.stall &&
data.stall.stall_name
)
? data.stall.stall_name
: '屋台',
/*
* 屋台ページ名
*/
stallPage:
(
data.stall &&
data.stall.page_name
)
? data.stall.page_name
: '',
menu:
menu
}
);
}
);
}
);
const groupKeys =
Object.keys(
groups
);
if (
groupKeys.length === 0
) {
return;
}
/* =================================
* サマリー全体
* ================================= */
const summary =
document.createElement(
'section'
);
summary.className =
'stall-product-group-summary';
const title =
document.createElement(
'h2'
);
title.className =
'stall-product-group-summary-title';
title.textContent =
'商品別比較サマリー';
summary.appendChild(
title
);
/* =================================
* 各商品グループ
* ================================= */
groupKeys.forEach(
function ( key ) {
const group =
groups[
key
];
const items =
group.items;
/*
* 同じPlacementを重複カウントしない
*/
const placementIds =
[
...new Set(
items.map(
function ( item ) {
return item
.placementId;
}
)
)
];
const card =
document.createElement(
'div'
);
card.className =
'stall-product-group-card';
/* =============================
* 商品名
* ============================= */
const heading =
document.createElement(
'h3'
);
heading.className =
'stall-product-group-name';
heading.textContent =
group.menuName +
' / ' +
group.unit;
card.appendChild(
heading
);
/* =============================
* 比較店舗数
* ============================= */
const count =
document.createElement(
'div'
);
count.className =
'stall-product-group-count';
count.textContent =
'比較店舗:' +
placementIds.length +
'店';
card.appendChild(
count
);
/* =============================
* 対象店舗リンク
* ============================= */
const stallList =
document.createElement(
'div'
);
stallList.className =
'stall-product-group-stalls';
const stallListLabel =
document.createElement(
'span'
);
stallListLabel.className =
'stall-product-group-label';
stallListLabel.textContent =
'対象店舗:';
stallList.appendChild(
stallListLabel
);
/*
* 屋台名をリンクとして追加
*/
appendStallLinks(
stallList,
items
);
card.appendChild(
stallList
);
/* =============================
* 1店舗しかない場合
* ============================= */
if (
placementIds.length < 2
) {
const notice =
document.createElement(
'div'
);
notice.className =
'stall-product-group-notice';
notice.textContent =
'比較対象が1店舗のみです。';
card.appendChild(
notice
);
}
/* =============================
* 最安価格の商品
* ============================= */
const lowestPriceItems =
items.filter(
function ( item ) {
return (
item.menu
.isLowestPrice ===
true
);
}
);
if (
lowestPriceItems.length > 0
) {
const lowestPrice =
lowestPriceItems[
0
].menu.priceValue;
const row =
document.createElement(
'div'
);
row.className =
'stall-product-group-best';
const label =
document.createElement(
'span'
);
label.className =
'stall-product-group-label';
label.textContent =
'最安価格:';
const value =
document.createElement(
'strong'
);
value.textContent =
lowestPrice +
'円';
row.appendChild(
label
);
row.appendChild(
value
);
card.appendChild(
row
);
/*
* 最安店舗リンク
*/
const shopRow =
document.createElement(
'div'
);
shopRow.className =
'stall-product-group-shop';
const shopLabel =
document.createElement(
'span'
);
shopLabel.className =
'stall-product-group-label';
shopLabel.textContent =
'最安:';
shopRow.appendChild(
shopLabel
);
/*
* 最安店舗をリンク表示
*/
appendStallLinks(
shopRow,
lowestPriceItems
);
card.appendChild(
shopRow
);
}
/* =============================
* 最安単位価格
* ============================= */
const lowestUnitItems =
items.filter(
function ( item ) {
return (
item.menu
.isLowestUnitPrice ===
true
);
}
);
if (
lowestUnitItems.length > 0
) {
const unitPrice =
lowestUnitItems[
0
].menu.unitPriceValue;
/*
* 小数表示調整
*/
const displayUnitPrice =
Math.round(
unitPrice *
100
) /
100;
const row =
document.createElement(
'div'
);
row.className =
'stall-product-group-best-unit';
const label =
document.createElement(
'span'
);
label.className =
'stall-product-group-label';
label.textContent =
'最安単位価格:';
const value =
document.createElement(
'strong'
);
value.textContent =
displayUnitPrice +
'円/' +
group.unit;
row.appendChild(
label
);
row.appendChild(
value
);
card.appendChild(
row
);
}
summary.appendChild(
card
);
}
);
/*
* 比較表の一番上へ追加
*/
comparePage.insertBefore(
summary,
comparePage.firstChild
);
}
/* =================================
* 最安値を自動判定
* ================================= */
markBestPrices(
compareData
);
renderComparison(
compareData
);
/*
* 詳細比較表を描画した後に
* 商品別サマリーを追加
*/
renderProductGroupSummary(
compareData
);
}
).catch(
function ( error ) {
console.error(
'Placement比較データ取得エラー:',
error
);
renderMessage(
'比較データの取得中にエラーが発生しました。'
);
}
);
} );