// CARD VIEW RENDERING ENGINE (STAK-118)
// =============================================================================
/**
* Returns the active card style from localStorage.
* @returns {'A'|'B'|'C'|'D'}
*/
const getCardStyle = () => localStorage.getItem(CARD_STYLE_KEY) || 'A';
/**
* Returns true when the card view (A/B/C) should be rendered instead of the table.
* Style D = table view. When D is selected, returns false so inventory.js renders the table.
* @returns {boolean}
*/
function isCardViewActive() {
return getCardStyle() !== 'D';
}
/**
* Computes portfolio summary totals for currently filtered items.
* @returns {{ purchase: number, melt: number, retail: number, gainLoss: number, count: number }}
*/
function _computePortfolioSummary() {
const items = (typeof filterInventory === 'function') ? filterInventory() : (inventory || []);
let purchase = 0, melt = 0, retail = 0, count = 0;
items.forEach(item => {
const spot = (typeof spotPrices !== 'undefined' ? spotPrices[(item.metal || '').toLowerCase()] : 0) || 0;
const valuation = (typeof computeItemValuation === 'function')
? computeItemValuation(item, spot)
: {
meltValue: (typeof computeMeltValue === 'function') ? computeMeltValue(item, spot) : 0,
purchaseTotal: (typeof item.price === 'number' ? item.price : parseFloat(item.price) || 0) * (Number(item.qty) || 1),
retailTotal: (() => {
const qty = Number(item.qty) || 1;
const gbPrice = (typeof getGoldbackRetailPrice === 'function') ? getGoldbackRetailPrice(item) : null;
const mktVal = parseFloat(item.marketValue) || 0;
return gbPrice ? gbPrice * qty : (mktVal > 0 ? mktVal * qty : ((typeof computeMeltValue === 'function') ? computeMeltValue(item, spot) : 0));
})(),
};
purchase += valuation.purchaseTotal || 0;
melt += valuation.meltValue || 0;
retail += valuation.retailTotal || 0;
count++;
});
return { purchase, melt, retail, gainLoss: retail - purchase, count };
}
/**
* Ensures the sort bar is always visible and syncs controls to the active view style.
* Called after every renderTable() — works around inventory.js hiding the bar in table mode.
*/
function _syncSortBar() {
const style = getCardStyle();
const bar = document.getElementById('cardSortBar');
if (!bar) return;
// Always show the bar
bar.style.display = 'flex';
// Sort controls: invisible when in table view (D) — visibility:hidden preserves
// layout so the summary stays centered; display:none would shift it right.
const sortLeft = bar.querySelector('.card-sort-left');
if (sortLeft) sortLeft.style.visibility = style === 'D' ? 'hidden' : '';
// Sync style toggle active state
const styleToggle = document.getElementById('cardStyleToggle');
if (styleToggle) {
styleToggle.querySelectorAll('.chip-sort-btn[data-style]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.style === style);
});
}
// Update summary
_renderSortBarSummary();
// Body class for CSS hooks
document.body.classList.toggle('view-mode-d', style === 'D');
// Set table min-width directly (inline style beats any CSS specificity issue).
// 1200px forces the table wider than typical tablet/mobile viewports so .portal-scroll scrolls.
// At desktop (>1200px) width:100% naturally fills the container — no forced overflow there.
const table = document.getElementById('inventoryTable');
if (table) table.style.minWidth = style === 'D' ? '1200px' : '';
// Custom horizontal scrollbar (show in D mode, hide in card modes)
_syncHScrollBar(style === 'D');
}
// ---------------------------------------------------------------------------
// Custom horizontal scrollbar for D (table) mode
// ---------------------------------------------------------------------------
/** Whether the custom H-scroll bar event listeners have been wired */
let _hScrollWired = false;
/**
* Creates the scrollbar DOM node (once) and inserts it after .portal-scroll.
* @returns {{ track: HTMLElement, thumb: HTMLElement }|null}
*/
function _getOrCreateHScrollBar() {
let track = document.getElementById('tableHScrollTrack');
if (track) return { track, thumb: document.getElementById('tableHScrollThumb') };
const portal = document.querySelector('.portal-scroll');
if (!portal) return null;
track = document.createElement('div');
track.id = 'tableHScrollTrack';
track.className = 'h-scroll-track';
track.setAttribute('aria-hidden', 'true');
const thumb = document.createElement('div');
thumb.id = 'tableHScrollThumb';
thumb.className = 'h-scroll-thumb';
track.appendChild(thumb);
// Insert immediately after the portal-scroll container
portal.parentNode.insertBefore(track, portal.nextSibling);
return { track, thumb };
}
/**
* Recalculates thumb size and position from portal scroll state.
*/
function _updateHScrollThumb() {
const track = document.getElementById('tableHScrollTrack');
const thumb = document.getElementById('tableHScrollThumb');
const portal = document.querySelector('.portal-scroll');
const table = document.getElementById('inventoryTable');
if (!track || !thumb || !portal || !table) return;
const scrollWidth = table.scrollWidth;
const clientWidth = portal.clientWidth;
// Hide the bar if content fits (no scroll needed)
if (scrollWidth <= clientWidth + 2) {
track.hidden = true;
return;
}
track.hidden = false;
const trackWidth = track.clientWidth || track.offsetWidth;
const thumbWidth = Math.max(44, (clientWidth / scrollWidth) * trackWidth);
const maxLeft = trackWidth - thumbWidth;
const scrollPct = portal.scrollLeft / (scrollWidth - clientWidth);
thumb.style.width = thumbWidth + 'px';
thumb.style.transform = `translateX(${scrollPct * maxLeft}px)`;
}
/**
* Shows or hides the custom H-scroll bar and wires events (once).
* @param {boolean} show
*/
function _syncHScrollBar(show) {
const result = _getOrCreateHScrollBar();
if (!result) return;
const { track, thumb } = result;
// Constrain table-section so it can't inflate beyond its parent container.
// Without this, table-section expands to match the table's min-width (1200px),
// making portal.clientWidth === table.scrollWidth → no perceived overflow → scrollbar hidden.
// overflow-x:hidden creates a BFC that locks the element to its parent's width.
const tableSection = document.getElementById('tableSectionEl');
if (tableSection) {
tableSection.style.overflowX = show ? 'hidden' : '';
}
// Switch portal-scroll to always-show scroll mode so iOS creates a real scroll container
const portal = document.querySelector('.portal-scroll');
if (portal) {
portal.style.overflowX = show ? 'scroll' : '';
portal.style.maxWidth = show ? '100%' : '';
}
if (!show) {
track.hidden = true;
return;
}
_updateHScrollThumb();
// Wire all events only once
if (_hScrollWired) return;
_hScrollWired = true;
// Sync native scroll → thumb position
if (portal) {
portal.addEventListener('scroll', _updateHScrollThumb, { passive: true });
}
window.addEventListener('resize', _updateHScrollThumb, { passive: true });
// ── Drag logic ──────────────────────────────────────────────────────────
let dragging = false;
let dragStartX = 0;
let dragStartScrollLeft = 0;
function clientX(e) {
return (e.touches && e.touches.length) ? e.touches[0].clientX : e.clientX;
}
function onDragStart(e) {
if (!portal) return;
dragging = true;
dragStartX = clientX(e);
dragStartScrollLeft = portal.scrollLeft;
thumb.style.cursor = 'grabbing';
e.preventDefault();
}
function onDragMove(e) {
if (!dragging || !portal) return;
const table = document.getElementById('inventoryTable');
if (!table) return;
const dx = clientX(e) - dragStartX;
const trackWidth = track.clientWidth || track.offsetWidth;
const thumbWidth = thumb.offsetWidth;
const maxLeft = trackWidth - thumbWidth;
const maxScroll = table.scrollWidth - portal.clientWidth;
if (maxLeft <= 0) return;
portal.scrollLeft = dragStartScrollLeft + (dx / maxLeft) * maxScroll;
_updateHScrollThumb();
e.preventDefault();
}
function onDragEnd() {
dragging = false;
thumb.style.cursor = '';
}
thumb.addEventListener('mousedown', onDragStart);
thumb.addEventListener('touchstart', onDragStart, { passive: false });
document.addEventListener('mousemove', onDragMove);
document.addEventListener('touchmove', onDragMove, { passive: false });
document.addEventListener('mouseup', onDragEnd);
document.addEventListener('touchend', onDragEnd);
// Click-on-track to jump scroll position
track.addEventListener('click', (e) => {
if (!portal || e.target === thumb) return;
const table = document.getElementById('inventoryTable');
if (!table) return;
const rect = track.getBoundingClientRect();
const clickFrac = (clientX(e) - rect.left) / rect.width;
portal.scrollLeft = clickFrac * (table.scrollWidth - portal.clientWidth);
_updateHScrollThumb();
});
}
/**
* Renders the portfolio summary strip in the sort bar.
*/
function _renderSortBarSummary() {
const el = document.getElementById('cardSortSummary');
if (!el) return;
const fmt = (typeof formatCurrency === 'function') ? formatCurrency : v => '$' + v.toFixed(2);
const s = _computePortfolioSummary();
const gl = s.gainLoss;
const glClass = gl >= 0 ? 'summary-positive' : 'summary-negative';
const glSign = gl >= 0 ? '+' : '';
el.innerHTML =
`<span class="summary-item"><span class="summary-label">Buy</span><span class="summary-val">${fmt(s.purchase)}</span></span>` +
`<span class="summary-sep">·</span>` +
`<span class="summary-item"><span class="summary-label">Melt</span><span class="summary-val">${fmt(s.melt)}</span></span>` +
`<span class="summary-sep">·</span>` +
`<span class="summary-item"><span class="summary-label">Market</span><span class="summary-val">${fmt(s.retail)}</span></span>` +
`<span class="summary-sep">·</span>` +
`<span class="summary-item ${glClass}"><span class="summary-label">G/L</span><span class="summary-val">${glSign}${fmt(gl)}</span></span>`;
}
// ---------------------------------------------------------------------------
// SVG Sparkline helpers
// ---------------------------------------------------------------------------
/**
* Converts an array of numeric values to SVG polyline coordinate string.
* @param {number[]} data - Data points
* @param {number} w - SVG viewBox width
* @param {number} h - SVG viewBox height
* @param {number} [padY=4] - Vertical padding
* @returns {string} Space-separated "x,y" pairs
*/
const dataToPolyline = (data, w, h, padY = 4) => {
if (data.length === 0) return '';
if (data.length === 1) return `0,${(h / 2).toFixed(1)}`;
const min = Math.min(...data);
const max = Math.max(...data);
const range = max - min || 1;
return data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = padY + ((max - v) / range) * (h - padY * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
};
/**
* Generates a "since purchased" 3-line sparkline SVG matching the view modal pattern.
* Red dashed = purchase price (flat), Green = melt value over time, Blue = retail/market value.
* Uses real spot history with timestamps when available.
* @param {object} item - Inventory item
* @param {number} w - SVG viewBox width
* @param {number} h - SVG viewBox height
* @param {object} [opts] - Options
* @param {number} [opts.opacity=1] - SVG opacity
* @param {number} [opts.points=60] - Number of data points
* @returns {string} SVG markup string
*/
const generateSparklineSVG = (item, w, h, opts = {}) => {
const opacity = opts.opacity || 1;
const points = opts.points || 60;
const metal = (item.metal || '').toLowerCase();
const weightOz = parseFloat(item.weight) || 1;
const qty = Number(item.qty) || 1;
const purity = parseFloat(item.purity) || 1;
const meltFactor = weightOz * qty * purity;
const purchasePerUnit = parseFloat(item.price) || 0;
const purchaseTotal = purchasePerUnit * qty;
const currentRetail = (parseFloat(item.marketValue) || 0) * qty;
// Try real spot history with timestamps
let spotEntries;
if (typeof getSpotHistoryForMetal === 'function') {
spotEntries = getSpotHistoryForMetal(metal, points, true);
}
let meltData, purchaseData, retailData;
if (spotEntries && spotEntries.length >= 2) {
// Prepend synthetic entry at purchase date if before earliest spot
const purchaseDate = item.date ? new Date(item.date).getTime() : 0;
if (purchaseDate > 0 && purchaseDate < spotEntries[0].ts) {
spotEntries.unshift({ ts: purchaseDate, spot: spotEntries[0].spot });
}
// Melt line: spot * meltFactor over time (green)
meltData = spotEntries.map(e => e.spot * meltFactor);
// Purchase line: flat reference (red dashed)
purchaseData = spotEntries.map(() => purchaseTotal || 1);
// Retail line: anchored sparse line (blue) — purchase at start, current at end
// with itemPriceHistory midpoints if available
retailData = new Array(spotEntries.length).fill(null);
// Anchor start: purchase price
if (purchaseTotal > 0) retailData[0] = purchaseTotal;
// Middle: sparse retail snapshots from itemPriceHistory
if (typeof itemPriceHistory !== 'undefined' && item.uuid) {
const history = itemPriceHistory[item.uuid] || [];
for (const re of history) {
if (re.retail > 0) {
// Find nearest spot entry index
let best = 0, bestDist = Math.abs(spotEntries[0].ts - re.ts);
for (let i = 1; i < spotEntries.length; i++) {
const dist = Math.abs(spotEntries[i].ts - re.ts);
if (dist < bestDist) { best = i; bestDist = dist; }
}
retailData[best] = re.retail * qty;
}
}
}
// Anchor end: current retail/market value
if (currentRetail > 0) {
retailData[spotEntries.length - 1] = currentRetail;
}
// Interpolate nulls for SVG polyline (spanGaps equivalent)
retailData = _interpolateNulls(retailData);
} else {
// No history — flat lines as fallback
const currentSpot = (typeof spotPrices !== 'undefined' && spotPrices[metal]) || 0;
const meltVal = currentSpot * meltFactor;
meltData = Array(points).fill(meltVal || 1);
purchaseData = Array(points).fill(purchaseTotal || 1);
const retailVal = currentRetail > 0 ? currentRetail : meltVal;
retailData = Array(points).fill(retailVal || 1);
}
// Use shared scale across all 3 lines so they're comparable
const allVals = [...meltData, ...purchaseData, ...retailData];
const globalMin = Math.min(...allVals);
const globalMax = Math.max(...allVals);
const meltLine = _dataToPolylineScaled(meltData, w, h, globalMin, globalMax);
const purchaseLine = _dataToPolylineScaled(purchaseData, w, h, globalMin, globalMax);
const retailLine = _dataToPolylineScaled(retailData, w, h, globalMin, globalMax);
const uid = (item.uuid || item.name || '').replace(/[^a-z0-9]/gi, '').slice(0, 20);
const meltGradId = `meltFill-${uid}`;
const purchGradId = `purchFill-${uid}`;
const meltFillPoints = `${meltLine} ${w},${h} 0,${h}`;
const purchFillPoints = `${purchaseLine} ${w},${h} 0,${h}`;
// Theme-aware — bold strokes and fills on light/sepia backgrounds
const _svgTheme = document.documentElement.getAttribute('data-theme') || 'light';
const _lt = _svgTheme === 'light' || _svgTheme === 'sepia';
const _meltStroke = _lt ? '#047857' : '#10b981';
const _purchStroke = _lt ? '#b91c1c' : '#ef4444';
const _retailStroke = _lt ? '#1d4ed8' : '#3b82f6';
const _meltFillOp = _lt ? '0.70' : '0.18';
const _purchFillOp = _lt ? '0.50' : '0.08';
const _sw = _lt ? '3.5' : '1.5';
const _purchOp = _lt ? '1' : '0.85';
return `<svg viewBox="0 0 ${w} ${h}" preserveAspectRatio="none" xmlns="http://www.w3.org/2000/svg" style="opacity:${opacity}">` +
`<defs>` +
`<linearGradient id="${meltGradId}" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0%" stop-color="${_meltStroke}" stop-opacity="${_meltFillOp}"/>` +
`<stop offset="100%" stop-color="${_meltStroke}" stop-opacity="0"/>` +
`</linearGradient>` +
`<linearGradient id="${purchGradId}" x1="0" y1="0" x2="0" y2="1">` +
`<stop offset="0%" stop-color="${_purchStroke}" stop-opacity="${_purchFillOp}"/>` +
`<stop offset="100%" stop-color="${_purchStroke}" stop-opacity="0"/>` +
`</linearGradient>` +
`</defs>` +
`<polygon points="${purchFillPoints}" fill="url(#${purchGradId})"/>` +
`<polygon points="${meltFillPoints}" fill="url(#${meltGradId})"/>` +
`<polyline points="${meltLine}" fill="none" stroke="${_meltStroke}" stroke-width="${_sw}" stroke-linecap="round"/>` +
`<polyline points="${purchaseLine}" fill="none" stroke="${_purchStroke}" stroke-width="${_sw}" stroke-dasharray="4,3" opacity="${_purchOp}"/>` +
`<polyline points="${retailLine}" fill="none" stroke="${_retailStroke}" stroke-width="${_sw}" stroke-linecap="round"/>` +
`</svg>`;
};
/**
* Interpolate null gaps in an array for SVG rendering (equivalent to Chart.js spanGaps).
* @param {Array.<(number|null)>} arr
* @returns {Array.<number>}
*/
const _interpolateNulls = (arr) => {
const result = [...arr];
// Find first non-null
let firstIdx = result.findIndex(v => v !== null);
if (firstIdx < 0) return result.map(() => 0);
// Fill leading nulls
for (let i = 0; i < firstIdx; i++) result[i] = result[firstIdx];
// Find last non-null and fill trailing
let lastIdx = result.length - 1;
while (lastIdx >= 0 && result[lastIdx] === null) lastIdx--;
for (let i = lastIdx + 1; i < result.length; i++) result[i] = result[lastIdx];
// Interpolate interior nulls
for (let i = firstIdx + 1; i < lastIdx; i++) {
if (result[i] !== null) continue;
// Find next non-null
let nextIdx = i + 1;
while (nextIdx < result.length && result[nextIdx] === null) nextIdx++;
const prevVal = result[i - 1];
const nextVal = result[nextIdx];
const span = nextIdx - (i - 1);
for (let j = i; j < nextIdx; j++) {
result[j] = prevVal + (nextVal - prevVal) * ((j - (i - 1)) / span);
}
}
return result;
};
/**
* Converts data to SVG polyline using a shared min/max scale.
* @param {number[]} data
* @param {number} w - SVG width
* @param {number} h - SVG height
* @param {number} globalMin
* @param {number} globalMax
* @param {number} [padY=4]
* @returns {string}
*/
const _dataToPolylineScaled = (data, w, h, globalMin, globalMax, padY = 4) => {
if (data.length === 0) return '';
if (data.length === 1) {
const y = padY + ((globalMax - data[0]) / (globalMax - globalMin || 1)) * (h - padY * 2);
return `0,${y.toFixed(1)}`;
}
const range = globalMax - globalMin || 1;
return data
.map((v, i) => {
const x = (i / (data.length - 1)) * w;
const y = padY + ((globalMax - v) / range) * (h - padY * 2);
return `${x.toFixed(1)},${y.toFixed(1)}`;
})
.join(' ');
};
// ---------------------------------------------------------------------------
// Shared helpers
// ---------------------------------------------------------------------------
/**
* Returns metal CSS class for card border/accent.
* @param {string} metal
* @returns {string}
*/
const _cardMetalClass = (metal) => `metal-${(metal || 'silver').toLowerCase()}`;
/**
* Returns coin image HTML. Renders an <img> with data attributes for async
* ImageCache resolution (same pattern as table thumbnails in inventory.js).
* Falls back to a metal-initial placeholder via onerror.
* @param {object} item
* @param {string} [extraClass='']
* @param {string} [side='obverse'] - 'obverse' or 'reverse'
* @returns {string}
*/
const _cardImageHTML = (item, extraClass = '', side = 'obverse') => {
const itemType = (item.type || '').toLowerCase();
const isRect = itemType === 'bar' || itemType === 'note' || itemType === 'aurum'
|| itemType === 'set' || item.weightUnit === 'gb';
const shape = isRect ? ' bar-shape' : '';
const uuid = item.uuid || '';
const catalogId = item.numistaId || '';
const metalKey = (item.metal || '').toLowerCase();
// Initial src from item data (CDN URL) if available
const urlKey = side === 'reverse' ? 'reverseImageUrl' : 'obverseImageUrl';
const rawUrl = item[urlKey] || '';
const directUrl = (rawUrl && /^https?:\/\/.+\..+/i.test(rawUrl)) ? rawUrl : '';
const srcAttr = directUrl ? ` src="${_cvEscapeAttr(directUrl)}"` : '';
const fitStyle = isRect ? 'object-fit:contain;' : 'object-fit:cover;';
return `<div class="coin-img${shape}${extraClass}" data-metal="${_cvEscapeAttr(metalKey)}">` +
`<img class="cv-thumb" data-side="${side}"${srcAttr}` +
` data-catalog-id="${_cvEscapeAttr(catalogId)}"` +
` data-item-uuid="${_cvEscapeAttr(uuid)}"` +
` data-item-name="${_cvEscapeAttr(item.name || '')}"` +
` data-item-metal="${_cvEscapeAttr(metalKey)}"` +
` data-item-type="${_cvEscapeAttr(item.type || '')}"` +
` alt="" loading="lazy" style="${directUrl ? '' : 'display:none;'}width:100%;height:100%;${fitStyle}border-radius:inherit;"` +
` onerror="this.style.display='none';this.nextElementSibling.style.display='flex';" />` +
`<div class="cv-no-image" style="display:${directUrl ? 'none' : 'flex'}"></div>` +
`</div>`;
};
/**
* Escapes HTML attribute values — fallback for card-view context.
* The main escapeAttribute lives in inventory.js (loaded before this file).
* @param {string} s
* @returns {string}
*/
const _cvEscapeAttr = (s) => {
if (typeof escapeAttribute === 'function') return escapeAttribute(s);
return String(s || '').replace(/&/g, '&').replace(/"/g, '"').replace(/</g, '<').replace(/>/g, '>');
};
/**
* Builds chip HTML for card views.
* @param {object} item
* @param {boolean} [small=false]
* @returns {string}
*/
const _cardChipsHTML = (item, small = false) => {
const s = small ? ' style="font-size:0.58rem;padding:0.05rem 0.35rem"' : '';
const type = (item.type || 'coin').toLowerCase();
let h = `<span class="cv-chip cv-chip-type ${type}"${s}>${sanitizeHtml(type)}</span>`;
if (item.year) h += `<span class="cv-chip cv-chip-year"${s}>${sanitizeHtml(String(item.year))}</span>`;
if (item.grade) h += `<span class="cv-chip cv-chip-grade"${s}>${sanitizeHtml(item.grade)}</span>`;
const qty = Number(item.qty) || 1;
if (qty > 1) h += `<span class="cv-chip cv-chip-qty"${s}>x${qty}</span>`;
const _wUnit = (item.weightUnit || 'oz').toLowerCase() === 'gb' ? 'gb' : (item.weightUnit || 'oz');
h += `<span class="cv-chip cv-chip-weight"${s}>${sanitizeHtml(item.weight || '')} ${sanitizeHtml(_wUnit)}</span>`;
return h;
};
/**
* Format currency using app's formatCurrency or simple fallback.
*/
const _cardFmt = (n) => {
if (typeof formatCurrency === 'function') return formatCurrency(Math.abs(n));
return '$' + Math.abs(n).toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
};
const _gainClass = (gl) => (gl >= 0 ? 'cv-gain' : 'cv-loss');
const _gainSign = (gl) => (gl >= 0 ? '+' : '-');
const _gainArrow = (gl) => (gl >= 0 ? '▲' : '▼');
// ---------------------------------------------------------------------------
// Card renderers — A through D
// ---------------------------------------------------------------------------
/**
* Renders Card Style A: Sparkline Header with stacked images.
* @param {object} item - Inventory item
* @param {number} idx - Original inventory index
* @param {object} computed - Pre-computed financial values
* @returns {string}
*/
const renderCardA = (item, idx, computed) => {
const { purchaseTotal, retailTotal, gainLoss } = computed;
const gl = gainLoss ?? 0;
const pct = purchaseTotal > 0 ? ((gl / purchaseTotal) * 100) : 0;
return `<article class="card-a ${_cardMetalClass(item.metal)}" data-idx="${idx}">` +
`<div class="card-a-chart-wrap"><canvas class="card-a-canvas" data-metal="${_cvEscapeAttr((item.metal || '').toLowerCase())}" data-weight="${parseFloat(item.weight) || 1}" data-qty="${Number(item.qty) || 1}" data-purity="${parseFloat(item.purity) || 1}" data-price="${parseFloat(item.price) || 0}" data-market="${parseFloat(item.marketValue) || 0}" data-date="${_cvEscapeAttr(item.date || '')}"></canvas></div>` +
`<div class="card-body">` +
`<div class="cv-item-name">${sanitizeHtml(item.name || '')}</div>` +
`<div class="cv-chips-img-row"><div class="cv-chips-row">${_cardChipsHTML(item)}</div><div class="cv-images-row cv-images-sm">${_cardImageHTML(item, '', 'obverse')}${_cardImageHTML(item, '', 'reverse')}</div></div>` +
`<div class="cv-value-row">` +
`<span class="cv-value-journey">${_cardFmt(purchaseTotal)}<span class="cv-arrow">→</span>${_cardFmt(retailTotal)}</span>` +
`<span class="cv-value-gain ${_gainClass(gl)}">${_gainArrow(gl)} ${_gainSign(gl)}${_cardFmt(gl)} (${_gainSign(gl)}${Math.abs(pct).toFixed(1)}%)</span>` +
`</div>` +
`</div>` +
`</article>`;
};
/**
* Renders Card Style B: Full-Bleed Overlay with hero gain number.
*/
const renderCardB = (item, idx, computed) => {
const { purchaseTotal, retailTotal, gainLoss } = computed;
const gl = gainLoss ?? 0;
const pct = purchaseTotal > 0 ? ((gl / purchaseTotal) * 100) : 0;
return `<article class="card-b ${_cardMetalClass(item.metal)}" data-idx="${idx}">` +
`<div class="sparkline-bg">${generateSparklineSVG(item, 400, 180)}</div>` +
`<div class="card-content">` +
`<div class="cv-images-row cv-images-center">${_cardImageHTML(item, '', 'obverse')}${_cardImageHTML(item, '', 'reverse')}</div>` +
`<div class="cv-item-name">${sanitizeHtml(item.name || '')}</div>` +
`<div class="cv-chips-row cv-chips-center">${_cardChipsHTML(item)}</div>` +
`<div class="cv-value-hero ${_gainClass(gl)}">${_gainSign(gl)}${_cardFmt(gl)}</div>` +
`<div class="cv-value-detail">${_cardFmt(purchaseTotal)} cost → ${_cardFmt(retailTotal)} retail · ${_gainSign(gl)}${Math.abs(pct).toFixed(1)}%</div>` +
`</div>` +
`</article>`;
};
/**
* Renders Card Style C: Split Card (image left, data right).
*/
const renderCardC = (item, idx, computed) => {
const { purchaseTotal, retailTotal, gainLoss } = computed;
const gl = gainLoss ?? 0;
return `<article class="card-c ${_cardMetalClass(item.metal)}" data-idx="${idx}">` +
`<div class="cv-image-col">` +
`${_cardImageHTML(item, '', 'obverse')}` +
`${_cardImageHTML(item, '', 'reverse')}` +
`<div class="cv-sparkline-strip"><svg viewBox="0 0 4 120" preserveAspectRatio="none"><rect width="4" height="120" rx="2" fill="var(--metal-color)" opacity="0.3"/></svg></div>` +
`</div>` +
`<div class="cv-data-col">` +
`<div class="cv-item-name">${sanitizeHtml(item.name || '')}</div>` +
`<div class="cv-chips-row">${_cardChipsHTML(item, true)}</div>` +
`<div class="cv-value-row">` +
`<span class="cv-value-journey">${_cardFmt(purchaseTotal)}<span class="cv-arrow">→</span>${_cardFmt(retailTotal)}</span>` +
`<span class="cv-value-gain ${_gainClass(gl)}">${_gainArrow(gl)} ${_gainSign(gl)}${_cardFmt(gl)}</span>` +
`</div>` +
`</div>` +
`</article>`;
};
// ---------------------------------------------------------------------------
// Main card view renderer
// ---------------------------------------------------------------------------
const _cardRenderers = { A: renderCardA, B: renderCardB, C: renderCardC };
/**
* Renders all items as cards into the given container.
* @param {object[]} sortedItems - Sorted/filtered inventory items
* @param {HTMLElement} container - Target container element
*/
const renderCardView = (sortedItems, container) => {
const style = getCardStyle();
const renderer = _cardRenderers[style] || renderCardB;
const html = sortedItems.map(item => {
const originalIdx = inventory.indexOf(item);
const currentSpot = (typeof spotPrices !== 'undefined' ? spotPrices[(item.metal || '').toLowerCase()] : 0) || 0;
const valuation = (typeof computeItemValuation === 'function')
? computeItemValuation(item, currentSpot)
: null;
const purchasePrice = typeof item.price === 'number' ? item.price : parseFloat(item.price) || 0;
const qty = Number(item.qty) || 1;
const meltValue = valuation ? valuation.meltValue : (typeof computeMeltValue === 'function' ? computeMeltValue(item, currentSpot) : 0);
const purchaseTotal = valuation ? valuation.purchaseTotal : purchasePrice * qty;
const retailTotal = valuation ? valuation.retailTotal : meltValue;
const fallbackHasRetailSignal = currentSpot > 0
|| (parseFloat(item.marketValue) || 0) > 0
|| !!((typeof getGoldbackRetailPrice === 'function') ? getGoldbackRetailPrice(item) : null);
const gainLoss = valuation ? valuation.gainLoss : (fallbackHasRetailSignal ? (retailTotal - purchaseTotal) : null);
return renderer(item, originalIdx, { purchaseTotal, retailTotal, meltValue, gainLoss });
});
// Revoke previous card blob URLs to prevent memory leaks
for (const url of _cvBlobUrls) {
try { URL.revokeObjectURL(url); } catch { /* ignore */ }
}
_cvBlobUrls = [];
// nosemgrep: javascript.browser.security.insecure-innerhtml.insecure-innerhtml
container.innerHTML = html.join('');
// Async image enhancement from ImageCache (fire-and-forget)
_enhanceCardImages(container);
// Initialize Chart.js charts on Card A canvases
_initCardCharts(container);
};
// ---------------------------------------------------------------------------
// Chart.js card charts (Card A) — mirrors view modal pattern
// ---------------------------------------------------------------------------
/** @type {Chart[]} Active card chart instances to destroy on re-render */
let _cvChartInstances = [];
/**
* Initializes Chart.js mini-charts on all .card-a-canvas elements.
* Reads spot history data from the canvas data attributes and builds
* the same 3-line chart as the view modal (purchase/melt/retail).
* @param {HTMLElement} container
*/
function _initCardCharts(container) {
// Destroy previous instances
for (const chart of _cvChartInstances) {
try { chart.destroy(); } catch { /* ignore */ }
}
_cvChartInstances = [];
if (typeof Chart === 'undefined') return;
const canvases = container.querySelectorAll('.card-a-canvas');
for (const canvas of canvases) {
const metal = canvas.dataset.metal || '';
const weightOz = parseFloat(canvas.dataset.weight) || 1;
const qty = parseInt(canvas.dataset.qty, 10) || 1;
const purity = parseFloat(canvas.dataset.purity) || 1;
const meltFactor = weightOz * qty * purity;
const purchasePerUnit = parseFloat(canvas.dataset.price) || 0;
const purchaseTotal = purchasePerUnit * qty;
const marketValue = parseFloat(canvas.dataset.market) || 0;
const currentRetail = marketValue * qty;
const purchaseDate = canvas.dataset.date ? new Date(canvas.dataset.date).getTime() : 0;
// Get spot history with timestamps
let spotEntries = [];
if (typeof getSpotHistoryForMetal === 'function') {
spotEntries = getSpotHistoryForMetal(metal, 60, true) || [];
}
if (spotEntries.length < 2) continue; // No chart without data
// Prepend synthetic entry at purchase date
if (purchaseDate > 0 && spotEntries.length > 0 && purchaseDate < spotEntries[0].ts) {
spotEntries.unshift({ ts: purchaseDate, spot: spotEntries[0].spot });
}
// Build labels (compact dates)
const labels = spotEntries.map(e => {
const d = new Date(e.ts);
return d.toLocaleDateString(undefined, { month: 'short', day: 'numeric' });
});
// Melt line: spot * meltFactor
const meltData = spotEntries.map(e => parseFloat((e.spot * meltFactor).toFixed(2)));
// Purchase line: flat
const purchaseLine = spotEntries.map(() => purchaseTotal);
// Retail line: anchored sparse (purchase at start → current at end)
const retailData = new Array(spotEntries.length).fill(null);
if (purchaseTotal > 0) retailData[0] = purchaseTotal;
// Sparse midpoints from itemPriceHistory
if (typeof itemPriceHistory !== 'undefined') {
const itemUuid = canvas.closest('[data-idx]')?.dataset.idx;
const item = typeof inventory !== 'undefined' && itemUuid ? inventory[parseInt(itemUuid, 10)] : null;
if (item?.uuid) {
const history = itemPriceHistory[item.uuid] || [];
for (const re of history) {
if (re.retail > 0) {
let best = 0, bestDist = Math.abs(spotEntries[0].ts - re.ts);
for (let i = 1; i < spotEntries.length; i++) {
const dist = Math.abs(spotEntries[i].ts - re.ts);
if (dist < bestDist) { best = i; bestDist = dist; }
}
retailData[best] = re.retail * qty;
}
}
}
}
if (currentRetail > 0) retailData[spotEntries.length - 1] = currentRetail;
const hasRetail = retailData.some(v => v !== null);
// Theme-aware chart colors — bold lines/fills for light & sepia
const _theme = document.documentElement.getAttribute('data-theme') || 'light';
const _isLight = _theme === 'light' || _theme === 'sepia';
const datasets = [
{
label: 'Purchase',
data: purchaseLine,
borderColor: _isLight ? '#b91c1c' : '#ef4444',
backgroundColor: _isLight ? 'rgba(185, 28, 28, 0.15)' : 'rgba(239, 68, 68, 0.06)',
fill: 'origin',
borderDash: [6, 3],
tension: 0,
pointRadius: 0,
pointHoverRadius: 0,
borderWidth: _isLight ? 3 : 1.5,
order: 1,
},
{
label: 'Melt',
data: meltData,
borderColor: _isLight ? '#047857' : '#10b981',
backgroundColor: _isLight ? 'rgba(4, 120, 87, 0.25)' : 'rgba(16, 185, 129, 0.18)',
fill: 'origin',
tension: 0.3,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: _isLight ? 3 : 2,
order: 3,
},
{
label: 'Retail',
data: retailData,
borderColor: _isLight ? '#1d4ed8' : '#3b82f6',
backgroundColor: _isLight ? 'rgba(29, 78, 216, 0.15)' : 'rgba(59, 130, 246, 0.12)',
fill: 'origin',
tension: 0.3,
spanGaps: true,
pointRadius: 0,
pointHoverRadius: 3,
borderWidth: _isLight ? 3 : 1.5,
hidden: !hasRetail,
order: 2,
},
];
const chart = new Chart(canvas, {
type: 'line',
data: { labels, datasets },
options: {
responsive: true,
maintainAspectRatio: false,
animation: { duration: 0 },
interaction: { mode: 'index', intersect: false },
scales: {
x: {
ticks: {
color: typeof getChartTextColor === 'function' ? getChartTextColor() : '#8b949e',
maxTicksLimit: 4,
autoSkip: true,
font: { size: 8 },
maxRotation: 0,
},
grid: { display: false },
},
y: {
ticks: {
color: typeof getChartTextColor === 'function' ? getChartTextColor() : '#8b949e',
maxTicksLimit: 3,
font: { size: 8 },
callback: function(value) {
return typeof formatCurrency === 'function' ? formatCurrency(value) : '$' + value;
},
},
grid: { color: 'rgba(128,128,128,0.08)' },
},
},
plugins: {
legend: {
display: true,
position: 'bottom',
labels: {
color: typeof getChartTextColor === 'function' ? getChartTextColor() : '#8b949e',
usePointStyle: true,
pointStyle: 'line',
padding: 6,
boxWidth: 16,
font: { size: 8 },
},
},
tooltip: {
enabled: true,
backgroundColor: 'rgba(0,0,0,0.8)',
titleColor: '#fff',
bodyColor: '#fff',
padding: 6,
bodyFont: { size: 10 },
titleFont: { size: 10 },
callbacks: {
label: function(ctx) {
if (ctx.parsed.y === null) return null;
const val = typeof formatCurrency === 'function' ? formatCurrency(ctx.parsed.y) : '$' + ctx.parsed.y.toFixed(2);
return `${ctx.dataset.label}: ${val}`;
}
}
}
}
}
});
_cvChartInstances.push(chart);
}
}
// ---------------------------------------------------------------------------
// Image enhancement — async load from ImageCache
// ---------------------------------------------------------------------------
/** @type {string[]} Blob URLs to revoke on next render */
let _cvBlobUrls = [];
/** @type {IntersectionObserver|null} */
let _cvImageObserver = null;
/**
* Enhances card thumbnail images from IndexedDB ImageCache.
* Uses IntersectionObserver for lazy loading (same pattern as table thumbnails).
* @param {HTMLElement} container
*/
async function _enhanceCardImages(container) {
if (typeof featureFlags === 'undefined' || !featureFlags.isEnabled('COIN_IMAGES')) return;
if (!window.imageCache?.isAvailable()) return;
if (_cvImageObserver) _cvImageObserver.disconnect();
_cvImageObserver = new IntersectionObserver((entries) => {
for (const entry of entries) {
if (!entry.isIntersecting) continue;
_cvImageObserver.unobserve(entry.target);
// Observe the visible .coin-img wrapper, find the img inside
const img = entry.target.querySelector('.cv-thumb');
if (img) _loadCardImage(img);
}
}, { rootMargin: '200px 0px' });
container.querySelectorAll('.coin-img').forEach(wrapper => {
_cvImageObserver.observe(wrapper);
});
}
/**
* Resolves and sets blob URL for a single card thumbnail image.
* @param {HTMLImageElement} img
*/
async function _loadCardImage(img) {
try {
const item = {
uuid: img.dataset.itemUuid || '',
numistaId: img.dataset.catalogId || '',
name: img.dataset.itemName || '',
metal: img.dataset.itemMetal || '',
type: img.dataset.itemType || '',
};
const side = img.dataset.side || 'obverse';
// Resolve CDN URL from inventory item
const idx = img.closest('[data-idx]')?.dataset.idx;
let cdnUrl = '';
if (idx !== undefined && typeof inventory !== 'undefined') {
const invItem = inventory[parseInt(idx, 10)];
if (invItem) {
const urlKey = side === 'reverse' ? 'reverseImageUrl' : 'obverseImageUrl';
cdnUrl = (invItem[urlKey] && /^https?:\/\/.+\..+/i.test(invItem[urlKey])) ? invItem[urlKey] : '';
}
}
// Numista override: CDN URLs (Numista source) win over user/pattern blobs
const numistaOverride = localStorage.getItem('numistaOverridePersonal') === 'true';
if (numistaOverride && cdnUrl) {
_showCardImage(img, cdnUrl);
return;
}
// Try IDB resolution cascade (user → pattern → numista cache)
if (window.imageCache?.isAvailable()) {
const blobUrl = await imageCache.resolveImageUrlForItem(item, side);
if (blobUrl) {
_cvBlobUrls.push(blobUrl);
_showCardImage(img, blobUrl);
return;
}
}
// Fallback: CDN URL
if (cdnUrl) {
_showCardImage(img, cdnUrl);
return;
}
} catch { /* ignore — IDB unavailable or entry missing */ }
}
/**
* Show a card image and hide its placeholder.
* @param {HTMLImageElement} img
* @param {string} url
*/
function _showCardImage(img, url) {
img.src = url;
img.style.display = '';
const placeholder = img.nextElementSibling;
if (placeholder) placeholder.style.display = 'none';
}
// ---------------------------------------------------------------------------
// Delegated click handler for card grid
// ---------------------------------------------------------------------------
/** @type {boolean} */
let _cardClickBound = false;
/**
* Binds a delegated click handler on the card grid container.
* @param {HTMLElement} container
*/
const bindCardClickHandler = (container) => {
if (_cardClickBound) return;
_cardClickBound = true;
container.addEventListener('click', (e) => {
const card = e.target.closest('[data-idx]');
if (!card) return;
const idx = Number(card.dataset.idx);
if (typeof showViewModal === 'function') {
showViewModal(idx);
} else if (typeof editItem === 'function') {
editItem(idx);
}
});
};
// ---------------------------------------------------------------------------
// Card Sort Bar — sort controls + style toggle (STAK-131)
// ---------------------------------------------------------------------------
/** @type {boolean} Whether card sort bar event listeners have been bound */
let _cardSortBarBound = false;
/**
* Updates the card sort bar UI to reflect current sort state and card style.
* Called from renderTable() whenever card view (A/B/C) is active.
* For D mode, _syncSortBar handles everything via MutationObserver.
*/
const updateCardSortBar = () => {
// Sync sort dropdown with current sortColumn
const colSelect = document.getElementById('cardSortColumn');
if (colSelect && colSelect.value !== String(sortColumn)) {
colSelect.value = String(sortColumn);
}
// Sync direction button
const dirBtn = document.getElementById('cardSortDirBtn');
if (dirBtn) {
dirBtn.setAttribute('data-dir', sortDirection);
dirBtn.title = sortDirection === 'asc' ? 'Ascending — click to reverse' : 'Descending — click to reverse';
}
_syncSortBar();
};
/**
* Binds event listeners on the card sort bar (once).
* Also sets up the MutationObserver that keeps the bar visible in D (table) mode.
*/
const initCardSortBar = () => {
if (_cardSortBarBound) return;
_cardSortBarBound = true;
// Sort column dropdown
const colSelect = document.getElementById('cardSortColumn');
if (colSelect) {
colSelect.addEventListener('change', () => {
sortColumn = parseInt(colSelect.value, 10);
if (typeof renderTable === 'function') renderTable();
});
}
// Sort direction toggle
const dirBtn = document.getElementById('cardSortDirBtn');
if (dirBtn) {
dirBtn.addEventListener('click', () => {
sortDirection = sortDirection === 'asc' ? 'desc' : 'asc';
if (typeof renderTable === 'function') renderTable();
});
}
// Style A/B/C/D toggle
const styleToggle = document.getElementById('cardStyleToggle');
if (styleToggle) {
styleToggle.addEventListener('click', (e) => {
const btn = e.target.closest('.chip-sort-btn');
if (!btn || !btn.dataset.style) return;
const newStyle = btn.dataset.style;
localStorage.setItem(CARD_STYLE_KEY, newStyle);
// Sync settings dropdown (D is not in the settings list, skip it)
const styleSelect = document.getElementById('settingsCardStyle');
if (styleSelect && newStyle !== 'D') styleSelect.value = newStyle;
if (typeof renderTable === 'function') renderTable();
// After renderTable(), inventory.js may have hidden bar (D mode) — restore it
_syncSortBar();
});
}
// MutationObserver: when inventory.js hides the bar in table mode (D), immediately restore it.
// inventory.js sets cardSortBar.style.display='none' when isCardViewActive()===false.
const bar = document.getElementById('cardSortBar');
if (bar) {
new MutationObserver(() => {
if (getCardStyle() === 'D' && bar.style.display === 'none') {
_syncSortBar();
}
}).observe(bar, { attributes: true, attributeFilter: ['style'] });
}
};
// =============================================================================
// GLOBAL SPOT CONTROLS
// =============================================================================
/**
* Trend period presets and labels for the header trend cycle button.
*/
const TREND_PRESETS = ['1', '7', '30', '90', '365', '1095'];
const TREND_LABELS = { '1': '1d', '7': '7d', '30': '30d', '90': '90d', '365': '1Y', '1095': '3Y' };
/**
* Applies a trend period value to all four metal sparkline selects and updates
* the header trend label.
* @param {string} val - The trend period value to apply.
*/
const _applyTrend = (val) => {
['Silver', 'Gold', 'Platinum', 'Palladium'].forEach(m => {
const sel = document.getElementById(`spotRange${m}`);
if (sel && sel.value !== val) { sel.value = val; sel.dispatchEvent(new Event('change')); }
});
const label = document.getElementById('headerTrendLabel');
if (label) label.textContent = TREND_LABELS[val] || val + 'd';
};
/**
* Cycles through trend period presets, persists the selection, and applies it.
*/
const cycleSpotTrend = () => {
const current = localStorage.getItem('spotTrendPeriod') || '90';
const next = TREND_PRESETS[(TREND_PRESETS.indexOf(current) + 1) % TREND_PRESETS.length];
localStorage.setItem('spotTrendPeriod', next);
_applyTrend(next);
};
window.cycleSpotTrend = cycleSpotTrend;
/**
* Initialises spot controls: applies the persisted trend period on load.
*
* The per-card <select> elements remain in the DOM (hidden via CSS) so that
* the existing spot.js sparkline listeners continue to work.
*/
const _initSpotControls = () => {
_applyTrend(localStorage.getItem('spotTrendPeriod') || '90');
};
// =============================================================================
// TOTALS CAROUSEL
// =============================================================================
/**
* Initialises the totals cards carousel:
* - Generates one dot per card inside #totalsDots
* - Wires prev/next buttons to scroll by one card width
* - Wires the scroll event to update the active dot and button states
*
* At ≥1350px the nav buttons and dots are hidden via CSS; the carousel
* degenerates to a plain flex row with no overflow.
*/
/**
* Rebuilds totals carousel dots after metal order/visibility changes.
* Safe to call multiple times — does not re-wire scroll or nav listeners.
*/
const refreshTotalsDots = () => {
const carousel = document.getElementById('totalsCarousel');
const dotsEl = document.getElementById('totalsDots');
if (!carousel || !dotsEl) return;
const cards = [...carousel.querySelectorAll('.total-card')].filter(c => c.style.display !== 'none');
dotsEl.innerHTML = '';
cards.forEach((card, i) => {
const dot = document.createElement('button');
dot.className = 'totals-dot' + (i === 0 ? ' active' : '');
dot.setAttribute('aria-label', `Go to card ${i + 1}`);
dot.addEventListener('click', () => carousel.scrollTo({ left: card.offsetLeft, behavior: 'smooth' }));
dotsEl.appendChild(dot);
});
};
window.refreshTotalsDots = refreshTotalsDots;
const _initTotalsCarousel = () => {
const carousel = document.getElementById('totalsCarousel');
const prevBtn = document.getElementById('totalsPrev');
const nextBtn = document.getElementById('totalsNext');
const dotsEl = document.getElementById('totalsDots');
if (!carousel || !prevBtn || !nextBtn || !dotsEl) return;
const cards = Array.from(carousel.querySelectorAll('.total-card'));
if (!cards.length) return;
// Sort by visual (rendered) left offset so CSS `order` is respected.
// All Metals has order:-1 so it renders first but is last in DOM order.
const sortedCards = [...cards].sort((a, b) => a.offsetLeft - b.offsetLeft);
// --- Build dots ---
dotsEl.innerHTML = '';
const dots = sortedCards.map((card, i) => {
const dot = document.createElement('button');
dot.className = 'totals-dot' + (i === 0 ? ' active' : '');
dot.setAttribute('aria-label', `Go to card ${i + 1}`);
dot.addEventListener('click', () => {
carousel.scrollTo({ left: card.offsetLeft, behavior: 'smooth' });
});
dotsEl.appendChild(dot);
return dot;
});
// --- Helpers ---
const getCardWidth = () => {
const card = sortedCards[0];
const gap = parseFloat(getComputedStyle(carousel).gap) || 0;
return card.getBoundingClientRect().width + gap;
};
const updateState = () => {
const scrollLeft = carousel.scrollLeft;
const maxScroll = carousel.scrollWidth - carousel.clientWidth;
prevBtn.disabled = scrollLeft < 4;
nextBtn.disabled = scrollLeft >= maxScroll - 1;
// Highlight the dot whose card is most in view
const activeIdx = Math.round(scrollLeft / getCardWidth());
dots.forEach((d, i) => d.classList.toggle('active', i === activeIdx));
};
// --- Button clicks ---
prevBtn.addEventListener('click', () => {
carousel.scrollBy({ left: -getCardWidth(), behavior: 'smooth' });
});
nextBtn.addEventListener('click', () => {
carousel.scrollBy({ left: getCardWidth(), behavior: 'smooth' });
});
// --- Scroll tracking ---
let scrollTimer;
carousel.addEventListener('scroll', () => {
clearTimeout(scrollTimer);
scrollTimer = setTimeout(updateState, 50);
}, { passive: true });
// --- Initial state ---
updateState();
};
// On page load: initialise sort bar event handlers (needed even in D mode where
// inventory.js never calls initCardSortBar) and fix initial bar visibility.
document.addEventListener('DOMContentLoaded', () => {
initCardSortBar();
_initSpotControls();
_initTotalsCarousel();
// requestAnimationFrame runs after all DOMContentLoaded handlers (including init.js)
// so the sort bar has already been shown/hidden by inventory.js by this point.
requestAnimationFrame(_syncSortBar);
});