編集の要約なし |
編集の要約なし |
||
| 4,550行目: | 4,550行目: | ||
renderComparison( | renderComparison( | ||
compareData | |||
); | |||
/* | |||
* 詳細比較表を描画した後に | |||
* 商品別サマリーを追加 | |||
*/ | |||
renderProductGroupSummary( | |||
compareData | compareData | ||
); | ); | ||
2026年8月12日 (水) 20:33時点における版
/* ========================================
* 屋台比較
* placement_id 正式版
*
* 最大4出店
*
* localStorage:
* matsuriWikiComparePlacements
*
* 1 placement =
* 1 festival + 1 year + 1 venue + 1 stall
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MAX_COMPARE = 4;
const api =
new mw.Api();
/*
* ページ表示中だけ使うキャッシュ
*/
const placementInfoCache = {};
/* =====================================
* localStorage
* ===================================== */
function getComparePlacements() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse( raw );
if ( !Array.isArray( ids ) ) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
function saveComparePlacements( ids ) {
mw.storage.set(
STORAGE_KEY,
JSON.stringify( ids )
);
}
/* =====================================
* Cargo共通処理
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return item.title || item;
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test( id );
}
)
)
];
}
function mapBy( rows, key ) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] === undefined ||
row[ key ] === null
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* Placement情報取得
* ===================================== */
function fetchPlacementInfo( ids ) {
if ( ids.length === 0 ) {
return Promise.resolve( {} );
}
const result = {};
const missingIds = [];
ids.forEach(
function ( id ) {
if (
placementInfoCache[ id ]
) {
result[ id ] =
placementInfoCache[ id ];
} else {
missingIds.push( id );
}
}
);
if ( missingIds.length === 0 ) {
return Promise.resolve(
result
);
}
return cargoQuery(
'FestivalStallPlacements',
'placement_id=placement_id,' +
'stall_id=stall_id,' +
'festival_id=festival_id,' +
'venue_id=venue_id,' +
'year=year,' +
'location_note=location_note',
'placement_id IN (' +
makeInClause(
missingIds
) +
')',
100
).then(
function ( placements ) {
const stallIds =
uniqueIds(
placements.map(
function ( row ) {
return row.stall_id;
}
)
);
const festivalIds =
uniqueIds(
placements.map(
function ( row ) {
return row.festival_id;
}
)
);
const venueIds =
uniqueIds(
placements.map(
function ( row ) {
return row.venue_id;
}
)
);
return Promise.all( [
stallIds.length
? cargoQuery(
'Stalls',
'stall_id=stall_id,' +
'name=stall_name,' +
'_pageName=page_name',
'stall_id IN (' +
makeInClause(
stallIds
) +
')',
100
)
: Promise.resolve( [] ),
festivalIds.length
? cargoQuery(
'Festivals',
'festival_id=festival_id,' +
'name=festival_name,' +
'_pageName=page_name',
'festival_id IN (' +
makeInClause(
festivalIds
) +
')',
100
)
: Promise.resolve( [] ),
venueIds.length
? cargoQuery(
'Venues',
'venue_id=venue_id,' +
'name=venue_name,' +
'_pageName=page_name',
'venue_id IN (' +
makeInClause(
venueIds
) +
')',
100
)
: Promise.resolve( [] )
] ).then(
function ( related ) {
return {
placements:
placements,
stalls:
related[ 0 ],
festivals:
related[ 1 ],
venues:
related[ 2 ]
};
}
);
}
).then(
function ( data ) {
const stallMap =
mapBy(
data.stalls,
'stall_id'
);
const festivalMap =
mapBy(
data.festivals,
'festival_id'
);
const venueMap =
mapBy(
data.venues,
'venue_id'
);
data.placements.forEach(
function ( placement ) {
const placementId =
String(
placement.placement_id
);
const info = {
placement:
placement,
stall:
stallMap[
String(
placement.stall_id
)
] || null,
festival:
festivalMap[
String(
placement.festival_id
)
] || null,
venue:
venueMap[
String(
placement.venue_id
)
] || null
};
placementInfoCache[
placementId
] = info;
result[
placementId
] = info;
}
);
return result;
}
);
}
/* =====================================
* メッセージ
* ===================================== */
function showMessage(
control,
message,
isError
) {
const element =
control.querySelector(
'.stall-compare-message'
);
if ( !element ) {
return;
}
element.textContent =
message;
element.classList.toggle(
'stall-compare-message-error',
Boolean( isError )
);
}
/* =====================================
* 比較ボタン生成
*
* 通常ページ
* stall-compare-placeholder-1
*
* 地図popup
* stall-map-compare-placeholder-1
*
* の両方に対応
* ===================================== */
function createCompareButtons() {
document
.querySelectorAll(
'.stall-compare-placeholder, ' +
'.stall-map-compare-placeholder'
)
.forEach(
function ( placeholder ) {
/*
* すでにbutton生成済み
*/
if (
placeholder.dataset.initialized ===
'1'
) {
return;
}
let placementId = '';
/*
* 通常ページ
*/
const normalPrefix =
'stall-compare-placeholder-';
/*
* 地図popup
*/
const mapPrefix =
'stall-map-compare-placeholder-';
if (
placeholder.id.startsWith(
normalPrefix
)
) {
placementId =
placeholder.id.substring(
normalPrefix.length
);
} else if (
placeholder.id.startsWith(
mapPrefix
)
) {
placementId =
placeholder.id.substring(
mapPrefix.length
);
} else {
return;
}
/*
* placement_idチェック
*/
if (
!/^\d+$/.test(
placementId
) ||
placementId === '0'
) {
return;
}
/*
* 本物のbuttonを
* JavaScript側で作る
*/
const button =
document.createElement(
'button'
);
button.type =
'button';
button.className =
'stall-compare-button';
button.dataset.placementId =
placementId;
button.setAttribute(
'aria-pressed',
'false'
);
button.textContent =
'比較に追加';
placeholder.appendChild(
button
);
/*
* 二重生成防止
*/
placeholder.dataset.initialized =
'1';
}
);
}
/* =====================================
* ボタン状態更新
* ===================================== */
function updateCompareButtons() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'.stall-compare-button'
)
.forEach(
function ( button ) {
const placementId =
String(
button.dataset
.placementId || ''
);
const selected =
ids.includes(
placementId
);
if ( selected ) {
button.textContent =
'比較から外す';
button.classList.add(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'true'
);
} else {
button.textContent =
'比較に追加';
button.classList.remove(
'stall-compare-button-selected'
);
button.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 固定比較トレイ生成
* ===================================== */
function createCompareTray() {
if (
document.getElementById(
'stall-compare-tray'
)
) {
return;
}
const tray =
document.createElement(
'div'
);
tray.id =
'stall-compare-tray';
tray.className =
'stall-compare-tray';
tray.setAttribute(
'aria-live',
'polite'
);
const header =
document.createElement(
'div'
);
header.className =
'stall-compare-tray-header';
const title =
document.createElement(
'strong'
);
title.className =
'stall-compare-tray-title';
title.textContent =
'比較候補';
const count =
document.createElement(
'span'
);
count.className =
'stall-compare-tray-count';
header.appendChild(
title
);
header.appendChild(
count
);
const items =
document.createElement(
'div'
);
items.className =
'stall-compare-tray-items';
const actions =
document.createElement(
'div'
);
actions.className =
'stall-compare-tray-actions';
const clearButton =
document.createElement(
'button'
);
clearButton.type =
'button';
clearButton.className =
'stall-compare-clear';
clearButton.textContent =
'すべて外す';
const compareLink =
document.createElement(
'a'
);
compareLink.className =
'stall-compare-open';
compareLink.href =
mw.util.getUrl(
'屋台比較'
);
compareLink.textContent =
'比較する';
actions.appendChild(
clearButton
);
actions.appendChild(
compareLink
);
tray.appendChild(
header
);
tray.appendChild(
items
);
tray.appendChild(
actions
);
document.body.appendChild(
tray
);
}
/* =====================================
* 比較トレイ更新
* ===================================== */
function updateCompareTray() {
createCompareTray();
const tray =
document.getElementById(
'stall-compare-tray'
);
if ( !tray ) {
return;
}
const ids =
getComparePlacements();
const count =
tray.querySelector(
'.stall-compare-tray-count'
);
const items =
tray.querySelector(
'.stall-compare-tray-items'
);
const compareLink =
tray.querySelector(
'.stall-compare-open'
);
if ( count ) {
count.textContent =
ids.length +
' / ' +
MAX_COMPARE;
}
if ( ids.length === 0 ) {
tray.classList.remove(
'stall-compare-tray-visible'
);
if ( items ) {
items.innerHTML = '';
}
return;
}
tray.classList.add(
'stall-compare-tray-visible'
);
if ( compareLink ) {
if ( ids.length >= 2 ) {
compareLink.classList.remove(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'false'
);
} else {
compareLink.classList.add(
'stall-compare-open-disabled'
);
compareLink.setAttribute(
'aria-disabled',
'true'
);
}
}
if ( !items ) {
return;
}
items.textContent =
'屋台情報を読み込み中…';
fetchPlacementInfo(
ids
).then(
function ( placementInfo ) {
const currentIds =
getComparePlacements();
items.innerHTML =
'';
currentIds.forEach(
function ( placementId ) {
const info =
placementInfo[
placementId
];
if ( !info ) {
return;
}
const item =
document.createElement(
'div'
);
item.className =
'stall-compare-tray-item';
const text =
document.createElement(
'div'
);
const name =
document.createElement(
info.stall &&
info.stall.page_name
? 'a'
: 'span'
);
if (
info.stall &&
info.stall.page_name
) {
name.href =
mw.util.getUrl(
info.stall.page_name
);
}
name.className =
'stall-compare-tray-item-name';
name.textContent =
info.stall
? info.stall.stall_name
: '屋台';
const context =
document.createElement(
'div'
);
context.className =
'stall-compare-tray-item-context';
const parts = [];
if (
info.placement &&
info.placement.year
) {
parts.push(
info.placement.year +
'年'
);
}
if (
info.festival &&
info.festival.festival_name
) {
parts.push(
info.festival
.festival_name
);
}
if (
info.venue &&
info.venue.venue_name
) {
parts.push(
info.venue
.venue_name
);
}
context.textContent =
parts.join(
' / '
);
text.appendChild(
name
);
text.appendChild(
context
);
const removeButton =
document.createElement(
'button'
);
removeButton.type =
'button';
removeButton.className =
'stall-compare-tray-remove';
removeButton.dataset.placementId =
placementId;
removeButton.setAttribute(
'aria-label',
(
info.stall
? info.stall.stall_name
: '屋台'
) +
'を比較候補から外す'
);
removeButton.textContent =
'×';
item.appendChild(
text
);
item.appendChild(
removeButton
);
items.appendChild(
item
);
}
);
}
).catch(
function ( error ) {
console.error(
'比較トレイ取得エラー:',
error
);
items.textContent =
'比較候補を読み込めませんでした。';
}
);
}
/* =====================================
* 詳細ページ
* 「比較に追加」
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-button'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
if (
!/^\d+$/.test(
placementId
)
) {
return;
}
const control =
button.closest(
'.stall-compare-control'
);
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
/*
* すでに選択中
*/
if ( index !== -1 ) {
ids.splice(
index,
1
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補から外しました。',
false
);
}
return;
}
/*
* 最大4件
*/
if (
ids.length >=
MAX_COMPARE
) {
if ( control ) {
showMessage(
control,
'比較できる出店は最大4件です。',
true
);
}
return;
}
ids.push(
placementId
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
if ( control ) {
showMessage(
control,
'比較候補に追加しました(' +
ids.length +
'/4)。',
false
);
}
}
);
/* =====================================
* トレイから1件削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-tray-remove'
);
if ( !button ) {
return;
}
event.preventDefault();
const placementId =
String(
button.dataset
.placementId || ''
);
let ids =
getComparePlacements();
ids =
ids.filter(
function ( id ) {
return id !== placementId;
}
);
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 全削除
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const button =
event.target.closest(
'.stall-compare-clear'
);
if ( !button ) {
return;
}
event.preventDefault();
saveComparePlacements(
[]
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* 1件時の比較リンク無効
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'.stall-compare-open-disabled'
);
if ( !link ) {
return;
}
event.preventDefault();
}
);
/* =====================================
* 初期化
* ===================================== */
function initPlacementCompare() {
createCompareButtons();
createCompareTray();
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
initPlacementCompare();
mw.hook(
'wikipage.content'
).add(
function () {
initPlacementCompare();
}
);
/* =====================================
* 地図popup 比較リンク
* ===================================== */
function getMapComparePlacementId( link ) {
if ( !link ) {
return '';
}
const href =
link.getAttribute( 'href' ) || '';
const match =
href.match(
/#compare-placement-(\d+)$/
);
if ( !match ) {
return '';
}
return match[ 1 ];
}
function updateMapCompareLinks() {
const ids =
getComparePlacements();
document
.querySelectorAll(
'a[href*="#compare-placement-"]'
)
.forEach(
function ( link ) {
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
const selected =
ids.includes(
placementId
);
link.classList.add(
'stall-map-compare-link'
);
link.dataset.placementId =
placementId;
if ( selected ) {
link.textContent =
'比較から外す';
link.classList.add(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'true'
);
} else {
link.textContent =
'比較に追加';
link.classList.remove(
'stall-map-compare-link-selected'
);
link.setAttribute(
'aria-pressed',
'false'
);
}
}
);
}
/* =====================================
* 地図popupクリック
* ===================================== */
document.addEventListener(
'click',
function ( event ) {
const link =
event.target.closest(
'a[href*="#compare-placement-"]'
);
if ( !link ) {
return;
}
const placementId =
getMapComparePlacementId(
link
);
if (
!placementId ||
placementId === '0'
) {
return;
}
event.preventDefault();
let ids =
getComparePlacements();
const index =
ids.indexOf(
placementId
);
if ( index !== -1 ) {
ids.splice(
index,
1
);
} else {
if (
ids.length >=
MAX_COMPARE
) {
link.textContent =
'最大4件までです';
return;
}
ids.push(
placementId
);
}
saveComparePlacements(
ids
);
updateCompareButtons();
updateMapCompareLinks();
updateCompareTray();
}
);
/* =====================================
* Leaflet popup生成監視
* ===================================== */
const mapCompareLinkObserver =
new MutationObserver(
function ( mutations ) {
let needsUpdate =
false;
mutations.forEach(
function ( mutation ) {
mutation.addedNodes.forEach(
function ( node ) {
if (
node.nodeType !== 1
) {
return;
}
if (
node.matches &&
node.matches(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
return;
}
if (
node.querySelector &&
node.querySelector(
'a[href*="#compare-placement-"]'
)
) {
needsUpdate =
true;
}
}
);
}
);
if ( needsUpdate ) {
updateMapCompareLinks();
}
}
);
mapCompareLinkObserver.observe(
document.body,
{
childList: true,
subtree: true
}
);
/*
* ★ この } ); が
* placement比較用mw.loaderの終了
*/
} );
/* ========================================
* 屋台比較ページ
* placement_id 正式版
* ======================================== */
mw.loader.using( [
'mediawiki.storage',
'mediawiki.api',
'mediawiki.util'
] ).then( function () {
'use strict';
const compareRoot =
document.getElementById(
'stall-compare-page'
);
/*
* 屋台比較ページ以外では終了
*/
if ( !compareRoot ) {
return;
}
const STORAGE_KEY =
'matsuriWikiComparePlacements';
const MIN_COMPARE = 2;
const MAX_COMPARE = 4;
const api =
new mw.Api();
/* =====================================
* localStorage
* ===================================== */
function getPlacementIds() {
const raw =
mw.storage.get(
STORAGE_KEY
);
if ( !raw ) {
return [];
}
try {
const ids =
JSON.parse(
raw
);
if (
!Array.isArray(
ids
)
) {
return [];
}
return [ ...new Set(
ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
) ].slice(
0,
MAX_COMPARE
);
} catch ( e ) {
return [];
}
}
/* =====================================
* Cargo
* ===================================== */
function cargoQuery(
table,
fields,
where,
limit
) {
const params = {
action: 'cargoquery',
tables: table,
fields: fields,
limit: limit || 100,
format: 'json'
};
if ( where ) {
params.where = where;
}
return api.get(
params
).then(
function ( data ) {
if (
!data ||
!Array.isArray(
data.cargoquery
)
) {
return [];
}
return data.cargoquery.map(
function ( item ) {
return (
item.title ||
item
);
}
);
}
);
}
function makeInClause( ids ) {
return ids
.map( String )
.filter(
function ( id ) {
return /^\d+$/.test(
id
);
}
)
.join( ',' );
}
function uniqueIds( values ) {
return [
...new Set(
values
.map( String )
.filter(
function ( id ) {
return (
id &&
/^\d+$/.test(
id
)
);
}
)
)
];
}
function mapBy(
rows,
key
) {
const result = {};
rows.forEach(
function ( row ) {
if (
row[ key ] ===
undefined
) {
return;
}
result[
String(
row[ key ]
)
] = row;
}
);
return result;
}
/* =====================================
* 表示ヘルパー
* ===================================== */
function textOrDash(
value
) {
if (
value === undefined ||
value === null ||
value === ''
) {
return '―';
}
return String(
value
);
}
function cleanNumber(
value
) {
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;
}
/* =================================
* 商品グループ作成
* ================================= */
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
: '屋台',
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
);
/* =============================
* 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 shops =
[
...new Set(
lowestPriceItems.map(
function (
item
) {
return item
.stallName;
}
)
)
];
const shopRow =
document.createElement(
'div'
);
shopRow.className =
'stall-product-group-shop';
shopRow.textContent =
'最安:' +
shops.join(
'・'
);
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(
'比較データの取得中にエラーが発生しました。'
);
}
);
} );