add filtering to magic item market
This commit is contained in:
parent
49754b1394
commit
4e37f2b0ee
95
app.js
95
app.js
@ -320,6 +320,8 @@ function getStockTransitions(rarity) {
|
|||||||
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) {
|
||||||
if (reputation <= -3) {
|
if (reputation <= -3) {
|
||||||
@ -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
393
filtering.js
Normal 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;
|
||||||
|
}
|
||||||
|
|
||||||
@ -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>
|
||||||
|
|
||||||
|
|||||||
174
styles.css
174
styles.css
@ -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 */
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user