diff --git a/static/suitbuilder.css b/static/suitbuilder.css index 3fe8a8d0..ef6bb4d2 100644 --- a/static/suitbuilder.css +++ b/static/suitbuilder.css @@ -520,20 +520,7 @@ body { min-width: 100px; } -/* Loading States */ -.loading { - display: flex; - align-items: center; - justify-content: center; - padding: 2rem; - font-style: italic; - color: #6b7280; -} - -.loading::before { - content: "🔍 "; - margin-right: 0.5rem; -} +/* Loading States - See Progressive Search Styles section below */ /* Error States */ .error { @@ -828,52 +815,718 @@ body { } /* Progressive Search Styles */ +.loading { + padding: 20px; + text-align: center; + background: linear-gradient(135deg, #e3f2fd 0%, #bbdefb 100%); + border: 1px solid #90caf9; + border-radius: 8px; + color: #1565c8; + font-weight: 500; + display: flex; + flex-direction: column; + align-items: center; + gap: 12px; +} + +.loading::before { + content: none; +} + +/* Spinning search indicator */ +.search-spinner { + width: 40px; + height: 40px; + border: 4px solid #bbdefb; + border-top: 4px solid #1976d2; + border-radius: 50%; + animation: spin 1s linear infinite; +} + +@keyframes spin { + 0% { transform: rotate(0deg); } + 100% { transform: rotate(360deg); } +} + +.search-status { + font-size: 16px; + font-weight: 600; + color: #1565c8; +} + .search-progress { - margin-top: 15px; - padding: 10px; - background: #f8f9fa; + margin-top: 8px; + padding: 12px 16px; + background: rgba(255, 255, 255, 0.8); border-radius: 6px; - border: 1px solid #e9ecef; + border: 1px solid #90caf9; + width: 100%; + max-width: 400px; } .progress-stats { - font-family: 'Courier New', monospace; + display: flex; + justify-content: center; + gap: 16px; + flex-wrap: wrap; + margin-bottom: 12px; +} + +.stat-item { font-size: 13px; - color: #495057; - margin-bottom: 10px; + color: #424242; +} + +.stat-value { + font-family: 'Courier New', monospace; + font-weight: bold; + color: #1565c8; } .stop-search-btn { - background: #dc3545; + background: linear-gradient(135deg, #ef5350 0%, #d32f2f 100%); color: white; border: none; - padding: 6px 12px; + padding: 8px 16px; border-radius: 4px; cursor: pointer; - font-size: 12px; + font-size: 13px; font-weight: bold; - transition: background-color 0.2s; + transition: all 0.2s; + box-shadow: 0 2px 4px rgba(211, 47, 47, 0.3); } .stop-search-btn:hover { - background: #c82333; + background: linear-gradient(135deg, #d32f2f 0%, #c62828 100%); + transform: translateY(-1px); + box-shadow: 0 3px 6px rgba(211, 47, 47, 0.4); } .stop-search-btn:active { - background: #bd2130; + transform: translateY(0); + box-shadow: 0 1px 2px rgba(211, 47, 47, 0.3); +} + +.search-complete { + font-size: 15px; + color: #2e7d32; + font-weight: 600; + padding: 8px; } #streamingResults { margin-top: 15px; } -/* Enhance loading messages */ -.loading { - padding: 20px; +/* ======================================== + ENHANCED SEARCH STATUS UI + ======================================== */ + +/* Search Status Container - Modern Dark Design */ +.search-status-container { + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 12px; + padding: 24px; + color: #eee; + box-shadow: 0 8px 32px rgba(0, 0, 0, 0.3); + margin-bottom: 20px; +} + +/* Phase Indicator */ +.phase-indicator { + display: flex; + align-items: center; + gap: 16px; + margin-bottom: 20px; +} + +.phase-icon { + font-size: 32px; + animation: pulse 1.5s infinite; + min-width: 40px; text-align: center; - background: #e3f2fd; - border: 1px solid #bbdefb; +} + +.phase-icon.complete { + animation: none; +} + +.phase-icon.searching { + animation: searchPulse 0.8s infinite; +} + +@keyframes pulse { + 0%, 100% { transform: scale(1); opacity: 1; } + 50% { transform: scale(1.1); opacity: 0.8; } +} + +@keyframes searchPulse { + 0%, 100% { transform: scale(1) rotate(0deg); } + 25% { transform: scale(1.1) rotate(-5deg); } + 75% { transform: scale(1.1) rotate(5deg); } +} + +.phase-text { + font-size: 18px; + font-weight: 600; + color: #4fc3f7; +} + +.phase-text.complete { + color: #66bb6a; +} + +/* Progress Bars */ +.progress-bars { + display: flex; + flex-direction: column; + gap: 12px; + margin-bottom: 20px; +} + +.progress-bar-container { + display: flex; + flex-direction: column; + gap: 4px; +} + +.progress-bar-container label { + font-size: 11px; + color: #888; + text-transform: uppercase; + letter-spacing: 1px; +} + +.progress-bar { + height: 8px; + background: rgba(255, 255, 255, 0.1); + border-radius: 4px; + overflow: hidden; +} + +.progress-fill { + height: 100%; + border-radius: 4px; + transition: width 0.3s ease; +} + +.phase-progress { + background: linear-gradient(90deg, #4fc3f7, #29b6f6); +} + +.bucket-progress { + background: linear-gradient(90deg, #66bb6a, #43a047); +} + +.progress-fill.indeterminate { + width: 30% !important; + animation: indeterminate 1.5s infinite linear; +} + +@keyframes indeterminate { + 0% { transform: translateX(-100%); } + 100% { transform: translateX(400%); } +} + +.progress-label { + font-size: 11px; + color: #aaa; + margin-top: 2px; +} + +/* Stats Grid */ +.stats-grid { + display: grid; + grid-template-columns: repeat(4, 1fr); + gap: 16px; + margin-bottom: 20px; +} + +.stat-card { + background: rgba(255, 255, 255, 0.05); + border-radius: 8px; + padding: 16px; + text-align: center; + border: 1px solid rgba(255, 255, 255, 0.1); + transition: transform 0.2s, border-color 0.2s; +} + +.stat-card:hover { + transform: translateY(-2px); + border-color: rgba(79, 195, 247, 0.3); +} + +.stat-card.highlight { + background: rgba(79, 195, 247, 0.15); + border-color: rgba(79, 195, 247, 0.3); +} + +.stat-label { + font-size: 11px; + color: #888; + text-transform: uppercase; + letter-spacing: 1px; + margin-bottom: 4px; +} + +.search-status-container .stat-value { + font-size: 28px; + font-weight: 700; + color: #fff; + font-family: 'Courier New', monospace; +} + +.stat-card.highlight .stat-value { + color: #4fc3f7; +} + +.stat-unit { + font-size: 10px; + color: #666; +} + +/* Verbose Log */ +.verbose-log-section { + margin-bottom: 16px; +} + +.verbose-log-section summary { + cursor: pointer; + padding: 8px 12px; + background: rgba(255, 255, 255, 0.05); border-radius: 6px; - color: #1976d2; + font-size: 13px; + color: #aaa; + display: flex; + align-items: center; + gap: 8px; + user-select: none; +} + +.verbose-log-section summary:hover { + background: rgba(255, 255, 255, 0.08); +} + +.verbose-log-section summary::marker { + color: #4fc3f7; +} + +.log-count { + background: rgba(79, 195, 247, 0.2); + color: #4fc3f7; + padding: 2px 8px; + border-radius: 10px; + font-size: 11px; + margin-left: auto; +} + +.verbose-log { + max-height: 200px; + overflow-y: auto; + background: rgba(0, 0, 0, 0.3); + border-radius: 6px; + padding: 8px; + margin-top: 8px; + font-family: 'Courier New', monospace; + font-size: 11px; +} + +.log-entry { + padding: 2px 4px; + border-radius: 2px; + margin-bottom: 2px; +} + +.log-entry .log-time { + color: #666; + margin-right: 8px; +} + +.log-info .log-message { color: #aaa; } +.log-debug .log-message { color: #888; } +.log-success .log-message { color: #66bb6a; } +.log-warning .log-message { color: #ffa726; } +.log-phase { background: rgba(79, 195, 247, 0.1); } +.log-phase .log-message { color: #4fc3f7; } + +/* Enhanced Stop Button for new container */ +.search-status-container .stop-search-btn { + width: 100%; + padding: 12px 24px; + background: linear-gradient(135deg, #e53935, #c62828); + border: none; + border-radius: 8px; + color: white; + font-size: 14px; + font-weight: 600; + cursor: pointer; + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + transition: all 0.2s; + box-shadow: 0 4px 12px rgba(198, 40, 40, 0.3); +} + +.search-status-container .stop-search-btn:hover { + background: linear-gradient(135deg, #c62828, #b71c1c); + transform: translateY(-1px); + box-shadow: 0 6px 16px rgba(198, 40, 40, 0.4); +} + +.search-status-container .stop-search-btn:active { + transform: translateY(0); +} + +.stop-icon { + font-size: 16px; +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .stats-grid { + grid-template-columns: repeat(2, 1fr); + } + + .search-status-container { + padding: 16px; + } + + .phase-icon { + font-size: 24px; + } + + .phase-text { + font-size: 14px; + } +} + +/* ======================================== + SLOT LOCK FORM & LOCKED DISPLAY + ======================================== */ + +/* Slot configuring state */ +.slot-item.configuring { + border-color: #4fc3f7 !important; + background: rgba(79, 195, 247, 0.1) !important; +} + +/* Lock Form */ +.lock-form { + padding: 8px; + font-size: 12px; +} + +.lock-form-header { + font-weight: 600; + color: #333; + margin-bottom: 10px; + padding-bottom: 6px; + border-bottom: 1px solid #ddd; +} + +.lock-form-set { + margin-bottom: 10px; +} + +.lock-form-set label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 4px; +} + +.lock-set-select { + width: 100%; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 4px; + font-size: 12px; + background: white; +} + +.lock-form-spells { + margin-bottom: 10px; +} + +.lock-form-spells > label { + display: block; + font-size: 11px; + color: #666; + margin-bottom: 6px; +} + +.lock-spell-checkboxes { + display: grid; + grid-template-columns: repeat(2, 1fr); + gap: 4px; + max-height: 150px; + overflow-y: auto; + padding: 4px; + background: #f8f8f8; + border-radius: 4px; +} + +.lock-spell-checkbox { + display: flex; + align-items: center; + gap: 4px; + font-size: 10px; + padding: 2px 4px; + border-radius: 3px; + cursor: pointer; +} + +.lock-spell-checkbox:hover { + background: rgba(0, 0, 0, 0.05); +} + +.lock-spell-checkbox input { + margin: 0; + width: 12px; + height: 12px; +} + +.lock-spell-checkbox span { + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.lock-form-actions { + display: flex; + gap: 8px; + margin-top: 10px; +} + +.lock-save-btn, +.lock-cancel-btn { + flex: 1; + padding: 6px 12px; + border: none; + border-radius: 4px; + font-size: 12px; font-weight: 500; + cursor: pointer; + transition: all 0.2s; +} + +.lock-save-btn { + background: linear-gradient(135deg, #e53935, #c62828); + color: white; +} + +.lock-save-btn:hover { + background: linear-gradient(135deg, #c62828, #b71c1c); +} + +.lock-cancel-btn { + background: #e0e0e0; + color: #333; +} + +.lock-cancel-btn:hover { + background: #d0d0d0; +} + +/* Locked Slot Display */ +.locked-slot-info { + padding: 8px; + text-align: center; +} + +.locked-header { + font-weight: 600; + color: #c62828; + margin-bottom: 8px; + font-size: 13px; +} + +.locked-set-badge { + display: inline-block; + background: linear-gradient(135deg, #7b1fa2, #512da8); + color: white; + padding: 4px 12px; + border-radius: 12px; + font-size: 11px; + font-weight: 600; + margin-bottom: 8px; +} + +.locked-spells { + display: flex; + flex-wrap: wrap; + gap: 4px; + justify-content: center; + margin-bottom: 8px; +} + +.locked-spell { + display: inline-block; + background: rgba(79, 195, 247, 0.2); + color: #0277bd; + padding: 2px 8px; + border-radius: 10px; + font-size: 9px; + font-weight: 500; +} + +.locked-no-spells { + font-size: 10px; + color: #999; + font-style: italic; + margin-bottom: 8px; +} + +.unlock-btn { + padding: 4px 12px; + background: #f5f5f5; + border: 1px solid #ddd; + border-radius: 4px; + font-size: 11px; + color: #666; + cursor: pointer; + transition: all 0.2s; +} + +.unlock-btn:hover { + background: #e8e8e8; + border-color: #ccc; + color: #333; +} + +/* Override locked slot styling for better visibility */ +.slot-item.locked { + border-color: #c62828 !important; + background: linear-gradient(135deg, rgba(198, 40, 40, 0.1), rgba(198, 40, 40, 0.05)) !important; +} + +.slot-item.locked .slot-header { + color: #c62828; +} + +.slot-item.locked .lock-btn { + color: #c62828 !important; + opacity: 1 !important; +} + +/* ======================================== + CLEAR SLOT BUTTON + ======================================== */ + +.slot-content { + position: relative; +} + +.clear-slot-btn { + position: absolute; + top: 2px; + right: 2px; + background: rgba(220, 38, 38, 0.1); + border: 1px solid rgba(220, 38, 38, 0.3); + border-radius: 3px; + cursor: pointer; + font-size: 10px; + color: #dc2626; + opacity: 0.4; + padding: 1px 4px; + transition: all 0.2s; +} + +.clear-slot-btn:hover { + opacity: 1; + background: rgba(220, 38, 38, 0.2); +} + +/* ======================================== + SUIT SUMMARY SECTION + ======================================== */ + +.suit-summary-section { + margin-top: 15px; + padding: 12px; + background: linear-gradient(135deg, #1a1a2e 0%, #16213e 100%); + border-radius: 8px; + border: 1px solid #2d3748; +} + +.summary-header { + display: flex; + justify-content: space-between; + align-items: center; + margin-bottom: 10px; + padding-bottom: 8px; + border-bottom: 1px solid #2d3748; +} + +.summary-header h4 { + color: #4fc3f7; + font-size: 14px; + margin: 0; +} + +.copy-summary-btn { + background: rgba(79, 195, 247, 0.2); + border: 1px solid rgba(79, 195, 247, 0.3); + color: #4fc3f7; + padding: 4px 10px; + border-radius: 4px; + cursor: pointer; + font-size: 11px; + transition: all 0.2s; +} + +.copy-summary-btn:hover { + background: rgba(79, 195, 247, 0.3); +} + +.suit-summary-content { + font-family: 'Courier New', monospace; + font-size: 11px; + max-height: 300px; + overflow-y: auto; +} + +.no-summary { + color: #666; + font-style: italic; + text-align: center; + padding: 10px; +} + +.summary-line { + padding: 4px 0; + border-bottom: 1px solid #2d3748; + color: #ccc; + display: flex; + flex-wrap: wrap; + gap: 4px; +} + +.summary-line:last-child { + border-bottom: none; +} + +.summary-slot { + color: #888; + min-width: 85px; + font-weight: 600; +} + +.summary-item { + color: #4ecdc4; + font-weight: 600; + flex: 1; + min-width: 150px; +} + +.summary-spells { + color: #ffe66d; + font-size: 10px; +} + +.summary-char { + color: #95a5a6; + font-size: 10px; + margin-left: auto; } \ No newline at end of file diff --git a/static/suitbuilder.html b/static/suitbuilder.html index 55d69b26..4a3b5c75 100644 --- a/static/suitbuilder.html +++ b/static/suitbuilder.html @@ -162,53 +162,53 @@
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
- +
@@ -458,6 +458,17 @@ + + +
+
+

Suit Summary

+ +
+
+
Select a suit to see summary
+
+
diff --git a/static/suitbuilder.js b/static/suitbuilder.js index 47a9a842..dafd34cf 100644 --- a/static/suitbuilder.js +++ b/static/suitbuilder.js @@ -1,13 +1,54 @@ // Suitbuilder JavaScript - Constraint Solver Frontend Logic -console.log('Suitbuilder.js loaded - VERSION: SCORE_ORDERING_AND_CANCELLATION_FIX_v3'); +console.log('Suitbuilder.js loaded - VERSION: CONSISTENT_SUIT_COUNT_v4'); // Configuration const API_BASE = '/inv/suitbuilder'; let currentSuits = []; -let lockedSlots = new Set(); +let lockedSlots = new Map(); // slot -> { set: string|null, setId: number|null, spells: string[] } let selectedSuit = null; let currentSearchController = null; // AbortController for current search +// Common legendary cantrips for lock form +const COMMON_CANTRIPS = [ + // Attributes + 'Legendary Strength', + 'Legendary Endurance', + 'Legendary Coordination', + 'Legendary Quickness', + 'Legendary Focus', + 'Legendary Willpower', + // Weapon Skills + 'Legendary Finesse Weapon Aptitude', + 'Legendary Heavy Weapon Aptitude', + 'Legendary Light Weapon Aptitude', + 'Legendary Missile Weapon Aptitude', + 'Legendary Two Handed Combat Aptitude', + // Magic Skills + 'Legendary War Magic Aptitude', + 'Legendary Void Magic Aptitude', + 'Legendary Creature Enchantment Aptitude', + 'Legendary Item Enchantment Aptitude', + 'Legendary Life Magic Aptitude', + // Defense + 'Legendary Magic Resistance', + 'Legendary Invulnerability' +]; + +// Common legendary wards for lock form +const COMMON_WARDS = [ + 'Legendary Flame Ward', + 'Legendary Frost Ward', + 'Legendary Acid Ward', + 'Legendary Storm Ward', + 'Legendary Slashing Ward', + 'Legendary Piercing Ward', + 'Legendary Bludgeoning Ward', + 'Legendary Armor' +]; + +// Armor slots (have equipment sets) +const ARMOR_SLOTS = ['Head', 'Chest', 'Upper Arms', 'Lower Arms', 'Hands', 'Abdomen', 'Upper Legs', 'Lower Legs', 'Feet']; + // Initialize when page loads document.addEventListener('DOMContentLoaded', function() { initializeSuitbuilder(); @@ -134,34 +175,97 @@ function setupSlotInteractions() { /** * Perform suit search with current constraints using streaming results */ +// Global timer for search +let searchStartTime = null; +let searchTimerInterval = null; + async function performSuitSearch() { const constraints = gatherConstraints(); - + if (!validateConstraints(constraints)) { return; } - + const resultsDiv = document.getElementById('suitResults'); const countSpan = document.getElementById('resultsCount'); - - // Reset current suits and UI + + // Start timer IMMEDIATELY + searchStartTime = Date.now(); + if (searchTimerInterval) clearInterval(searchTimerInterval); + + // Reset current suits and UI with new fancy template currentSuits = []; resultsDiv.innerHTML = ` -
- 🔍 Searching for optimal suits... -
-
- Found: 0 suits | - Checked: 0 combinations | - Time: 0.0s -
- +
+ +
+
âŗ
+
Initializing...
+ + +
+
+ +
+
+
+
+ +
+ + +
+
+
Time
+
0.0
+
seconds
+
+
+
Evaluated
+
0
+
combinations
+
+
+
Rate
+
-
+
per second
+
+
+
Found
+
0
+
suits
+
+
+ + +
+ Verbose Output 0 +
+
+ + +
`; countSpan.textContent = ''; - + + // Start client-side timer update (every 100ms for responsive display) + searchTimerInterval = setInterval(() => { + const elapsed = (Date.now() - searchStartTime) / 1000; + const timeEl = document.getElementById('elapsedTime'); + if (timeEl) timeEl.textContent = elapsed.toFixed(1); + }, 100); + try { await streamOptimalSuits(constraints); } catch (error) { @@ -173,6 +277,12 @@ async function performSuitSearch() { resultsDiv.innerHTML = `
❌ Suit search failed: ${error.message}
`; countSpan.textContent = ''; } + } finally { + // Stop client-side timer + if (searchTimerInterval) { + clearInterval(searchTimerInterval); + searchTimerInterval = null; + } } } @@ -212,10 +322,18 @@ function gatherConstraints() { .filter(cb => cb.checked) .map(cb => cb.value), - // Locked slots - locked_slots: Array.from(lockedSlots) + // Locked slots - convert Map to object for API + locked_slots: Object.fromEntries( + Array.from(lockedSlots.entries()).map(([slot, info]) => [ + slot, + { + set_id: info.setId || null, + spells: info.spells || [] + } + ]) + ) }; - + return constraints; } @@ -252,7 +370,7 @@ async function streamOptimalSuits(constraints) { ...constraints.legendary_cantrips, ...constraints.protection_spells ], - locked_items: {}, // TODO: implement locked items + locked_slots: constraints.locked_slots || {}, include_equipped: constraints.include_equipped, include_inventory: constraints.include_inventory, min_armor: constraints.min_armor ? parseInt(constraints.min_armor) : null, @@ -336,6 +454,10 @@ async function streamOptimalSuits(constraints) { handleSuitEvent(eventData); } else if (currentEventType === 'progress') { handleProgressEvent(eventData); + } else if (currentEventType === 'phase') { + handlePhaseEvent(eventData); + } else if (currentEventType === 'log') { + handleLogEvent(eventData); } else if (currentEventType === 'complete') { handleCompleteEvent(eventData); resolve(); @@ -372,23 +494,32 @@ async function streamOptimalSuits(constraints) { function handleSuitEvent(data) { try { console.log('NEW handleSuitEvent called with data:', data); - + // Transform backend suit format to frontend format const transformedSuit = transformSuitData(data); console.log('Transformed suit:', transformedSuit); - + // Insert suit in score-ordered position (highest score first) const insertIndex = insertSuitInScoreOrder(transformedSuit); console.log('Insert index returned:', insertIndex); - - // Insert DOM element at the correct position instead of regenerating everything + + // If suit wasn't inserted (not good enough for top N), skip DOM update + if (insertIndex === -1) { + console.log('Suit not inserted, skipping DOM update'); + return; + } + + // Insert DOM element at the correct position insertSuitDOMAtPosition(transformedSuit, insertIndex); console.log('DOM insertion complete'); - + + // Trim excess suits from array and DOM to maintain consistent count + trimExcessSuits(); + // Update count document.getElementById('foundCount').textContent = currentSuits.length; document.getElementById('resultsCount').textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`; - + } catch (error) { console.error('Error processing suit data:', error); console.error('Stack trace:', error.stack); @@ -397,28 +528,165 @@ async function streamOptimalSuits(constraints) { function handleProgressEvent(data) { try { - document.getElementById('foundCount').textContent = data.found || currentSuits.length; - document.getElementById('checkedCount').textContent = data.evaluated || 0; - document.getElementById('elapsedTime').textContent = data.elapsed || '0.0'; + // Always show frontend's actual suit count for consistency + document.getElementById('foundCount').textContent = currentSuits.length; + document.getElementById('checkedCount').textContent = (data.evaluated || 0).toLocaleString(); + + // Update rate display + const rateEl = document.getElementById('searchRate'); + if (rateEl && data.rate !== undefined) { + rateEl.textContent = data.rate.toLocaleString(); + } + + // Update bucket progress bar + if (data.current_depth !== undefined && data.total_buckets) { + const bucketContainer = document.getElementById('bucketProgressContainer'); + const bucketProgress = document.getElementById('bucketProgress'); + const currentBucket = document.getElementById('currentBucket'); + + if (bucketContainer) bucketContainer.style.display = 'block'; + if (bucketProgress) { + const progress = (data.current_depth / data.total_buckets) * 100; + bucketProgress.style.width = `${progress}%`; + } + if (currentBucket && data.current_bucket) { + currentBucket.textContent = `Searching: ${data.current_bucket} (${data.current_depth + 1}/${data.total_buckets})`; + } + } + + // Note: Timer is now handled client-side for immediate response } catch (error) { console.error('Error processing progress data:', error); } } + + function handlePhaseEvent(data) { + try { + const phaseIcon = document.getElementById('phaseIcon'); + const phaseText = document.getElementById('searchPhase'); + const phaseProgress = document.getElementById('phaseProgress'); + + // Phase icons + const icons = { + 'loading': '📂', + 'loaded': '✅', + 'buckets': 'đŸ—‚ī¸', + 'buckets_done': '✅', + 'reducing': 'âœ‚ī¸', + 'sorting': '📊', + 'searching': '🔍' + }; + + if (phaseIcon) { + phaseIcon.textContent = icons[data.phase] || 'âŗ'; + // Add/remove animation for searching phase + if (data.phase === 'searching') { + phaseIcon.classList.add('searching'); + } + } + + if (phaseText) { + phaseText.textContent = data.message || data.phase; + } + + // Update initialization progress bar + if (phaseProgress && data.phase_number && data.total_phases) { + const progress = (data.phase_number / data.total_phases) * 100; + phaseProgress.style.width = `${progress}%`; + + // Make it indeterminate during searching + if (data.phase === 'searching') { + phaseProgress.classList.add('indeterminate'); + } + } + + // Log the phase change + addLogEntry(data.message, 'phase'); + + } catch (error) { + console.error('Error processing phase data:', error); + } + } + + function handleLogEvent(data) { + try { + addLogEntry(data.message, data.level || 'info', data.timestamp); + } catch (error) { + console.error('Error processing log data:', error); + } + } + + function addLogEntry(message, level = 'info', timestamp = null) { + const logContainer = document.getElementById('verboseLog'); + const logCount = document.getElementById('logCount'); + if (!logContainer) return; + + const elapsed = timestamp || ((Date.now() - searchStartTime) / 1000); + + const entry = document.createElement('div'); + entry.className = `log-entry log-${level}`; + entry.innerHTML = `[${elapsed.toFixed(1)}s] ${message}`; + + logContainer.appendChild(entry); + + // Trim old entries (max 100) + while (logContainer.children.length > 100) { + logContainer.removeChild(logContainer.firstChild); + } + + // Update log count + if (logCount) { + logCount.textContent = logContainer.children.length; + } + + // Auto-scroll to bottom + logContainer.scrollTop = logContainer.scrollHeight; + } function handleCompleteEvent(data) { try { - // Hide loading indicator - const loadingDiv = document.querySelector('.loading'); - if (loadingDiv) { - loadingDiv.innerHTML = `✅ Search complete! Found ${data.suits_found} suits in ${data.duration}s.`; + const duration = ((Date.now() - searchStartTime) / 1000).toFixed(1); + + // Update phase indicator to complete + const phaseIcon = document.getElementById('phaseIcon'); + const phaseText = document.getElementById('searchPhase'); + const phaseProgress = document.getElementById('phaseProgress'); + + if (phaseIcon) { + phaseIcon.textContent = '✅'; + phaseIcon.classList.remove('searching'); + phaseIcon.classList.add('complete'); } - + if (phaseText) { + phaseText.textContent = `Search complete! Found ${currentSuits.length} suits`; + phaseText.classList.add('complete'); + } + if (phaseProgress) { + phaseProgress.classList.remove('indeterminate'); + phaseProgress.style.width = '100%'; + } + + // Fill bucket progress bar + const bucketProgress = document.getElementById('bucketProgress'); + if (bucketProgress) { + bucketProgress.style.width = '100%'; + } + + // Hide stop button + const stopBtn = document.getElementById('stopSearch'); + if (stopBtn) { + stopBtn.style.display = 'none'; + } + + // Add completion log entry + addLogEntry(`Search complete: ${currentSuits.length} suits found in ${duration}s`, 'success'); + // Update final results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { countSpan.textContent = `Found ${currentSuits.length} suit${currentSuits.length !== 1 ? 's' : ''}`; } - + } catch (error) { console.error('Error processing completion data:', error); } @@ -439,19 +707,36 @@ async function streamOptimalSuits(constraints) { const stopButton = document.getElementById('stopSearch'); stopButton.addEventListener('click', () => { searchStopped = true; - + // Actually abort the HTTP request if (currentSearchController) { currentSearchController.abort(); currentSearchController = null; } - - // Update UI to show search was stopped - const loadingDiv = document.querySelector('.loading'); - if (loadingDiv) { - loadingDiv.innerHTML = 'âšī¸ Search stopped by user.'; + + // Update phase indicator to show stopped + const phaseIcon = document.getElementById('phaseIcon'); + const phaseText = document.getElementById('searchPhase'); + const phaseProgress = document.getElementById('phaseProgress'); + + if (phaseIcon) { + phaseIcon.textContent = 'âšī¸'; + phaseIcon.classList.remove('searching'); } - + if (phaseText) { + phaseText.textContent = `Search stopped - Found ${currentSuits.length} suits`; + phaseText.style.color = '#ffa726'; + } + if (phaseProgress) { + phaseProgress.classList.remove('indeterminate'); + } + + // Hide stop button + stopButton.style.display = 'none'; + + // Add log entry + addLogEntry(`Search stopped by user with ${currentSuits.length} suits found`, 'warning'); + // Update results count const countSpan = document.getElementById('resultsCount'); if (countSpan) { @@ -499,10 +784,13 @@ function transformSuitData(suit) { /** * Insert a suit into the currentSuits array in score-ordered position (highest first) + * Keeps only top MAX_SUITS to match backend behavior and ensure consistent counts */ +const MAX_SUITS = 10; // Must match max_results sent to backend + function insertSuitInScoreOrder(suit) { console.log(`Inserting suit with score ${suit.score}. Current suits:`, currentSuits.map(s => s.score)); - + // Find the correct position to insert the suit (highest score first) let insertIndex = 0; for (let i = 0; i < currentSuits.length; i++) { @@ -512,14 +800,47 @@ function insertSuitInScoreOrder(suit) { } insertIndex = i + 1; } - + + // If we already have MAX_SUITS and this suit would be at the end (worst), skip it + if (currentSuits.length >= MAX_SUITS && insertIndex >= MAX_SUITS) { + console.log(`Suit with score ${suit.score} not good enough for top ${MAX_SUITS}, skipping`); + return -1; // Signal that suit wasn't inserted + } + // Insert the suit at the correct position currentSuits.splice(insertIndex, 0, suit); - + console.log(`Inserted at index ${insertIndex}. New order:`, currentSuits.map(s => s.score)); return insertIndex; } +/** + * Trim excess suits from both the array and DOM to maintain MAX_SUITS limit + * This ensures consistent suit counts between runs + */ +function trimExcessSuits() { + const streamingResults = document.getElementById('streamingResults'); + if (!streamingResults) return; + + // Remove excess suits from array (keep only top MAX_SUITS) + while (currentSuits.length > MAX_SUITS) { + const removed = currentSuits.pop(); + console.log(`Trimmed suit with score ${removed.score} (exceeds MAX_SUITS=${MAX_SUITS})`); + } + + // Remove excess DOM elements (keep only top MAX_SUITS) + while (streamingResults.children.length > MAX_SUITS) { + const lastChild = streamingResults.lastElementChild; + if (lastChild) { + console.log(`Removing DOM element for suit at position ${streamingResults.children.length}`); + lastChild.remove(); + } + } + + // Update medals after trimming + updateAllMedals(); +} + /** * Regenerate the entire results display to maintain proper score ordering */ @@ -1137,30 +1458,107 @@ function selectSuit(suitId) { * Populate the visual equipment slots with suit items */ function populateVisualSlots(items) { - // Clear all slots first + // Clear non-locked slots first document.querySelectorAll('.slot-content').forEach(slot => { - const slotName = slot.id.replace('slot_', '').replace('_', ' '); - slot.innerHTML = 'Empty'; - slot.parentElement.classList.remove('populated'); + const slotName = slot.id.replace('slot_', '').replace(/_/g, ' '); + // Skip locked slots - preserve their summary + if (!lockedSlots.has(slotName)) { + slot.innerHTML = 'Empty'; + slot.parentElement.classList.remove('populated'); + } }); - - // Populate with items + + // Populate non-locked slots with items Object.entries(items).forEach(([slotName, item]) => { - const slotId = `slot_${slotName.replace(' ', '_')}`; + // Skip locked slots - they keep their configured info + if (lockedSlots.has(slotName)) return; + + const slotId = `slot_${slotName.replace(/ /g, '_')}`; const slotElement = document.getElementById(slotId); - + if (slotElement) { const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; - + slotElement.innerHTML = `
${item.name}
${item.source_character || item.character_name || 'Unknown'}
${formatItemProperties(item)}
${needsReducing} + `; slotElement.parentElement.classList.add('populated'); } }); + + // Update suit summary + updateSuitSummary(); +} + +/** + * Clear a single slot + */ +function clearSlot(slotName) { + const slotElement = document.querySelector(`[data-slot="${slotName}"]`); + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + + slotContent.innerHTML = 'Empty'; + slotElement.classList.remove('populated'); + + // Also remove from selectedSuit if present + if (selectedSuit && selectedSuit.items[slotName]) { + delete selectedSuit.items[slotName]; + } + + updateSuitSummary(); +} + +/** + * Update the suit summary display + */ +function updateSuitSummary() { + const summaryDiv = document.getElementById('suitSummary'); + if (!summaryDiv) return; + + if (!selectedSuit || Object.keys(selectedSuit.items).length === 0) { + summaryDiv.innerHTML = '
Select a suit to see summary
'; + return; + } + + const lines = Object.entries(selectedSuit.items).map(([slot, item]) => { + const spells = (item.spell_names || []).map(s => s.replace('Legendary ', 'L.')).join(', ') || 'No spells'; + const char = item.source_character || item.character_name || 'Unknown'; + return `
+ ${slot}: + ${item.name} + [${spells}] + - ${char} +
`; + }).join(''); + + summaryDiv.innerHTML = lines; +} + +/** + * Copy suit summary to clipboard + */ +function copySuitSummary() { + if (!selectedSuit) return; + + const text = Object.entries(selectedSuit.items).map(([slot, item]) => { + const spells = (item.spell_names || []).join(', ') || 'No spells'; + const char = item.source_character || item.character_name || 'Unknown'; + return `${slot}: ${item.name} [${spells}] - ${char}`; + }).join('\n'); + + navigator.clipboard.writeText(text).then(() => { + // Brief visual feedback + const btn = document.querySelector('.copy-summary-btn'); + if (btn) { + const original = btn.textContent; + btn.textContent = 'Copied!'; + setTimeout(() => btn.textContent = original, 1500); + } + }); } /** @@ -1169,29 +1567,236 @@ function populateVisualSlots(items) { function toggleSlotLock(slotName) { const slotElement = document.querySelector(`[data-slot="${slotName}"]`); const lockBtn = slotElement.querySelector('.lock-btn'); - + if (lockedSlots.has(slotName)) { - lockedSlots.delete(slotName); - slotElement.classList.remove('locked'); - lockBtn.classList.remove('locked'); + // Unlocking - remove from map and clear visual + unlockSlot(slotName); } else { - lockedSlots.add(slotName); - slotElement.classList.add('locked'); - lockBtn.classList.add('locked'); + // Locking - check if slot is populated + const isPopulated = slotElement.classList.contains('populated'); + + if (isPopulated && selectedSuit && selectedSuit.items[slotName]) { + // Auto-extract set/spells from the populated item + const item = selectedSuit.items[slotName]; + const lockInfo = { + set: item.set_name || null, + setId: item.set_id || null, + spells: item.spell_names || item.spells || [] + }; + lockedSlots.set(slotName, lockInfo); + slotElement.classList.add('locked'); + lockBtn.classList.add('locked'); + renderLockedSlotSummary(slotName, lockInfo); + } else { + // Empty slot - show lock form for manual entry + showLockSlotForm(slotName); + } } } +/** + * Unlock a slot and restore its content + */ +function unlockSlot(slotName) { + const slotElement = document.querySelector(`[data-slot="${slotName}"]`); + const lockBtn = slotElement.querySelector('.lock-btn'); + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + + lockedSlots.delete(slotName); + slotElement.classList.remove('locked'); + lockBtn.classList.remove('locked'); + + // Restore slot content (either from selected suit or empty) + if (selectedSuit && selectedSuit.items[slotName]) { + const item = selectedSuit.items[slotName]; + const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; + slotContent.innerHTML = ` +
${item.name}
+
${item.source_character || item.character_name || 'Unknown'}
+
${formatItemProperties(item)}
+ ${needsReducing} + `; + } else { + slotContent.innerHTML = 'Empty'; + slotElement.classList.remove('populated'); + } +} + +/** + * Show lock form for manual slot configuration + */ +function showLockSlotForm(slotName) { + const slotElement = document.querySelector(`[data-slot="${slotName}"]`); + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + const isArmorSlot = ARMOR_SLOTS.includes(slotName); + + // Build spell checkboxes + const spellCheckboxes = [...COMMON_CANTRIPS, ...COMMON_WARDS].map(spell => ` + + `).join(''); + + // Build set dropdown (armor only) + const setDropdown = isArmorSlot ? ` +
+ + +
+ ` : ''; + + slotContent.innerHTML = ` +
+
Configure Locked Slot
+ ${setDropdown} +
+ +
+ ${spellCheckboxes} +
+
+
+ + +
+
+ `; + + slotElement.classList.add('configuring'); +} + +/** + * Get equipment set options for dropdown + */ +function getEquipmentSetOptions() { + // Equipment sets - IDs must match getSetNameById() and backend + const commonSets = [ + { id: 14, name: "Adept's" }, + { id: 13, name: "Soldier's" }, + { id: 15, name: "Archer's" }, + { id: 16, name: "Defender's" }, + { id: 19, name: 'Hearty' }, + { id: 20, name: 'Dexterous' }, + { id: 21, name: 'Wise' }, + { id: 22, name: 'Swift' }, + { id: 24, name: 'Reinforced' }, + { id: 26, name: 'Flame Proof' }, + { id: 29, name: 'Lightning Proof' }, + { id: 40, name: 'Heroic Protector' }, + { id: 41, name: 'Heroic Destroyer' }, + { id: 46, name: 'Relic Alduressa' }, + { id: 47, name: 'Ancient Relic' }, + { id: 48, name: 'Noble Relic' } + ]; + + return commonSets.map(set => + `` + ).join(''); +} + +/** + * Save lock slot form and apply lock + */ +function saveLockSlotForm(slotName) { + const slotElement = document.querySelector(`[data-slot="${slotName}"]`); + const lockBtn = slotElement.querySelector('.lock-btn'); + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + const isArmorSlot = ARMOR_SLOTS.includes(slotName); + + // Get selected set (armor only) + let setId = null; + let setName = null; + if (isArmorSlot) { + const setSelect = document.getElementById(`lockSetSelect_${slotName.replace(/ /g, '_')}`); + if (setSelect && setSelect.value) { + setId = parseInt(setSelect.value); + setName = setSelect.options[setSelect.selectedIndex].text; + } + } + + // Get selected spells + const selectedSpells = []; + slotContent.querySelectorAll('.lock-spell-checkbox input:checked').forEach(cb => { + selectedSpells.push(cb.value); + }); + + // Create lock info + const lockInfo = { + set: setName, + setId: setId, + spells: selectedSpells + }; + + // Store and render + lockedSlots.set(slotName, lockInfo); + slotElement.classList.remove('configuring'); + slotElement.classList.add('locked'); + lockBtn.classList.add('locked'); + renderLockedSlotSummary(slotName, lockInfo); +} + +/** + * Cancel lock slot form + */ +function cancelLockSlotForm(slotName) { + const slotElement = document.querySelector(`[data-slot="${slotName}"]`); + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + + slotElement.classList.remove('configuring'); + + // Restore original content + if (selectedSuit && selectedSuit.items[slotName]) { + const item = selectedSuit.items[slotName]; + const needsReducing = isMultiSlotItem(item) ? 'Need Reducing' : ''; + slotContent.innerHTML = ` +
${item.name}
+
${item.source_character || item.character_name || 'Unknown'}
+
${formatItemProperties(item)}
+ ${needsReducing} + `; + } else { + slotContent.innerHTML = 'Empty'; + } +} + +/** + * Render locked slot summary display + */ +function renderLockedSlotSummary(slotName, lockInfo) { + const slotContent = document.getElementById(`slot_${slotName.replace(/ /g, '_')}`); + + const setBadge = lockInfo.set ? + `
${lockInfo.set}
` : ''; + + const spellPills = lockInfo.spells.length > 0 ? + `
+ ${lockInfo.spells.map(s => `${s.replace('Legendary ', 'L. ')}`).join('')} +
` : '
No spells specified
'; + + slotContent.innerHTML = ` +
+
🔒 Locked
+ ${setBadge} + ${spellPills} + +
+ `; +} + /** * Handle slot click events */ function handleSlotClick(slotName) { - // For now, just toggle lock state - // Later this could open item selection dialog + // Could open item selection dialog in the future console.log(`Clicked slot: ${slotName}`); } /** - * Lock currently selected slots + * Lock currently selected/populated slots (auto-extract from items) */ function lockSelectedSlots() { document.querySelectorAll('.slot-item.populated').forEach(slot => { @@ -1206,10 +1811,22 @@ function lockSelectedSlots() { * Clear all slot locks */ function clearAllLocks() { + // Get all locked slot names before clearing + const lockedSlotNames = Array.from(lockedSlots.keys()); + lockedSlots.clear(); + + // Restore each slot + lockedSlotNames.forEach(slotName => { + unlockSlot(slotName); + }); + + // Also clear any remaining visual lock states document.querySelectorAll('.slot-item').forEach(slot => { slot.classList.remove('locked'); - slot.querySelector('.lock-btn').classList.remove('locked'); + slot.classList.remove('configuring'); + const lockBtn = slot.querySelector('.lock-btn'); + if (lockBtn) lockBtn.classList.remove('locked'); }); }