add filtering to magic item market

This commit is contained in:
Thomas 2026-01-24 23:56:35 -05:00
parent 49754b1394
commit 4e37f2b0ee
4 changed files with 637 additions and 29 deletions

95
app.js
View File

@ -319,6 +319,8 @@ function getStockTransitions(rarity) {
// Global market data and sort state // Global market data and sort state
let marketData = null; let marketData = null;
let currentSort = { column: 'name', direction: 'asc' }; let currentSort = { column: 'name', direction: 'asc' };
// Calculate price multiplier based on reputation and rarity // Calculate price multiplier based on reputation and rarity
function getPriceMultiplier(reputation, rarity) { function getPriceMultiplier(reputation, rarity) {
@ -435,6 +437,7 @@ document.addEventListener('click', (event) => {
function renderMarket(marketData, reputationData) { function renderMarket(marketData, reputationData) {
const marketContainer = document.getElementById('market-display'); const marketContainer = document.getElementById('market-display');
if (!marketContainer || !marketData || !marketData.items) return; if (!marketContainer || !marketData || !marketData.items) return;
captureMarketTableScroll();
// Filter to only available items (stock > unavailable AND reputation high enough) // Filter to only available items (stock > unavailable AND reputation high enough)
const availableItems = []; const availableItems = [];
@ -478,46 +481,45 @@ function renderMarket(marketData, reputationData) {
return; return;
} }
const filterValues = getMarketFilterValues(availableItems);
const filteredItems = applyMarketFilters(availableItems);
if (filteredItems.length === 0) {
marketContainer.innerHTML = `
<div class="market-table-wrapper">
<table class="market-table">
<thead>
${renderMarketHeader()}
${renderMarketFilters(filterValues)}
</thead>
<tbody>
<tr>
<td class="no-items" colspan="6">No items match the current filters.</td>
</tr>
</tbody>
</table>
</div>
`;
restoreMarketFilterFocus();
return;
}
// Sort items // Sort items
sortItems(availableItems, currentSort.column, currentSort.direction); sortItems(filteredItems, currentSort.column, currentSort.direction);
// Build table HTML with scrollable wrapper // Build table HTML with scrollable wrapper
let html = ` let html = `
<div class="market-table-wrapper"> <div class="market-table-wrapper">
<table class="market-table"> <table class="market-table">
<thead> <thead>
<tr> ${renderMarketHeader()}
<th class="sortable ${currentSort.column === 'name' ? 'sorted-' + currentSort.direction : ''}" ${renderMarketFilters(filterValues)}
onclick="sortMarket('name')">
Name ${getSortIndicator('name')}
</th>
<th class="sortable ${currentSort.column === 'faction' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('faction')">
Faction ${getSortIndicator('faction')}
</th>
<th class="sortable ${currentSort.column === 'rarity' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('rarity')">
Rarity ${getSortIndicator('rarity')}
</th>
<th class="sortable ${currentSort.column === 'stock' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('stock')">
Stock ${getSortIndicator('stock')}
</th>
<th class="sortable ${currentSort.column === 'cost' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('cost')">
Cost ${getSortIndicator('cost')}
</th>
<th class="sortable ${currentSort.column === 'attunement' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('attunement')">
Attunement ${getSortIndicator('attunement')}
</th>
</tr>
</thead> </thead>
<tbody> <tbody>
`; `;
// Render each item as table row // Render each item as table row
availableItems.forEach(item => { filteredItems.forEach(item => {
const stockClass = item.stock.replace(' ', '-'); const stockClass = item.stock.replace(' ', '-');
const factionClass = item.faction.toLowerCase(); const factionClass = item.faction.toLowerCase();
const rarityClass = item.rarity.toLowerCase().replace(' ', '-'); const rarityClass = item.rarity.toLowerCase().replace(' ', '-');
@ -570,9 +572,41 @@ function renderMarket(marketData, reputationData) {
`; `;
marketContainer.innerHTML = html; marketContainer.innerHTML = html;
restoreMarketFilterFocus();
restoreMarketTableScroll();
}
function renderMarketHeader() {
return `
<tr>
<th class="sortable ${currentSort.column === 'name' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('name')">
Name ${getSortIndicator('name')}
</th>
<th class="sortable ${currentSort.column === 'faction' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('faction')">
Faction ${getSortIndicator('faction')}
</th>
<th class="sortable ${currentSort.column === 'rarity' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('rarity')">
Rarity ${getSortIndicator('rarity')}
</th>
<th class="sortable ${currentSort.column === 'stock' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('stock')">
Stock ${getSortIndicator('stock')}
</th>
<th class="sortable cost-col ${currentSort.column === 'cost' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('cost')">
Cost ${getSortIndicator('cost')}
</th>
<th class="sortable ${currentSort.column === 'attunement' ? 'sorted-' + currentSort.direction : ''}"
onclick="sortMarket('attunement')">
Attunement ${getSortIndicator('attunement')}
</th>
</tr>
`;
} }
// Sort items by column
function sortItems(items, column, direction) { function sortItems(items, column, direction) {
const multiplier = direction === 'asc' ? 1 : -1; const multiplier = direction === 'asc' ? 1 : -1;
@ -841,6 +875,9 @@ document.addEventListener('click', (event) => {
tooltip.classList.remove('show'); tooltip.classList.remove('show');
}); });
} }
if (!event.target.closest('.market-filter-dropdown')) {
closeMarketFilterDropdown();
}
}); });
function renderQuestBoard(quests, currentTime) { function renderQuestBoard(quests, currentTime) {

393
filtering.js Normal file
View File

@ -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 `
<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>
<th>
${renderFilterDropdown('attunement', 'Attunement', attunements, marketFilters.attunement)}
</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);
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 `
<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 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;
}

View File

@ -75,7 +75,11 @@
<!-- Magic Items Data --> <!-- Magic Items Data -->
<script src="magic-items-data.js"></script> <script src="magic-items-data.js"></script>
<!-- Market Filtering -->
<script src="filtering.js"></script>
<!-- App Logic --> <!-- App Logic -->
<script src="app.js"></script> <script src="app.js"></script>
</body> </body>
</html> </html>

View File

@ -979,6 +979,7 @@ button {
.no-items { .no-items {
text-align: center; text-align: center;
vertical-align: top;
padding: 2rem; padding: 2rem;
color: #b8a992; color: #b8a992;
font-style: italic; font-style: italic;
@ -1017,6 +1018,177 @@ button {
background: rgba(139, 115, 85, 0.5); 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 { .market-table tbody tr {
border-bottom: 1px solid rgba(139, 115, 85, 0.2); border-bottom: 1px solid rgba(139, 115, 85, 0.2);
transition: background 0.2s ease; transition: background 0.2s ease;
@ -1159,10 +1331,12 @@ button {
/* Market table wrapper for horizontal scroll */ /* Market table wrapper for horizontal scroll */
.market-table-wrapper { .market-table-wrapper {
overflow-x: auto; overflow-x: auto;
overflow-y: visible;
-webkit-overflow-scrolling: touch; -webkit-overflow-scrolling: touch;
margin: 0 -10px; margin: 0 -10px;
padding: 0 10px; padding: 0 10px;
position: relative; position: relative;
min-height: 320px;
} }
/* Scroll hint shadow on mobile */ /* Scroll hint shadow on mobile */