From 4e37f2b0ee1fd07d4a3a3decee1059b7ffe2dca7 Mon Sep 17 00:00:00 2001 From: Thomas Date: Sat, 24 Jan 2026 23:56:35 -0500 Subject: [PATCH] add filtering to magic item market --- app.js | 95 +++++++++---- filtering.js | 393 +++++++++++++++++++++++++++++++++++++++++++++++++++ index.html | 4 + styles.css | 174 +++++++++++++++++++++++ 4 files changed, 637 insertions(+), 29 deletions(-) create mode 100644 filtering.js 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 = `
- - - - - - - - + ${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 ` + + + + + + + + + `; } -// 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 ` + + + + + + + + + `; +} + +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 @@ + + + + diff --git a/styles.css b/styles.css index 244709e..1999655 100644 --- a/styles.css +++ b/styles.css @@ -979,6 +979,7 @@ button { .no-items { text-align: center; + vertical-align: top; padding: 2rem; color: #b8a992; font-style: italic; @@ -1017,6 +1018,177 @@ button { background: rgba(139, 115, 85, 0.5); } +.market-table thead tr:first-child th { + white-space: nowrap; +} + +.market-filter-row th { + background: rgba(245, 222, 179, 0.5); + padding: 0.5rem 0.75rem; + vertical-align: top; + white-space: normal; +} + +.market-table tbody { + height: 40vh; +} + +.market-filter-input, +.market-filter-select { + width: 100%; + padding: 0.35rem 0.5rem; + border: 1px solid rgba(139, 115, 85, 0.5); + border-radius: 4px; + font-size: 0.85rem; + background: #fff; + color: #2c1810; +} + +.market-filter-input::placeholder { + color: #8b7355; +} + +.market-filter-search { + position: relative; +} + +.market-filter-search-input { + padding-right: 1.6rem; +} + +.market-filter-clear { + position: absolute; + top: 50%; + right: 6px; + transform: translateY(-50%); + border: none; + padding: 0 0.5vw; + background: transparent; + color: #8b7355; + cursor: pointer; + font-size: 1.5rem; + line-height: 1; + opacity: 0; + pointer-events: none; + transition: opacity 0.15s ease; +} + +.market-filter-clear.active { + opacity: 0.9; + pointer-events: auto; +} + +.market-filter-range { + display: flex; + flex-wrap: wrap; + gap: 0.35rem; +} + +.cost-col { + width: 150px; + min-width: 150px; +} + +.market-filter-input.cost-filter { + flex: 1 1 70px; + min-width: 0; +} + +.market-filter-dropdown { + position: relative; +} + +.market-filter-trigger { + width: 100%; + text-align: left; + border: 1px solid rgba(139, 115, 85, 0.5); + border-radius: 4px; + background: #fff; + color: #2c1810; + padding: 0.35rem 0.5rem; + font-size: 0.85rem; + cursor: pointer; +} + +.market-filter-trigger.is-empty { + border-color: #b22222; + color: #b22222; +} + +.market-filter-summary { + font-weight: 600; +} + +.market-filter-popover { + position: absolute; + top: calc(100% + 6px); + left: 0; + z-index: 20; + width: 125px; + max-width: 80vw; + background: #fff; + border: 1px solid rgba(139, 115, 85, 0.5); + border-radius: 6px; + box-shadow: 0 8px 18px rgba(0,0,0,0.2); + padding: 0.5rem; + display: none; +} + +.market-filter-popover.show { + display: block; +} + +.market-filter-popover-header { + display: grid; + gap: 0.35rem; +} + +.market-filter-popover-search { + font-size: 0.8rem; +} + +.market-filter-popover-actions { + display: flex; + justify-content: space-between; + gap: 0.35rem; +} + +.market-filter-popover-actions button { + border: 1px solid rgba(139, 115, 85, 0.4); + background: rgba(245, 222, 179, 0.6); + color: #2c1810; + border-radius: 4px; + padding: 0.2rem 0.4rem; + font-size: 0.75rem; + cursor: pointer; +} + +.market-filter-options { + margin-top: 0.5rem; + max-height: 180px; + overflow-y: auto; + display: grid; + gap: 0.25rem; +} + +.market-filter-option { + display: flex; + align-items: center; + gap: 0.35rem; + font-size: 0.8rem; + color: #2c1810; + cursor: pointer; +} + +.market-filter-option input { + accent-color: #8b4513; +} + +.market-filter-empty { + font-size: 0.75rem; + color: #8b7355; +} + .market-table tbody tr { border-bottom: 1px solid rgba(139, 115, 85, 0.2); transition: background 0.2s ease; @@ -1159,10 +1331,12 @@ button { /* Market table wrapper for horizontal scroll */ .market-table-wrapper { overflow-x: auto; + overflow-y: visible; -webkit-overflow-scrolling: touch; margin: 0 -10px; padding: 0 10px; position: relative; + min-height: 320px; } /* Scroll hint shadow on mobile */
- Name ${getSortIndicator('name')} - - Faction ${getSortIndicator('faction')} - - Rarity ${getSortIndicator('rarity')} - - Stock ${getSortIndicator('stock')} - - Cost ${getSortIndicator('cost')} - - Attunement ${getSortIndicator('attunement')} -
+ Name ${getSortIndicator('name')} + + Faction ${getSortIndicator('faction')} + + Rarity ${getSortIndicator('rarity')} + + Stock ${getSortIndicator('stock')} + + Cost ${getSortIndicator('cost')} + + Attunement ${getSortIndicator('attunement')} +
+ + + ${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)} +