eberron-quest-board/filtering.js
2026-01-28 05:53:41 -05:00

383 lines
13 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// 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;
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;
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
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);
}
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
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
};
}
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
function selectAllMarketFilter(key) {
// Select all values for the given filter.
const values = marketFilters[key];
if (!Array.isArray(values)) return;
marketFilters[key] = [...(marketFilterAllValues[key] || [])];
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
function toggleMarketFilterDropdown(key) {
// Open/close the filter popover for the given column.
marketFilterOpenKey = marketFilterOpenKey === key ? null : key;
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
if (marketFilterOpenKey) {
requestAnimationFrame(() => positionMarketFilterPopover(marketFilterOpenKey));
}
}
function updateMarketFilterSearch(key, value) {
// Update popover search text and refresh the list.
captureMarketFilterFocus();
marketFilterSearch[key] = value;
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
function closeMarketFilterDropdown() {
// Close any open popover when clicking outside.
if (marketFilterOpenKey !== null) {
marketFilterOpenKey = null;
if (marketData && campaignData) {
renderMarket(marketData, campaignData.reputation);
}
}
}
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;
}