378 lines
13 KiB
JavaScript
378 lines
13 KiB
JavaScript
// Magic Item Market filtering state and helpers
|
||
let marketFilters = {
|
||
name: '',
|
||
faction: [],
|
||
rarity: [],
|
||
stock: [],
|
||
costMin: '',
|
||
costMax: ''
|
||
};
|
||
let marketFilterFocus = null;
|
||
let marketFilterOpenKey = null;
|
||
let marketFilterSearch = {
|
||
faction: '',
|
||
rarity: '',
|
||
stock: '',
|
||
};
|
||
let marketTableScrollLeft = null;
|
||
let marketFilterAllValues = {
|
||
faction: [],
|
||
rarity: [],
|
||
stock: [],
|
||
};
|
||
let marketFiltersInitialized = false;
|
||
let marketFilterDebounce = null;
|
||
function renderMarketFilters(values) {
|
||
// Build the filter row markup for the market table.
|
||
const { factions, rarities, stocks, } = values;
|
||
return `
|
||
<tr class="market-filter-row">
|
||
<th>
|
||
<div class="market-filter-search">
|
||
<input class="market-filter-input market-filter-search-input" type="text" placeholder="Search"
|
||
value="${escapeAttribute(marketFilters.name)}"
|
||
data-filter-key="name"
|
||
oninput="updateMarketFilter('name', this.value)" />
|
||
<button class="market-filter-clear${marketFilters.name ? ' active' : ''}"
|
||
type="button"
|
||
aria-label="Clear name filter"
|
||
onclick="clearMarketFilter('name')">×</button>
|
||
</div>
|
||
</th>
|
||
<th>
|
||
${renderFilterDropdown('faction', 'Faction', factions, marketFilters.faction)}
|
||
</th>
|
||
<th>
|
||
${renderFilterDropdown('rarity', 'Rarity', rarities, marketFilters.rarity)}
|
||
</th>
|
||
<th>
|
||
${renderFilterDropdown('stock', 'Stock', stocks, marketFilters.stock, toTitleCase)}
|
||
</th>
|
||
<th class="cost-col">
|
||
<div class="market-filter-range">
|
||
<div class="market-filter-search">
|
||
<input class="market-filter-input cost-filter" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="Min"
|
||
value="${escapeAttribute(marketFilters.costMin)}"
|
||
data-filter-key="costMin"
|
||
oninput="updateMarketFilter('costMin', this.value)" />
|
||
<button class="market-filter-clear${marketFilters.costMin ? ' active' : ''}"
|
||
type="button"
|
||
aria-label="Clear minimum cost"
|
||
onclick="clearMarketFilter('costMin')">×</button>
|
||
</div>
|
||
<div class="market-filter-search">
|
||
<input class="market-filter-input cost-filter" type="text" inputmode="numeric" pattern="[0-9]*" placeholder="Max"
|
||
value="${escapeAttribute(marketFilters.costMax)}"
|
||
data-filter-key="costMax"
|
||
oninput="updateMarketFilter('costMax', this.value)" />
|
||
<button class="market-filter-clear${marketFilters.costMax ? ' active' : ''}"
|
||
type="button"
|
||
aria-label="Clear maximum cost"
|
||
onclick="clearMarketFilter('costMax')">×</button>
|
||
</div>
|
||
</div>
|
||
</th>
|
||
</tr>
|
||
`;
|
||
}
|
||
|
||
function getMarketFilterValues(items) {
|
||
// Derive filter options once per render, seeding defaults on first load.
|
||
const factions = getUniqueValues(items, item => item.faction);
|
||
const rarities = getRarityValues();
|
||
const stocks = getUniqueValues(items, item => item.stock, stockSort);
|
||
marketFilterAllValues = {
|
||
faction: factions,
|
||
rarity: rarities,
|
||
stock: stocks,
|
||
};
|
||
if (!marketFiltersInitialized) {
|
||
marketFilters.faction = [...factions];
|
||
marketFilters.rarity = [...rarities];
|
||
marketFilters.stock = [...stocks];
|
||
marketFiltersInitialized = true;
|
||
}
|
||
return { factions, rarities, stocks };
|
||
}
|
||
|
||
function getUniqueValues(items, getter, sorter) {
|
||
// Collect unique values and apply optional custom sort.
|
||
const values = new Set();
|
||
items.forEach(item => {
|
||
const value = getter(item);
|
||
if (value) values.add(value);
|
||
});
|
||
const list = Array.from(values);
|
||
if (sorter) {
|
||
return list.sort(sorter);
|
||
}
|
||
return list.sort((a, b) => a.localeCompare(b));
|
||
}
|
||
|
||
function renderFilterDropdown(key, label, values, selected, labelFormatter) {
|
||
// Compact filter UI with search and multi-select checkboxes.
|
||
const formatted = labelFormatter || (value => value);
|
||
const search = marketFilterSearch[key] || '';
|
||
const filteredValues = values.filter(value =>
|
||
value.toLowerCase().includes(search.toLowerCase())
|
||
);
|
||
// "All" is shown only when every value is selected; "NONE" means zero selected.
|
||
const isAllSelected = selected.length === values.length && values.length > 0;
|
||
const isNoneSelected = selected.length === 0;
|
||
const summary = isAllSelected
|
||
? 'All'
|
||
: isNoneSelected
|
||
? 'NONE'
|
||
: selected.length === 1
|
||
? formatted(selected[0])
|
||
: `${selected.length} selected`;
|
||
const options = filteredValues.map(value => {
|
||
const isChecked = selected.includes(value) ? ' checked' : '';
|
||
const display = escapeAttribute(formatted(value));
|
||
return `
|
||
<label class="market-filter-option">
|
||
<input type="checkbox" value="${escapeAttribute(value)}"${isChecked}
|
||
onchange="toggleMarketFilterValue('${key}', this.value, this.checked)" />
|
||
<span>${display}</span>
|
||
</label>
|
||
`;
|
||
}).join('');
|
||
|
||
const openClass = marketFilterOpenKey === key ? ' show' : '';
|
||
return `
|
||
<div class="market-filter-dropdown" data-filter-key="${key}">
|
||
<button type="button" class="market-filter-trigger${isNoneSelected ? ' is-empty' : ''}" onclick="toggleMarketFilterDropdown('${key}')">
|
||
${label}: <span class="market-filter-summary">${escapeAttribute(summary)}</span>
|
||
</button>
|
||
<div class="market-filter-popover${openClass}" onclick="event.stopPropagation()">
|
||
<div class="market-filter-popover-header">
|
||
<input class="market-filter-input market-filter-popover-search" type="text" placeholder="Search"
|
||
value="${escapeAttribute(search)}"
|
||
data-filter-search-key="${key}"
|
||
oninput="updateMarketFilterSearch('${key}', this.value)" />
|
||
<div class="market-filter-popover-actions">
|
||
<button type="button" onclick="selectAllMarketFilter('${key}')">All</button>
|
||
<button type="button" onclick="clearMarketFilter('${key}')">Clear</button>
|
||
</div>
|
||
</div>
|
||
<div class="market-filter-options">
|
||
${options || '<div class="market-filter-empty">No matches</div>'}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
function toTitleCase(value) {
|
||
// Title-case display helper for stock labels.
|
||
return value.replace(/\b\w/g, (char) => char.toUpperCase());
|
||
}
|
||
|
||
function stockSort(a, b) {
|
||
// Enforce stock order from most scarce to most available.
|
||
const order = ["Very Limited", "Limited", "Modest", "Abundant", "Very Abundant"];
|
||
return order.indexOf(toTitleCase(a)) - order.indexOf(toTitleCase(b));
|
||
}
|
||
|
||
function raritySort(a, b) {
|
||
// Enforce rarity order from rarest to most common.
|
||
const order = ["Unique", "Artifact", "Legendary", "Very Rare", "Rare", "Uncommon", "Common"];
|
||
const indexA = order.indexOf(a);
|
||
const indexB = order.indexOf(b);
|
||
if (indexA === -1 && indexB === -1) return a.localeCompare(b);
|
||
if (indexA === -1) return 1;
|
||
if (indexB === -1) return -1;
|
||
return indexA - indexB;
|
||
}
|
||
|
||
function getRarityValues() {
|
||
// Use full dataset so rarities aren't limited by current visibility.
|
||
if (typeof MAGIC_ITEMS_DATA === 'undefined') return [];
|
||
const values = new Set();
|
||
MAGIC_ITEMS_DATA.forEach(item => {
|
||
if (item.rarity) values.add(item.rarity);
|
||
});
|
||
const list = Array.from(values);
|
||
return list.sort(raritySort);
|
||
}
|
||
|
||
function applyMarketFilters(items) {
|
||
// Empty selections mean "show none" for that filter (intentional for clear state).
|
||
const name = marketFilters.name.trim().toLowerCase();
|
||
const faction = marketFilters.faction;
|
||
const rarity = marketFilters.rarity;
|
||
const stock = marketFilters.stock;
|
||
const costMin = marketFilters.costMin === '' ? null : Number(marketFilters.costMin);
|
||
const costMax = marketFilters.costMax === '' ? null : Number(marketFilters.costMax);
|
||
|
||
return items.filter(item => {
|
||
if (name && !item.name.toLowerCase().includes(name)) return false;
|
||
if (faction.length === 0 || !faction.includes(item.faction)) return false;
|
||
if (rarity.length === 0 || !rarity.includes(item.rarity)) return false;
|
||
if (stock.length === 0 || !stock.includes(item.stock)) return false;
|
||
if (costMin !== null && item.adjustedPrice < costMin) return false;
|
||
if (costMax !== null && item.adjustedPrice > costMax) return false;
|
||
return true;
|
||
});
|
||
}
|
||
|
||
function updateMarketFilter(key, value) {
|
||
// Preserve focus while re-rendering.
|
||
captureMarketFilterFocus();
|
||
marketFilters[key] = value;
|
||
scheduleMarketFilterRender();
|
||
}
|
||
|
||
function toggleMarketFilterValue(key, value, isChecked) {
|
||
// Toggle a checkbox value within the active selection array.
|
||
const values = marketFilters[key];
|
||
if (!Array.isArray(values)) return;
|
||
const index = values.indexOf(value);
|
||
if (isChecked && index === -1) {
|
||
values.push(value);
|
||
} else if (!isChecked && index >= 0) {
|
||
values.splice(index, 1);
|
||
}
|
||
scheduleMarketFilterRender();
|
||
}
|
||
|
||
function clearMarketFilter(key) {
|
||
// Clear selection or input for the given filter key.
|
||
captureMarketFilterFocus();
|
||
if (Array.isArray(marketFilters[key])) {
|
||
marketFilters[key] = [];
|
||
} else {
|
||
marketFilters[key] = '';
|
||
}
|
||
if (key === 'costMin' || key === 'costMax' || key === 'name') {
|
||
marketFilterFocus = {
|
||
key,
|
||
selectionStart: 0,
|
||
selectionEnd: 0
|
||
};
|
||
}
|
||
scheduleMarketFilterRender();
|
||
}
|
||
|
||
function selectAllMarketFilter(key) {
|
||
// Select all values for the given filter.
|
||
const values = marketFilters[key];
|
||
if (!Array.isArray(values)) return;
|
||
marketFilters[key] = [...(marketFilterAllValues[key] || [])];
|
||
scheduleMarketFilterRender();
|
||
}
|
||
|
||
function toggleMarketFilterDropdown(key) {
|
||
// Open/close the filter popover for the given column.
|
||
marketFilterOpenKey = marketFilterOpenKey === key ? null : key;
|
||
scheduleMarketFilterRender();
|
||
if (marketFilterOpenKey) {
|
||
requestAnimationFrame(() => positionMarketFilterPopover(marketFilterOpenKey));
|
||
}
|
||
}
|
||
|
||
function updateMarketFilterSearch(key, value) {
|
||
// Update popover search text and refresh the list.
|
||
captureMarketFilterFocus();
|
||
marketFilterSearch[key] = value;
|
||
scheduleMarketFilterRender();
|
||
}
|
||
|
||
function closeMarketFilterDropdown() {
|
||
// Close any open popover when clicking outside.
|
||
if (marketFilterOpenKey !== null) {
|
||
captureMarketFilterFocus();
|
||
marketFilterOpenKey = null;
|
||
scheduleMarketFilterRender();
|
||
}
|
||
}
|
||
|
||
function positionMarketFilterPopover(key) {
|
||
// Keep the popover within the viewport on narrow screens.
|
||
const dropdown = document.querySelector(`.market-filter-dropdown[data-filter-key="${key}"]`);
|
||
if (!dropdown) return;
|
||
const trigger = dropdown.querySelector('.market-filter-trigger');
|
||
const popover = dropdown.querySelector('.market-filter-popover');
|
||
if (!popover || !trigger) return;
|
||
|
||
const margin = 12;
|
||
const viewportWidth = window.innerWidth;
|
||
const triggerRect = trigger.getBoundingClientRect();
|
||
|
||
popover.style.position = 'absolute';
|
||
popover.style.left = '0';
|
||
popover.style.right = 'auto';
|
||
popover.style.transform = 'none';
|
||
|
||
const popoverRect = popover.getBoundingClientRect();
|
||
const popoverWidth = popoverRect.width;
|
||
|
||
const maxLeft = Math.max(margin, viewportWidth - popoverWidth - margin);
|
||
const left = Math.min(Math.max(triggerRect.left, margin), maxLeft);
|
||
const shiftLeft = triggerRect.left - left;
|
||
if (shiftLeft !== 0) {
|
||
popover.style.transform = `translateX(-${shiftLeft}px)`;
|
||
}
|
||
}
|
||
|
||
function captureMarketFilterFocus() {
|
||
// Track cursor placement to avoid input blur on re-render.
|
||
const el = document.activeElement;
|
||
if (!el || !el.dataset) return;
|
||
if (el.dataset.filterKey) {
|
||
marketFilterFocus = {
|
||
type: 'filter',
|
||
key: el.dataset.filterKey,
|
||
selectionStart: el.selectionStart,
|
||
selectionEnd: el.selectionEnd
|
||
};
|
||
} else if (el.dataset.filterSearchKey) {
|
||
marketFilterFocus = {
|
||
type: 'search',
|
||
key: el.dataset.filterSearchKey,
|
||
selectionStart: el.selectionStart,
|
||
selectionEnd: el.selectionEnd
|
||
};
|
||
}
|
||
}
|
||
|
||
function restoreMarketFilterFocus() {
|
||
// Restore focus and caret to the active filter input.
|
||
if (!marketFilterFocus) return;
|
||
const selector = marketFilterFocus.type === 'search'
|
||
? `[data-filter-search-key="${marketFilterFocus.key}"]`
|
||
: `[data-filter-key="${marketFilterFocus.key}"]`;
|
||
const el = document.querySelector(selector);
|
||
if (!el) return;
|
||
el.focus();
|
||
if (typeof el.selectionStart === 'number') {
|
||
el.setSelectionRange(marketFilterFocus.selectionStart, marketFilterFocus.selectionEnd);
|
||
}
|
||
marketFilterFocus = null;
|
||
}
|
||
|
||
function captureMarketTableScroll() {
|
||
// Preserve horizontal scroll when re-rendering on mobile.
|
||
const wrapper = document.querySelector('.market-table-wrapper');
|
||
if (!wrapper) return;
|
||
marketTableScrollLeft = wrapper.scrollLeft;
|
||
}
|
||
|
||
function restoreMarketTableScroll() {
|
||
// Restore horizontal scroll position after re-render.
|
||
if (marketTableScrollLeft === null) return;
|
||
const wrapper = document.querySelector('.market-table-wrapper');
|
||
if (!wrapper) return;
|
||
wrapper.scrollLeft = marketTableScrollLeft;
|
||
marketTableScrollLeft = null;
|
||
}
|
||
|
||
function scheduleMarketFilterRender() {
|
||
clearTimeout(marketFilterDebounce);
|
||
marketFilterDebounce = setTimeout(() => {
|
||
if (marketData && campaignData) {
|
||
renderMarket(marketData, campaignData.reputation);
|
||
}
|
||
}, 250);
|
||
}
|