diff --git a/app.js b/app.js
index ebffda2..9d98c3a 100644
--- a/app.js
+++ b/app.js
@@ -319,6 +319,8 @@ function getStockTransitions(rarity) {
// Global market data and sort state
let marketData = null;
let currentSort = { column: 'name', direction: 'asc' };
+
+
// Calculate price multiplier based on reputation and rarity
function getPriceMultiplier(reputation, rarity) {
@@ -435,6 +437,7 @@ document.addEventListener('click', (event) => {
function renderMarket(marketData, reputationData) {
const marketContainer = document.getElementById('market-display');
if (!marketContainer || !marketData || !marketData.items) return;
+ captureMarketTableScroll();
// Filter to only available items (stock > unavailable AND reputation high enough)
const availableItems = [];
@@ -478,46 +481,45 @@ function renderMarket(marketData, reputationData) {
return;
}
+ const filterValues = getMarketFilterValues(availableItems);
+ const filteredItems = applyMarketFilters(availableItems);
+
+ if (filteredItems.length === 0) {
+ marketContainer.innerHTML = `
+
+
+
+ ${renderMarketHeader()}
+ ${renderMarketFilters(filterValues)}
+
+
+
+ | No items match the current filters. |
+
+
+
+
+ `;
+ restoreMarketFilterFocus();
+ return;
+ }
+
// Sort items
- sortItems(availableItems, currentSort.column, currentSort.direction);
+ sortItems(filteredItems, currentSort.column, currentSort.direction);
// Build table HTML with scrollable wrapper
let html = `
-
- |
- Name ${getSortIndicator('name')}
- |
-
- Faction ${getSortIndicator('faction')}
- |
-
- Rarity ${getSortIndicator('rarity')}
- |
-
- Stock ${getSortIndicator('stock')}
- |
-
- Cost ${getSortIndicator('cost')}
- |
-
- Attunement ${getSortIndicator('attunement')}
- |
-
+ ${renderMarketHeader()}
+ ${renderMarketFilters(filterValues)}
`;
// Render each item as table row
- availableItems.forEach(item => {
+ filteredItems.forEach(item => {
const stockClass = item.stock.replace(' ', '-');
const factionClass = item.faction.toLowerCase();
const rarityClass = item.rarity.toLowerCase().replace(' ', '-');
@@ -570,9 +572,41 @@ function renderMarket(marketData, reputationData) {
`;
marketContainer.innerHTML = html;
+ restoreMarketFilterFocus();
+ restoreMarketTableScroll();
+}
+
+function renderMarketHeader() {
+ return `
+
+ |
+ Name ${getSortIndicator('name')}
+ |
+
+ Faction ${getSortIndicator('faction')}
+ |
+
+ Rarity ${getSortIndicator('rarity')}
+ |
+
+ Stock ${getSortIndicator('stock')}
+ |
+
+ Cost ${getSortIndicator('cost')}
+ |
+
+ Attunement ${getSortIndicator('attunement')}
+ |
+
+ `;
}
-// Sort items by column
function sortItems(items, column, direction) {
const multiplier = direction === 'asc' ? 1 : -1;
@@ -841,6 +875,9 @@ document.addEventListener('click', (event) => {
tooltip.classList.remove('show');
});
}
+ if (!event.target.closest('.market-filter-dropdown')) {
+ closeMarketFilterDropdown();
+ }
});
function renderQuestBoard(quests, currentTime) {
diff --git a/filtering.js b/filtering.js
new file mode 100644
index 0000000..226ec7b
--- /dev/null
+++ b/filtering.js
@@ -0,0 +1,393 @@
+// Magic Item Market filtering state and helpers
+let marketFilters = {
+ name: '',
+ faction: [],
+ rarity: [],
+ stock: [],
+ attunement: [],
+ costMin: '',
+ costMax: ''
+};
+let marketFilterFocus = null;
+let marketFilterOpenKey = null;
+let marketFilterSearch = {
+ faction: '',
+ rarity: '',
+ stock: '',
+ attunement: ''
+};
+let marketTableScrollLeft = null;
+let marketFilterAllValues = {
+ faction: [],
+ rarity: [],
+ stock: [],
+ attunement: []
+};
+let marketFiltersInitialized = false;
+
+function renderMarketFilters(values) {
+ // Build the filter row markup for the market table.
+ const { factions, rarities, stocks, attunements } = values;
+ return `
+
+ |
+
+
+
+
+ |
+
+ ${renderFilterDropdown('faction', 'Faction', factions, marketFilters.faction)}
+ |
+
+ ${renderFilterDropdown('rarity', 'Rarity', rarities, marketFilters.rarity)}
+ |
+
+ ${renderFilterDropdown('stock', 'Stock', stocks, marketFilters.stock, toTitleCase)}
+ |
+
+
+ |
+
+ ${renderFilterDropdown('attunement', 'Attunement', attunements, marketFilters.attunement)}
+ |
+
+ `;
+}
+
+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);
+ const attunements = getUniqueValues(items, item => item.attunementRequired);
+ marketFilterAllValues = {
+ faction: factions,
+ rarity: rarities,
+ stock: stocks,
+ attunement: attunements
+ };
+ if (!marketFiltersInitialized) {
+ marketFilters.faction = [...factions];
+ marketFilters.rarity = [...rarities];
+ marketFilters.stock = [...stocks];
+ marketFilters.attunement = [...attunements];
+ marketFiltersInitialized = true;
+ }
+ return { factions, rarities, stocks, attunements };
+}
+
+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 `
+
+ `;
+ }).join('');
+
+ const openClass = marketFilterOpenKey === key ? ' show' : '';
+ return `
+
+
+
+
+
+ ${options || '
No matches
'}
+
+
+
+ `;
+}
+
+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 attunement = marketFilters.attunement;
+ 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 (attunement.length === 0 || !attunement.includes(item.attunementRequired)) 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;
+}
+
diff --git a/index.html b/index.html
index f6df24c..ce380e6 100644
--- a/index.html
+++ b/index.html
@@ -75,7 +75,11 @@
+
+
+