編集の要約なし |
編集の要約なし |
||
| 7,772行目: | 7,772行目: | ||
return; | return; | ||
} | } | ||
if (rows.length === 0) { | if (rows.length === 0) { | ||
2026年8月15日 (土) 15:42時点における版
/* ========================================
* 屋台比較
* 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 resetButton =
document.createElement(
'button'
);
resetButton.type =
'button';
resetButton.className =
'festival-stall-search-reset';
resetButton.textContent =
'絞り込みをリセット';
resetButton.setAttribute(
'aria-label',
'屋台の検索条件をすべてリセット'
);
resetButton.disabled =
true;
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(
resetButton
);
searchBox.appendChild(
count
);
searchBox.appendChild(
noResults
);
/*
* 最初の屋台カードの直前に表示
*/
/*
* 検索UIの表示位置
*/
const searchAnchor =
document.getElementById(
'festival-stall-search-anchor'
);
if (
searchAnchor
) {
searchAnchor.appendChild(
searchBox
);
} else {
/*
* 古いテンプレート等への
* フォールバック
*/
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;
/*
* 検索条件が1つでもあれば
* リセットボタンを有効化
*/
resetButton.disabled =
(
normalizeSearchText(
input.value
) === '' &&
categorySelect.value === '' &&
venueSelect.value === ''
);
/*
* 地図を一覧と同期
*/
syncMapMarkers(
visiblePlacementIds
);
}
/*
* 各カードへ
* 地図で見るボタン
*/
createMapViewButtons();
/*
* 初期状態
*
* 最初は全placementを表示
*/
syncMapMarkers(
placementIds
);
input.addEventListener(
'input',
applySearch
);
categorySelect.addEventListener(
'change',
applySearch
);
venueSelect.addEventListener(
'change',
applySearch
);
/* =====================================
* 絞り込みをすべてリセット
* ===================================== */
resetButton.addEventListener(
'click',
function () {
/*
* フリーワード
*/
input.value =
'';
/*
* カテゴリ
*/
categorySelect.value =
'';
/*
* 会場
*/
venueSelect.value =
'';
/*
* 一覧・件数・0件表示・
* 地図markerをすべて再計算
*/
applySearch();
/*
* 続けて検索しやすくする
*/
input.focus();
}
);
/* =====================================
* 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(
'比較データの取得中にエラーが発生しました。'
);
}
);
} );
$(function () {
const statusLabels = {
active: '出店中・出店予定',
cancelled: '出店中止',
unknown: '未確認',
test: 'テストデータ'
};
const select = document.querySelector(
'select[name="FestivalStallPlacement[status]"]'
);
if (!select) {
return;
}
Array.from(select.options).forEach(function (option) {
if (statusLabels[option.value]) {
option.textContent = statusLabels[option.value];
}
});
});
$(function () {
const statusLabels = {
active: '出店中・出店予定',
cancelled: '出店中止',
unknown: '未確認',
test: 'テストデータ'
};
const statusSelect = document.querySelector(
'select[name="FestivalStallPlacement[status]"]'
);
if (statusSelect) {
Array.from(statusSelect.options).forEach(function (option) {
if (statusLabels[option.value]) {
option.textContent = statusLabels[option.value];
}
});
}
const verificationLabels = {
verified: '確認済み',
partially_verified: '一部確認済み',
unverified: '未確認',
outdated: '情報が古い可能性あり'
};
const verificationSelect = document.querySelector(
'select[name="FestivalStallPlacement[verification_status]"]'
);
if (verificationSelect) {
Array.from(verificationSelect.options).forEach(function (option) {
if (verificationLabels[option.value]) {
option.textContent = verificationLabels[option.value];
}
});
}
const yearInput = document.querySelector(
'input[name="FestivalStallPlacement[year]"]'
);
if (yearInput) {
yearInput.inputMode = 'numeric';
yearInput.maxLength = 4;
const validateYear = function () {
const value = yearInput.value.trim();
if (value !== '' && !/^\d{4}$/.test(value)) {
yearInput.setCustomValidity(
'開催年は4桁の数字で入力してください(例:2026)'
);
} else {
yearInput.setCustomValidity('');
}
};
yearInput.addEventListener('input', validateYear);
yearInput.addEventListener('change', validateYear);
yearInput.addEventListener('invalid', validateYear);
validateYear();
}
const positionLabels = {
exact: '位置確認済み',
approximate: 'おおよその位置',
unknown: '位置未確認',
test: 'テスト位置'
};
const positionSelect = document.querySelector(
'select[name="FestivalStallPlacement[position_status]"]'
);
if (positionSelect) {
Array.from(positionSelect.options).forEach(function (option) {
if (positionLabels[option.value]) {
option.textContent = positionLabels[option.value];
}
});
}
const accuracyInput = document.querySelector(
'input[name="FestivalStallPlacement[position_accuracy_m]"]'
);
if (accuracyInput) {
accuracyInput.inputMode = 'numeric';
const validateAccuracy = function () {
const value = accuracyInput.value.trim();
if (value !== '' && !/^\d+$/.test(value)) {
accuracyInput.setCustomValidity(
'位置精度は0以上の整数で入力してください(例:10)'
);
} else {
accuracyInput.setCustomValidity('');
}
};
accuracyInput.addEventListener('input', validateAccuracy);
accuracyInput.addEventListener('change', validateAccuracy);
accuracyInput.addEventListener('invalid', validateAccuracy);
validateAccuracy();
}
const priceMinInput = document.querySelector(
'input[name="FestivalStallPlacement[price_min]"]'
);
const priceMaxInput = document.querySelector(
'input[name="FestivalStallPlacement[price_max]"]'
);
if (priceMinInput && priceMaxInput) {
priceMinInput.inputMode = 'numeric';
priceMaxInput.inputMode = 'numeric';
const validatePrices = function () {
const minValue = priceMinInput.value.trim();
const maxValue = priceMaxInput.value.trim();
priceMinInput.setCustomValidity('');
priceMaxInput.setCustomValidity('');
if (minValue !== '' && !/^\d+$/.test(minValue)) {
priceMinInput.setCustomValidity(
'最低価格は0以上の整数で入力してください。'
);
return;
}
if (maxValue !== '' && !/^\d+$/.test(maxValue)) {
priceMaxInput.setCustomValidity(
'最高価格は0以上の整数で入力してください。'
);
return;
}
if (
minValue !== '' &&
maxValue !== '' &&
Number(minValue) > Number(maxValue)
) {
priceMaxInput.setCustomValidity(
'最高価格は最低価格以上にしてください。'
);
}
};
priceMinInput.addEventListener('input', validatePrices);
priceMaxInput.addEventListener('input', validatePrices);
priceMinInput.addEventListener('change', validatePrices);
priceMaxInput.addEventListener('change', validatePrices);
validatePrices();
}
const openingTimeInput = document.querySelector(
'input[name="FestivalStallPlacement[opening_time]"]'
);
const closingTimeInput = document.querySelector(
'input[name="FestivalStallPlacement[closing_time]"]'
);
const timePattern = /^([01]\d|2[0-3]):[0-5]\d$/;
function setupTimeValidation(input, label) {
if (!input) {
return;
}
input.placeholder = '例:10:00';
const validateTime = function () {
const value = input.value.trim();
input.setCustomValidity('');
if (value !== '' && !timePattern.test(value)) {
input.setCustomValidity(
label + 'は24時間表記の HH:MM 形式で入力してください(例:10:00)'
);
}
};
input.addEventListener('input', validateTime);
input.addEventListener('change', validateTime);
input.addEventListener('invalid', validateTime);
validateTime();
}
setupTimeValidation(openingTimeInput, '営業開始時刻');
setupTimeValidation(closingTimeInput, '営業終了時刻');
const latitudeInput = document.querySelector(
'input[name="FestivalStallPlacement[latitude]"]'
);
const longitudeInput = document.querySelector(
'input[name="FestivalStallPlacement[longitude]"]'
);
function setupCoordinateValidation(input, label, min, max) {
if (!input) {
return null;
}
input.inputMode = 'decimal';
const validateCoordinate = function () {
const value = input.value.trim();
input.setCustomValidity('');
/*
* exact または approximate の場合は
* 緯度・経度を必須にする。
*/
if (value === '') {
if (
positionSelect &&
(
positionSelect.value === 'exact' ||
positionSelect.value === 'approximate'
)
) {
input.setCustomValidity(
label +
'は「位置確認済み」または「おおよその位置」を選択した場合は必須です。'
);
}
return;
}
/*
* 数値形式チェック
*/
if (!/^-?\d+(\.\d+)?$/.test(value)) {
input.setCustomValidity(
label + 'は数値で入力してください。'
);
return;
}
/*
* 範囲チェック
*/
const number = Number(value);
if (number < min || number > max) {
input.setCustomValidity(
label +
'は' +
min +
'〜' +
max +
'の範囲で入力してください。'
);
}
};
input.addEventListener(
'input',
validateCoordinate
);
input.addEventListener(
'change',
validateCoordinate
);
input.addEventListener(
'invalid',
validateCoordinate
);
validateCoordinate();
/*
* position_status変更時に
* 再チェックできるよう関数を返す。
*/
return validateCoordinate;
}
const validateLatitude =
setupCoordinateValidation(
latitudeInput,
'緯度',
-90,
90
);
const validateLongitude =
setupCoordinateValidation(
longitudeInput,
'経度',
-180,
180
);
/*
* 位置情報の状態を変更した場合、
* 緯度・経度を再検証する。
*/
if (positionSelect) {
positionSelect.addEventListener(
'change',
function () {
if (validateLatitude) {
validateLatitude();
}
if (validateLongitude) {
validateLongitude();
}
}
);
}
const sourceUrlInput = document.querySelector(
'input[name="FestivalStallPlacement[source_url]"]'
);
if (sourceUrlInput) {
sourceUrlInput.inputMode = 'url';
const validateSourceUrl = function () {
const value = sourceUrlInput.value.trim();
sourceUrlInput.setCustomValidity('');
if (value === '') {
return;
}
try {
const url = new URL(value);
if (url.protocol !== 'http:' && url.protocol !== 'https:') {
sourceUrlInput.setCustomValidity(
'情報元URLは http:// または https:// で始まるURLを入力してください。'
);
}
} catch (e) {
sourceUrlInput.setCustomValidity(
'情報元URLを正しいURL形式で入力してください。'
);
}
};
sourceUrlInput.addEventListener('input', validateSourceUrl);
sourceUrlInput.addEventListener('change', validateSourceUrl);
sourceUrlInput.addEventListener('invalid', validateSourceUrl);
validateSourceUrl();
}
const sortOrderInput = document.querySelector(
'input[name="FestivalStallPlacement[sort_order]"]'
);
if (sortOrderInput) {
sortOrderInput.inputMode = 'numeric';
const validateSortOrder = function () {
const value = sortOrderInput.value.trim();
sortOrderInput.setCustomValidity('');
if (value !== '' && !/^\d+$/.test(value)) {
sortOrderInput.setCustomValidity(
'表示順は0以上の整数で入力してください(例:1)'
);
}
};
sortOrderInput.addEventListener('input', validateSortOrder);
sortOrderInput.addEventListener('change', validateSortOrder);
sortOrderInput.addEventListener('invalid', validateSortOrder);
validateSortOrder();
}
});
/**
* FestivalStallPlacement - 最終確認日の未来日チェック
*/
(function () {
'use strict';
function setupLastConfirmedValidation() {
const dateInputs = document.querySelectorAll(
'input[name="FestivalStallPlacement[last_confirmed]"]'
);
dateInputs.forEach(function (dateInput) {
if (dateInput.dataset.lastConfirmedValidation === '1') {
return;
}
dateInput.dataset.lastConfirmedValidation = '1';
function getVisibleInput() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
return widget.querySelector('input[type="text"]');
}
function getErrorElement() {
const widget = dateInput.closest('.oo-ui-widget');
if (!widget) {
return null;
}
let error = widget.parentNode.querySelector(
'.stall-last-confirmed-error'
);
if (!error) {
error = document.createElement('div');
error.className = 'stall-last-confirmed-error';
error.setAttribute('role', 'alert');
error.hidden = true;
widget.insertAdjacentElement('afterend', error);
}
return error;
}
function showError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (!visibleInput || !error) {
return;
}
let message;
if (dateInput.validity.rangeOverflow) {
const maxDate = dateInput.max.replace(/-/g, '/');
message =
'未来の日付は入力できません。' +
maxDate +
'以前の日付を入力してください。';
} else {
message =
dateInput.validationMessage ||
'正しい日付を入力してください。';
}
error.textContent = message;
error.hidden = false;
visibleInput.setAttribute('aria-invalid', 'true');
}
function clearError() {
const visibleInput = getVisibleInput();
const error = getErrorElement();
if (error) {
error.hidden = true;
error.textContent = '';
}
if (visibleInput) {
visibleInput.removeAttribute('aria-invalid');
}
}
/*
* 非表示の date input に対する
* ブラウザ標準エラー表示を止める。
*/
dateInput.addEventListener('invalid', function (event) {
event.preventDefault();
showError();
const visibleInput = getVisibleInput();
if (visibleInput) {
window.setTimeout(function () {
visibleInput.focus();
}, 0);
}
});
/*
* ユーザーが日付を修正したら
* 有効になった時点でエラーを消す。
*/
const visibleInput = getVisibleInput();
if (visibleInput) {
['input', 'change'].forEach(function (eventName) {
visibleInput.addEventListener(eventName, function () {
window.setTimeout(function () {
if (dateInput.validity.valid) {
clearError();
} else if (dateInput.validity.rangeOverflow) {
showError();
}
}, 0);
});
});
}
});
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupLastConfirmedValidation
);
} else {
setupLastConfirmedValidation();
}
mw.hook('wikipage.content').add(function () {
setupLastConfirmedValidation();
});
})();
/**
* FestivalStallPlacement
* Cargo既存レコードによる重複候補警告
*
* festival + year + venue + stall が同じでも
* 出店場所が違う可能性があるため保存は禁止しない。
*/
mw.loader.using([
'mediawiki.api',
'mediawiki.util'
]).then(function () {
'use strict';
const api = new mw.Api();
function setupFestivalStallDuplicateWarning() {
const form = document.getElementById('pfForm');
if (!form) {
return;
}
if (form.dataset.stallDuplicateWarning === '1') {
return;
}
const placementInput = form.querySelector(
'input[name="FestivalStallPlacement[placement_id]"]'
);
const stallSelect = form.querySelector(
'select[name="FestivalStallPlacement[stall_id]"]'
);
const festivalSelect = form.querySelector(
'select[name="FestivalStallPlacement[festival_id]"]'
);
const venueSelect = form.querySelector(
'select[name="FestivalStallPlacement[venue_id]"]'
);
const yearInput = form.querySelector(
'input[name="FestivalStallPlacement[year]"]'
);
if (
!placementInput ||
!stallSelect ||
!festivalSelect ||
!venueSelect ||
!yearInput
) {
return;
}
form.dataset.stallDuplicateWarning = '1';
let checkTimer = null;
let requestNumber = 0;
function escapeCargoString(value) {
return String(value).replace(/'/g, "''");
}
function cargoQuery(table, fields, where, limit) {
return api.get({
action: 'cargoquery',
tables: table,
fields: fields,
where: where,
limit: limit || 50,
format: 'json'
}).then(function (data) {
if (
!data ||
!Array.isArray(data.cargoquery)
) {
return [];
}
return data.cargoquery.map(function (item) {
return item.title || item;
});
});
}
/**
* 表示名からCargo内部IDを取得
*/
function resolveId(
table,
idField,
nameField,
rawValue
) {
if (!rawValue) {
return Promise.resolve(null);
}
if (/^\d+$/.test(rawValue)) {
return Promise.resolve(rawValue);
}
return cargoQuery(
table,
idField + '=resolved_id',
nameField +
"='" +
escapeCargoString(rawValue) +
"'",
2
).then(function (rows) {
if (rows.length !== 1) {
return null;
}
return String(rows[0].resolved_id);
});
}
function getWarningBox() {
let box = form.querySelector(
'.stall-duplicate-warning'
);
if (box) {
return box;
}
box = document.createElement('div');
box.className = 'stall-duplicate-warning';
box.hidden = true;
box.setAttribute('role', 'status');
const cell = yearInput.closest('td');
if (cell) {
cell.appendChild(box);
}
return box;
}
function clearWarning() {
const box = getWarningBox();
box.hidden = true;
box.replaceChildren();
}
function showQueryFailure() {
const box = getWarningBox();
box.replaceChildren();
const message = document.createElement('div');
message.className =
'stall-duplicate-warning-text';
message.textContent =
'既存の出店情報を確認できませんでした。' +
'登録はできますが、重複がないか後で確認してください。';
box.appendChild(message);
box.hidden = false;
}
function showWarning(rows) {
const box = getWarningBox();
box.replaceChildren();
const title = document.createElement('strong');
title.className =
'stall-duplicate-warning-title';
title.textContent =
'⚠ 同じ祭り・開催年・会場・屋台の既存データが' +
rows.length +
'件あります。';
box.appendChild(title);
const description =
document.createElement('p');
description.className =
'stall-duplicate-warning-description';
description.textContent =
'同じ条件でも出店場所が異なる場合は、' +
'新しい出店情報として登録できます。' +
'登録前に既存データをご確認ください。';
box.appendChild(description);
const list = document.createElement('ul');
list.className =
'stall-duplicate-warning-list';
rows.forEach(function (row) {
const item = document.createElement('li');
const link = document.createElement('a');
link.href = mw.util.getUrl(
row.page_name
);
/*
* 入力中フォームを失わないよう
* 新しいタブで開く。
*/
link.target = '_blank';
link.rel = 'noopener';
let label =
'出店情報 #' +
row.placement_id;
if (
row.location_note &&
String(row.location_note).trim() !== ''
) {
label +=
' — ' +
row.location_note;
} else {
label +=
' — 場所メモなし';
}
link.textContent = label;
item.appendChild(link);
list.appendChild(item);
});
box.appendChild(list);
const footer = document.createElement('div');
footer.className =
'stall-duplicate-warning-footer';
footer.textContent =
'出店場所が同じ場合は、既存データの編集をおすすめします。';
box.appendChild(footer);
box.hidden = false;
}
async function checkExistingPlacements() {
const currentRequest =
++requestNumber;
const placementId =
placementInput.value.trim();
const stallValue =
stallSelect.value.trim();
const festivalValue =
festivalSelect.value.trim();
const venueValue =
venueSelect.value.trim();
const year =
yearInput.value.trim();
/*
* 4項目が揃うまでは警告しない。
*/
if (
!stallValue ||
!festivalValue ||
!venueValue ||
!/^\d{4}$/.test(year)
) {
clearWarning();
return;
}
try {
const ids = await Promise.all([
resolveId(
'Stalls',
'stall_id',
'name',
stallValue
),
resolveId(
'Festivals',
'festival_id',
'name',
festivalValue
),
resolveId(
'Venues',
'venue_id',
'name',
venueValue
)
]);
/*
* より新しい検索が始まっていた場合は
* 古い結果を表示しない。
*/
if (currentRequest !== requestNumber) {
return;
}
const stallId = ids[0];
const festivalId = ids[1];
const venueId = ids[2];
if (
!stallId ||
!festivalId ||
!venueId
) {
clearWarning();
return;
}
const where =
'festival_id=' +
festivalId +
' AND year=' +
year +
' AND venue_id=' +
venueId +
' AND stall_id=' +
stallId;
let rows = await cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'location_note=location_note,' +
'_pageName=page_name',
where,
50
);
if (currentRequest !== requestNumber) {
return;
}
if (rows.length === 0) {
clearWarning();
return;
}
showWarning(rows);
} catch (error) {
console.error(
'FestivalStallPlacement duplicate warning:',
error
);
showQueryFailure();
}
}
function scheduleCheck() {
window.clearTimeout(checkTimer);
checkTimer = window.setTimeout(
checkExistingPlacements,
300
);
}
/*
* 選択内容が変わったら自動確認。
*/
[
stallSelect,
festivalSelect,
venueSelect
].forEach(function (element) {
element.addEventListener(
'change',
scheduleCheck
);
});
yearInput.addEventListener(
'input',
scheduleCheck
);
yearInput.addEventListener(
'change',
scheduleCheck
);
/*
* 編集ページなど、最初から値が
* 入っている場合にも確認。
*/
scheduleCheck();
}
if (document.readyState === 'loading') {
document.addEventListener(
'DOMContentLoaded',
setupFestivalStallDuplicateWarning
);
} else {
setupFestivalStallDuplicateWarning();
}
mw.hook('pf.formSetupAfter').add(
setupFestivalStallDuplicateWarning
);
});