diff --git a/resources/logic_pipeline.js b/resources/logic_pipeline.js index 364ce2b..64f7dc5 100644 --- a/resources/logic_pipeline.js +++ b/resources/logic_pipeline.js @@ -79,6 +79,52 @@ function matchesLogicFilters(item) { return true; } +// ============================================================ +// QUICK FILTER HELPERS (faceted chip filtering) +// ============================================================ + +/** + * Test whether a single item matches a single quick-filter chip. + * - lora: matches if the chip value is one of the lora names in the item's stack. + * - model: exact equality on the full model string. + * - numeric types (cfg/steps/denoise/seed): float tolerance 1e-6. + * - everything else: String exact match. + */ +function _quickFilterMatches(item, f) { + if (f.type === 'lora') { + const lora = String(item.lora || 'None'); + if (lora === 'None') return f.value === 'None'; + const names = lora.split(' + ').map(p => p.split(':')[0].trim()); + return names.includes(f.value); + } + if (f.type === 'model') { + return String(item.model || '') === String(f.value); + } + if (['cfg', 'steps', 'denoise', 'seed'].includes(f.type)) { + const a = parseFloat(item[f.type]); + const b = parseFloat(f.value); + if (isNaN(a) || isNaN(b)) return String(item[f.type]) === String(f.value); + return Math.abs(a - b) < 1e-6; + } + if (f.type === 'size') { + const itemSize = `${item.width || '?'}x${item.height || '?'}`; + return itemSize === String(f.value); + } + return String(item[f.type]) === String(f.value); +} + +/** + * Check if an item passes ALL active quick-filter chips (AND logic). + * Returns true when no chips are active, or every chip matches. + */ +function matchesQuickFilters(item) { + if (typeof quickFilters === 'undefined' || quickFilters.length === 0) return true; + for (const f of quickFilters) { + if (!_quickFilterMatches(item, f)) return false; + } + return true; +} + // Generate cache key from current filters function getFilterKey() { const parts = [ @@ -101,7 +147,8 @@ function getFilterKey() { [...filters.upscaleMethod].sort().join(','), [...filters.mediaType].sort().join(','), searchFilters.map(f => `${f.type}:${f.term}`).join('|'), - (typeof logicFilters !== 'undefined' ? logicFilters.map(f => `${f.field}${f.op}${f.value}`).join('|') : '') + (typeof logicFilters !== 'undefined' ? logicFilters.map(f => `${f.field}${f.op}${f.value}`).join('|') : ''), + (typeof quickFilters !== 'undefined' ? quickFilters.map(f => `${f.type}:${f.value}`).join('|') : '') ]; return parts.join('|'); } @@ -347,6 +394,9 @@ function incrementalFilter(newItems) { // Apply logic filters (numeric comparisons) if (!matchesLogicFilters(d)) return false; + // Apply quick filters (faceted chip AND logic) + if (!matchesQuickFilters(d)) return false; + return true; }); } @@ -441,6 +491,9 @@ function executePipeline() { // Apply logic filters (numeric comparisons) if (!matchesLogicFilters(d)) return false; + // Apply quick filters (faceted chip AND logic) + if (!matchesQuickFilters(d)) return false; + return true; }); diff --git a/resources/logic_state.js b/resources/logic_state.js index e41e6f7..45ef690 100644 --- a/resources/logic_state.js +++ b/resources/logic_state.js @@ -65,6 +65,11 @@ const sortOptionsList = [ // Multiple filters are applied with AND logic. OR is a future extension. var logicFilters = logicFilters || []; +// --- QUICK FILTER STATE --- +// Array of faceted filter chips: { type: 'lora', value: 'DMD2.safetensors', _expanded: false } +// Multiple chips are ANDed together. LoRA chips match any lora in a stack. +var quickFilters = quickFilters || []; + // --- VIRTUALIZATION & PIPELINE --- let visibleSlice = { start: -1, end: -1 }; diff --git a/resources/logic_ui.js b/resources/logic_ui.js index 3db8587..dc0c438 100644 --- a/resources/logic_ui.js +++ b/resources/logic_ui.js @@ -745,6 +745,12 @@ function resetAllFilters() { if (typeof renderLogicFilters === 'function') renderLogicFilters(); } + // Clear quick filters + if (typeof quickFilters !== 'undefined') { + quickFilters.length = 0; + if (typeof renderQuickFilters === 'function') renderQuickFilters(); + } + // Clear the filter button cache so initFilters rebuilds on next call if (typeof filterButtonCache !== 'undefined') { for (const key in filterButtonCache) delete filterButtonCache[key]; @@ -1967,6 +1973,385 @@ function renderLogicFilters() { } } +// ============================================================ +// QUICK FILTER FUNCTIONS (faceted chip filtering) +// ============================================================ + +// Human-readable labels for each filter type +const _qfTypeLabels = { + sampler: 'Sampler', + scheduler: 'Scheduler', + lora: 'LoRA', + model: 'Model', + denoise: 'Denoise', + size: 'Size', + seed: 'Seed', + steps: 'Steps', + cfg: 'CFG', + upscaleMethod: 'Upscale', + mediaType: 'Media', + positive: 'Positive', + negative: 'Negative' +}; + +// All types that Quick Filter supports (type-menu order) +const _qfAllTypes = ['lora', 'cfg', 'steps', 'sampler', 'scheduler', 'denoise', 'size', 'model', 'seed', 'upscaleMethod', 'mediaType', 'positive', 'negative']; + +// Types shown in the expanded facet panel (omit prompt types for brevity) +const _qfFacetableTypes = ['sampler', 'scheduler', 'lora', 'model', 'denoise', 'cfg', 'steps', 'size', 'seed', 'upscaleMethod', 'mediaType']; + +/** + * Extract a facet value (or array of values) from an item for a given type. + * Returns null if the field is missing. + */ +function _qfExtractItemValue(item, type) { + if (type === 'lora') { + const lora = String(item.lora || 'None'); + if (lora === 'None') return ['None']; + return lora.split(' + ').map(p => p.split(':')[0].trim()); + } + if (type === 'size') { + return `${item.width || '?'}x${item.height || '?'}`; + } + if (type === 'model') { + return item.model || meta && meta.model || 'Default'; + } + if (type === 'steps') return String(item.steps); + if (type === 'cfg') return String(item.cfg); + if (type === 'positive') return item.positive || (meta && meta.positive) || ''; + if (type === 'negative') return item.negative || (meta && meta.negative) || ''; + if (type === 'upscaleMethod') { + if (!item.upscaled) return 'No Upscale'; + const mode = item.upscale_mode || ''; + const model = item.upscale_model; + const shortModel = model ? String(model).replace(/\\/g, '/').split('/').pop().replace(/\.[^.]+$/, '') : ''; + return shortModel ? `${mode} + ${shortModel}` : mode || 'Upscaled'; + } + if (type === 'mediaType') return item.media_type || 'image'; + const v = item[type]; + if (v === undefined || v === null) return null; + return String(v); +} + +/** + * Get all unique values in activeData for a given type. + * Returns a sorted array of strings. + */ +function _qfUniqueValuesForType(type) { + const seen = new Set(); + const data = (typeof activeData !== 'undefined' ? activeData : []); + for (const item of data) { + const val = _qfExtractItemValue(item, type); + if (val === null || val === undefined) continue; + if (Array.isArray(val)) { + val.forEach(v => seen.add(String(v))); + } else { + seen.add(String(val)); + } + } + return [...seen].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); +} + +/** + * Compute facets for an expanded chip: + * - subset: items matching this chip's primary (type, value) + * - for each other type, collect unique values in that subset + */ +function _computeChipFacets(chip) { + const data = (typeof activeData !== 'undefined' ? activeData : []); + const subset = data.filter(item => _quickFilterMatches(item, chip)); + const facets = {}; + for (const type of _qfFacetableTypes) { + if (type === chip.type) continue; + facets[type] = new Set(); + } + for (const item of subset) { + for (const type of _qfFacetableTypes) { + if (type === chip.type) continue; + const val = _qfExtractItemValue(item, type); + if (val === null || val === undefined) continue; + if (Array.isArray(val)) { + val.forEach(v => facets[type].add(String(v))); + } else { + facets[type].add(String(val)); + } + } + } + return { count: subset.length, facets }; +} + +/** + * Close all open quick-filter menus by clicking outside. + */ +function _closeQuickFilterMenus() { + const tm = document.getElementById('quick-filter-type-menu'); + const vm = document.getElementById('quick-filter-value-menu'); + if (tm) tm.style.display = 'none'; + if (vm) vm.style.display = 'none'; +} + +/** + * Open the type-picker dropdown from the "+ Add Quick Filter" button. + */ +function _openQuickFilterTypeMenu(btnEl) { + _closeQuickFilterMenus(); + const tm = document.getElementById('quick-filter-type-menu'); + if (!tm) return; + tm.innerHTML = ''; + + const data = (typeof activeData !== 'undefined' ? activeData : []); + if (data.length === 0) { + tm.innerHTML = '
No data loaded
'; + tm.style.display = 'block'; + return; + } + + for (const type of _qfAllTypes) { + const label = _qfTypeLabels[type] || type; + const div = document.createElement('div'); + div.style.cssText = 'padding:7px 14px; cursor:pointer; font-size:12px; color:#ccc; white-space:nowrap;'; + div.textContent = label; + div.onmouseover = () => div.style.background = '#2a2a2a'; + div.onmouseout = () => div.style.background = ''; + div.onclick = (e) => { + e.stopPropagation(); + tm.style.display = 'none'; + _openQuickFilterValueMenu(type, div); + }; + tm.appendChild(div); + } + + // Position below the button + const rect = btnEl.getBoundingClientRect(); + tm.style.position = 'fixed'; + tm.style.top = (rect.bottom + 4) + 'px'; + tm.style.left = rect.left + 'px'; + tm.style.display = 'block'; + + // Close on outside click + setTimeout(() => { + document.addEventListener('click', _closeQuickFilterMenus, { once: true }); + }, 0); +} + +/** + * Open the value-picker dropdown for a given type. + */ +function _openQuickFilterValueMenu(type, anchorEl) { + _closeQuickFilterMenus(); + const vm = document.getElementById('quick-filter-value-menu'); + if (!vm) return; + vm.innerHTML = ''; + + const values = _qfUniqueValuesForType(type); + const label = _qfTypeLabels[type] || type; + + if (values.length === 0) { + vm.innerHTML = `
No values for ${label}
`; + } else { + const header = document.createElement('div'); + header.style.cssText = 'padding:5px 12px; font-size:10px; color:#555; border-bottom:1px solid #2a2a2a; text-transform:uppercase; letter-spacing:0.05em;'; + header.textContent = label; + vm.appendChild(header); + + for (const val of values) { + const div = document.createElement('div'); + div.style.cssText = 'padding:6px 14px; cursor:pointer; font-size:12px; color:#ccc; white-space:nowrap; overflow:hidden; text-overflow:ellipsis; max-width:240px;'; + div.title = val; + div.textContent = val; + div.onmouseover = () => div.style.background = '#2a2a2a'; + div.onmouseout = () => div.style.background = ''; + div.onclick = (e) => { + e.stopPropagation(); + vm.style.display = 'none'; + addQuickFilter(type, val); + }; + vm.appendChild(div); + } + } + + // Position near the anchor + const rect = anchorEl.getBoundingClientRect(); + vm.style.position = 'fixed'; + vm.style.top = rect.top + 'px'; + vm.style.left = (rect.right + 6) + 'px'; + vm.style.display = 'block'; + + setTimeout(() => { + document.addEventListener('click', _closeQuickFilterMenus, { once: true }); + }, 0); +} + +/** + * Add a quick-filter chip. Prevents exact duplicates. + */ +function addQuickFilter(type, value) { + // Prevent duplicates + const exists = quickFilters.some(f => f.type === type && String(f.value) === String(value)); + if (exists) { + console.log(`[QuickFilter] Duplicate chip skipped: ${type}=${value}`); + return; + } + quickFilters.push({ type, value: String(value), _expanded: false }); + renderQuickFilters(); + updateDataPipeline(); + console.log(`[QuickFilter] Added chip: ${type}=${value} (total: ${quickFilters.length})`); +} + +/** + * Remove a quick-filter chip by index. + */ +function removeQuickFilter(idx) { + if (idx >= 0 && idx < quickFilters.length) { + const removed = quickFilters.splice(idx, 1)[0]; + console.log(`[QuickFilter] Removed chip: ${removed.type}=${removed.value}`); + renderQuickFilters(); + updateDataPipeline(); + } +} + +/** + * Toggle the expand/collapse state of a chip. + */ +function toggleQuickFilterChip(idx) { + if (idx >= 0 && idx < quickFilters.length) { + quickFilters[idx]._expanded = !quickFilters[idx]._expanded; + renderQuickFilters(); + } +} + +/** + * Clear all quick-filter chips. + */ +function clearAllQuickFilters() { + if (quickFilters.length === 0) return; + quickFilters.length = 0; + renderQuickFilters(); + updateDataPipeline(); + console.log('[QuickFilter] All chips cleared'); +} + +/** + * Render quick-filter chips into #quick-filter-chips. + */ +function renderQuickFilters() { + const container = document.getElementById('quick-filter-chips'); + if (!container) return; + container.innerHTML = ''; + + if (quickFilters.length === 0) { + container.innerHTML = '
No active quick filters
'; + return; + } + + quickFilters.forEach((chip, idx) => { + const label = _qfTypeLabels[chip.type] || chip.type; + + const chipEl = document.createElement('div'); + chipEl.style.cssText = 'border:1px solid #1e4a6e; border-radius:4px; margin-bottom:6px; overflow:hidden; background:#0d1f2d;'; + + // --- Header row --- + const header = document.createElement('div'); + header.style.cssText = 'display:flex; align-items:center; padding:4px 6px; gap:6px; cursor:pointer; user-select:none;'; + + const typeBadge = document.createElement('span'); + typeBadge.style.cssText = 'background:#1a3a5c; color:#5ab4f5; font-size:10px; padding:1px 6px; border-radius:3px; font-weight:600; letter-spacing:0.04em; flex-shrink:0;'; + typeBadge.textContent = label.toUpperCase(); + + const valueSpan = document.createElement('span'); + valueSpan.style.cssText = 'flex:1; font-size:11px; color:#d0d8e0; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;'; + valueSpan.title = chip.value; + valueSpan.textContent = chip.value; + + const toggleBtn = document.createElement('button'); + toggleBtn.style.cssText = 'background:none; border:none; color:#5ab4f5; cursor:pointer; font-size:11px; padding:0 4px; flex-shrink:0;'; + toggleBtn.title = chip._expanded ? 'Collapse' : 'Expand facets'; + toggleBtn.textContent = chip._expanded ? '▼' : '▶'; + toggleBtn.onclick = (e) => { e.stopPropagation(); toggleQuickFilterChip(idx); }; + + const removeBtn = document.createElement('button'); + removeBtn.style.cssText = 'background:none; border:none; color:#666; cursor:pointer; font-size:12px; padding:0 2px; flex-shrink:0; line-height:1;'; + removeBtn.title = 'Remove filter'; + removeBtn.textContent = '✕'; + removeBtn.onclick = (e) => { e.stopPropagation(); removeQuickFilter(idx); }; + + header.onclick = () => toggleQuickFilterChip(idx); + header.appendChild(typeBadge); + header.appendChild(valueSpan); + header.appendChild(toggleBtn); + header.appendChild(removeBtn); + chipEl.appendChild(header); + + // --- Expanded body --- + if (chip._expanded) { + const body = document.createElement('div'); + body.style.cssText = 'border-top:1px solid #1e4a6e; padding:8px; background:#0a151f;'; + + const { count, facets } = _computeChipFacets(chip); + + const countEl = document.createElement('div'); + countEl.style.cssText = 'font-size:10px; color:#5ab4f5; margin-bottom:6px; font-weight:600;'; + countEl.textContent = `Items matching: ${count}`; + body.appendChild(countEl); + + let anyFacets = false; + for (const type of _qfFacetableTypes) { + if (type === chip.type) continue; + const vals = facets[type]; + if (!vals || vals.size === 0) continue; + + const sortedVals = [...vals].sort((a, b) => a.localeCompare(b, undefined, { numeric: true, sensitivity: 'base' })); + + anyFacets = true; + const row = document.createElement('div'); + row.style.cssText = 'margin-bottom:5px;'; + + const rowLabel = document.createElement('span'); + rowLabel.style.cssText = 'font-size:9px; color:#555; text-transform:uppercase; letter-spacing:0.05em; margin-right:6px;'; + rowLabel.textContent = (_qfTypeLabels[type] || type) + ':'; + row.appendChild(rowLabel); + + for (const val of sortedVals) { + // Check if this value is already an active chip + const alreadyActive = quickFilters.some(f => f.type === type && f.value === val); + const btn = document.createElement('button'); + btn.style.cssText = `display:inline-block; margin:1px 2px; padding:2px 7px; font-size:10px; border-radius:3px; cursor:pointer; border:1px solid ${alreadyActive ? '#2a6e44' : '#1e4a6e'}; background:${alreadyActive ? '#1a4a2a' : 'transparent'}; color:${alreadyActive ? '#4ecf7a' : '#8ab4d4'}; transition:background 0.1s;`; + btn.title = alreadyActive ? `Already filtering ${type}=${val}` : `Add filter: ${type}=${val}`; + btn.textContent = val.length > 30 ? val.slice(0, 28) + '…' : val; + if (!alreadyActive) { + btn.onclick = (e) => { e.stopPropagation(); addQuickFilter(type, val); }; + btn.onmouseover = () => btn.style.background = '#1a3a5c'; + btn.onmouseout = () => btn.style.background = 'transparent'; + } + row.appendChild(btn); + } + body.appendChild(row); + } + + if (!anyFacets) { + const empty = document.createElement('div'); + empty.style.cssText = 'font-size:10px; color:#444; font-style:italic;'; + empty.textContent = 'No other facets available in this subset.'; + body.appendChild(empty); + } + + chipEl.appendChild(body); + } + + container.appendChild(chipEl); + }); + + // Clear-all button when multiple chips are active + if (quickFilters.length > 1) { + const clearBtn = document.createElement('button'); + clearBtn.className = 'search-filter-add-btn'; + clearBtn.style.cssText = 'background:#ff3860; padding:4px 10px; margin-top:4px;'; + clearBtn.textContent = 'CLEAR ALL'; + clearBtn.onclick = clearAllQuickFilters; + container.appendChild(clearBtn); + } +} + // ============================================================ // MANIFEST ANALYTICS // ============================================================ diff --git a/resources/template.html b/resources/template.html index 977876e..aaa936c 100644 --- a/resources/template.html +++ b/resources/template.html @@ -533,6 +533,22 @@ + + +