Last active
February 10, 2026 20:34
-
-
Save pakoito/839fadb61de0f5c5ddc2b2e31b2a1cad to your computer and use it in GitHub Desktop.
Manabase Auto-Analyzer - Greasemonkey script for Salubrious Snail
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| // ==UserScript== | |
| // @name Manabase Tool Auto-Analyzer | |
| // @namespace http://tampermonkey.net/ | |
| // @version 3.3.1 | |
| // @description Auto-triggers analyzers, maintains history, and optimizes basic land distribution | |
| // @author pakoito | |
| // @match https://ianrh125.github.io/snail-analyzer/ | |
| // @icon https://www.google.com/s2/favicons?sz=64&domain=github.io | |
| // @require https://gist.githubusercontent.com/pakoito/5c7f9b8c35efee0126b2b874beb365db/raw/manabase-optimizer-bundle.js?v=1770755660401 | |
| // @grant none | |
| // @run-at document-start | |
| // ==/UserScript== | |
| /** | |
| * Manabase Tool Auto-Analyzer v3.3.1 | |
| * | |
| * PURPOSE: | |
| * Automatically triggers the Color Analyzer and Tap Analyzer buttons after a deck | |
| * successfully loads in the Salubrious Snail Manabase Tool, maintains a complete | |
| * history of all deck analyses, and optimizes basic land distribution using a | |
| * hill-climbing algorithm. | |
| * | |
| * FEATURES: | |
| * 1. Auto-Trigger: Automatically runs both analyzers when deck loads | |
| * 2. History Capture: Saves up to 100 analysis results automatically | |
| * 3. Diff Tracking: Shows what changed between deck versions | |
| * 4. One-Click Restore: Load any previous deck state and re-run analysis | |
| * 5. Clipboard Paste: Paste deck from clipboard with auto-commander detection | |
| * 6. Basic Land Optimizer: Find optimal basic land distribution for your deck | |
| * 7. localStorage Storage: Persists history across browser sessions | |
| * | |
| * BASIC LAND OPTIMIZER: | |
| * The optimizer uses a hill-climbing algorithm to find better basic land distributions: | |
| * 1. Extracts your deck list and current basic land counts | |
| * 2. Fetches card data from Scryfall API | |
| * 3. Runs the website's color calculation algorithm on different land configurations | |
| * 4. Tests neighbors (1-land swaps) and explores improvements | |
| * 5. Returns top 5 configurations ranked by cast rate and average delay | |
| * | |
| * The optimizer identifies your weakest color and suggests adding more of those lands | |
| * while removing from stronger colors. It preserves total land count and maintains | |
| * minimum 1 of each type present in your deck. | |
| * | |
| * HOW IT WORKS: | |
| * Auto-Trigger: | |
| * 1. Monitors #load-result for "Deck loaded!" message | |
| * 2. Auto-triggers Color and Tap Analyzers with 200ms delay | |
| * 3. Watches analyzer result boxes for completion | |
| * 4. Captures complete snapshot when both analyzers finish | |
| * 5. Stores in localStorage with 100-entry FIFO limit | |
| * 6. Displays in expandable history panel below analyzers | |
| * | |
| * Optimizer: | |
| * 1. Click "Optimize Basic Lands" button in optimizer panel | |
| * 2. Script extracts deck list from #decklist textarea | |
| * 3. Counts basic lands (Plains, Island, Swamp, Mountain, Forest, Wastes) | |
| * 4. Calls ManabaseOptimizer.optimizeLands() with deck data | |
| * 5. Shows progress during card loading and optimization | |
| * 6. Displays top 5 configurations with cast rates and changes needed | |
| * | |
| * HISTORY DATA CAPTURED: | |
| * - Deck list (trimmed, max 110 lines) | |
| * - Commander names and importance weights | |
| * - Manabase metrics (cast rate, average delay) | |
| * - Commander-specific cast rates and delays | |
| * - Color Analyzer summary | |
| * - Tap statistics | |
| * - Card-by-card diff from previous entry | |
| * | |
| * KEY ELEMENTS: | |
| * - #load-result: Deck load status message | |
| * - #color-compute-button, #tap-compute-button: Analyzer triggers | |
| * - #color-analyzer-result-box, #tap-analyzer-result-box: Result containers | |
| * - #optimizer-panel: Optimizer UI (created by script) | |
| * - #history-panel: History UI (created by script) | |
| * - localStorage['manabase-history']: Persistent storage | |
| * - ManabaseOptimizer: Global object from bundled optimizer module | |
| * | |
| * EXTERNAL DEPENDENCIES: | |
| * - Optimizer Bundle: Loaded via @require from GitHub Gist | |
| * https://gist.github.com/pakoito/5c7f9b8c35efee0126b2b874beb365db | |
| * - Scryfall API: Card data fetching (rate limited to 50-100ms between requests) | |
| * | |
| * SAFETY FEATURES: | |
| * - Validates all DOM elements before access | |
| * - Graceful degradation for missing data | |
| * - Emergency trim if localStorage quota exceeded | |
| * - Corrupted data detection and cleanup | |
| * - Comprehensive error handling and logging | |
| * - Confirmation dialog for clearing history | |
| * - Progress feedback during optimization | |
| * - Button disabled while optimizer is running | |
| * | |
| * STORAGE: | |
| * Uses localStorage with key 'manabase-history'. FIFO enforced at 100 entries. | |
| * Typical storage: ~50-100KB for 100 entries. Emergency trim to 50 if quota exceeded. | |
| * | |
| * PERFORMANCE: | |
| * - Optimizer typically tests 20-50 configurations | |
| * - Each configuration takes ~2-3 seconds (Scryfall rate limiting) | |
| * - Total optimization time: 1-2 minutes for most decks | |
| * - Results cached to avoid redundant calculations | |
| */ | |
| (function () { | |
| 'use strict'; | |
| /** | |
| * Debug flag - set to true to enable verbose logging | |
| * When false, only logs errors, warnings, and major actions | |
| */ | |
| const DEBUG = true; | |
| // Helper logging functions | |
| function logDebug(...args) { | |
| if (DEBUG) console.log(...args); | |
| } | |
| function logInfo(...args) { | |
| console.log(...args); | |
| } | |
| function logWarn(...args) { | |
| console.warn(...args); | |
| } | |
| function logError(...args) { | |
| console.error(...args); | |
| } | |
| logInfo('Manabase Auto-Analyzer: Script loaded'); | |
| /** | |
| * State Management | |
| * | |
| * Track the last load message that triggered the analyzers to prevent | |
| * duplicate triggers. The load result text changes to "Deck loaded! (1)", | |
| * "Deck loaded! (2)", etc. for sequential loads, so we compare the full text. | |
| */ | |
| let lastTriggeredText = ''; | |
| /** | |
| * Initialize the script once DOM is ready | |
| */ | |
| function initializeScript() { | |
| /** | |
| * Element Detection | |
| * | |
| * The #load-result element contains the deck load status message. | |
| * It displays "Deck loaded!" on success or error messages on failure. | |
| * If this element doesn't exist, the page structure has likely changed | |
| * and the script cannot function. | |
| */ | |
| const loadResult = document.getElementById('load-result'); | |
| if (!loadResult) { | |
| logError('Manabase Auto-Analyzer: load-result element not found'); | |
| return; | |
| } | |
| logInfo('Manabase Auto-Analyzer: Monitoring deck loads...'); | |
| startMonitoring(loadResult); | |
| } | |
| /** | |
| * Start monitoring for deck loads | |
| */ | |
| function startMonitoring(loadResult) { | |
| /** | |
| * Auto-Trigger Function | |
| * | |
| * Triggers both Color Analyzer and Tap Analyzer in sequence. | |
| * | |
| * EXECUTION ORDER: | |
| * 1. Verify both buttons exist and are enabled | |
| * 2. Click Color Analyzer button | |
| * 3. Wait 200ms (gives time for Color Analyzer to initialize) | |
| * 4. Click Tap Analyzer button | |
| * | |
| * WHY THE DELAY: | |
| * The 200ms delay between clicks ensures the Color Analyzer has time to | |
| * start its computation before the Tap Analyzer begins. This prevents | |
| * potential race conditions or resource conflicts. You can adjust this | |
| * value if needed (increase if analyzers conflict, decrease to speed up). | |
| * | |
| * ERROR HANDLING: | |
| * - Wrapped in try-catch to handle unexpected errors gracefully | |
| * - Checks for button existence before clicking | |
| * - Verifies buttons are enabled (not disabled) | |
| * - Second try-catch for the delayed Tap Analyzer click | |
| */ | |
| function triggerAnalyzers() { | |
| try { | |
| // Reset analyzer completion flags before triggering | |
| resetAnalyzerFlags(); | |
| // Get references to both analyzer buttons | |
| const colorButton = document.getElementById('color-compute-button'); | |
| const tapButton = document.getElementById('tap-compute-button'); | |
| // Verify both buttons exist before proceeding | |
| if (!colorButton || !tapButton) { | |
| logWarn('Manabase Auto-Analyzer: One or more analyzer buttons not found'); | |
| return; | |
| } | |
| // Check if Color button is enabled (disabled during computation) | |
| if (colorButton.disabled) { | |
| logWarn('Manabase Auto-Analyzer: Color button is disabled, skipping auto-trigger'); | |
| return; | |
| } | |
| // Trigger Color Analyzer | |
| logInfo('Manabase Auto-Analyzer: Triggering Color Analyzer...'); | |
| colorButton.click(); | |
| /** | |
| * Delayed Tap Analyzer Trigger | |
| * | |
| * Use setTimeout to create a 200ms delay before clicking the Tap Analyzer. | |
| * This ensures the Color Analyzer has started before we trigger the second one. | |
| */ | |
| setTimeout(() => { | |
| try { | |
| // Re-verify button exists and is enabled (state may have changed) | |
| if (tapButton && !tapButton.disabled) { | |
| logInfo('Manabase Auto-Analyzer: Triggering Tap Analyzer...'); | |
| tapButton.click(); | |
| } else { | |
| logWarn('Manabase Auto-Analyzer: Tap button not available or disabled'); | |
| } | |
| } catch (error) { | |
| logError('Manabase Auto-Analyzer: Error triggering Tap Analyzer:', error); | |
| } | |
| }, 200); // 200ms delay - adjust if needed | |
| } catch (error) { | |
| logError('Manabase Auto-Analyzer: Error in triggerAnalyzers:', error); | |
| } | |
| } | |
| /** | |
| * MutationObserver Setup | |
| * | |
| * WHY MUTATIONOBSERVER: | |
| * We use MutationObserver instead of polling (setInterval) because it's more | |
| * efficient and responsive. The observer fires immediately when the DOM changes, | |
| * whereas polling would check on a fixed schedule and could miss rapid changes | |
| * or waste resources checking when nothing has changed. | |
| * | |
| * WHAT IT WATCHES: | |
| * Monitors the #load-result element for any text content changes. When the user | |
| * clicks the "Load" button, the deck loading function updates this element with | |
| * either "Deck loaded!" (success) or an error message (failure). | |
| * | |
| * OBSERVATION CONFIGURATION: | |
| * - childList: true → Watches for added/removed child nodes | |
| * - characterData: true → Watches for text content changes | |
| * - subtree: true → Watches all descendants, not just direct children | |
| * | |
| * These options ensure we catch the text change regardless of how the page | |
| * updates the element (direct text, innerHTML, child nodes, etc.). | |
| */ | |
| const observer = new MutationObserver((mutations) => { | |
| try { | |
| // Get the current text content of the load result element | |
| const loadText = loadResult.innerText.trim(); | |
| /** | |
| * Success Detection | |
| * | |
| * Check if the deck loaded successfully by looking for "Deck loaded!". | |
| * The message format is: | |
| * - First load: "Deck loaded!" | |
| * - Second load: "Deck loaded! (1)" | |
| * - Third load: "Deck loaded! (2)" | |
| * etc. | |
| */ | |
| if (loadText.includes('Deck loaded!')) { | |
| /** | |
| * Duplicate Prevention | |
| * | |
| * The MutationObserver may fire multiple times for the same change | |
| * (due to multiple mutations in the DOM). We track the last message | |
| * that triggered the analyzers and skip if it's the same. | |
| */ | |
| if (loadText === lastTriggeredText) { | |
| return; | |
| } | |
| // Update state and trigger analyzers | |
| lastTriggeredText = loadText; | |
| logInfo('Manabase Auto-Analyzer: Deck loaded successfully, triggering analyzers'); | |
| // Update complexity warnings (analyze deck once, pass to both) | |
| const complexity = analyzeDeckComplexity(); | |
| updateColorAnalyzerComplexityWarning(complexity); | |
| updateOptimizerComplexityWarning(complexity); | |
| triggerAnalyzers(); | |
| // Clear optimizer results and enable button + timeout input | |
| const optimizeBtn = document.getElementById('optimize-lands-btn'); | |
| const timeoutInput = document.getElementById('optimizer-timeout'); | |
| const timeoutLabel = document.getElementById('optimizer-timeout-label'); | |
| const statusDiv = document.getElementById('optimizer-status'); | |
| const resultsDiv = document.getElementById('optimizer-results'); | |
| if (optimizeBtn) { | |
| optimizeBtn.disabled = false; | |
| optimizeBtn.value = 'Optimize Basic Lands'; | |
| optimizeBtn.style.backgroundColor = ''; | |
| optimizeBtn.style.color = ''; | |
| optimizeBtn.onclick = null; // Clear any cancel handler | |
| logDebug('Optimizer: Button enabled after deck load'); | |
| } | |
| if (timeoutInput) { | |
| timeoutInput.disabled = false; | |
| timeoutInput.style.display = ''; | |
| logDebug('Optimizer: Timeout input enabled after deck load'); | |
| } | |
| if (timeoutLabel) { | |
| timeoutLabel.style.display = ''; | |
| logDebug('Optimizer: Timeout label shown after deck load'); | |
| } | |
| if (resultsDiv) { | |
| resultsDiv.innerHTML = ''; | |
| resultsDiv.style.display = 'none'; | |
| logDebug('Optimizer: Results cleared after deck load'); | |
| } | |
| if (statusDiv) { | |
| // Reset status to ready | |
| statusDiv.textContent = 'Ready to optimize'; | |
| statusDiv.style.color = '#555'; | |
| } | |
| } else if (loadText.toLowerCase().includes('error')) { | |
| /** | |
| * Error Detection | |
| * | |
| * If the deck load failed (invalid format, missing commander, etc.), | |
| * the load result will contain an error message. We don't trigger | |
| * the analyzers in this case since there's no valid deck to analyze. | |
| * | |
| * We also reset lastTriggeredText so the next successful load will | |
| * trigger even if it happens to have the same counter number. | |
| */ | |
| logInfo('Manabase Auto-Analyzer: Deck load failed, skipping auto-trigger'); | |
| lastTriggeredText = ''; // Reset for next attempt | |
| } | |
| } catch (error) { | |
| // Catch any unexpected errors to prevent the observer from breaking | |
| logError('Manabase Auto-Analyzer: Error in MutationObserver callback:', error); | |
| } | |
| }); | |
| /** | |
| * Start Observing | |
| * | |
| * Begin watching the #load-result element for changes. The observer will | |
| * continue running until the page is closed or refreshed. | |
| */ | |
| observer.observe(loadResult, { | |
| childList: true, // Watch for added/removed child nodes | |
| characterData: true, // Watch for text content changes | |
| subtree: true // Watch all descendants | |
| }); | |
| } | |
| /** | |
| * ================================================================= | |
| * CLIPBOARD PASTE FEATURE | |
| * ================================================================= | |
| */ | |
| // Fix UTF-8 double-encoding: text was UTF-8, misread as Latin-1, then re-encoded as UTF-8 | |
| // Reverse by treating each codepoint as a Latin-1 byte value and re-decoding as UTF-8 | |
| function fixMojibake(text) { | |
| try { | |
| const bytes = []; | |
| for (let i = 0; i < text.length; i++) { | |
| const code = text.charCodeAt(i); | |
| if (code < 256) { | |
| bytes.push(code); | |
| } else { | |
| // Character outside Latin-1 range - not double-encoded, return original | |
| return text; | |
| } | |
| } | |
| const decoded = new TextDecoder('utf-8').decode(new Uint8Array(bytes)); | |
| // If decoding produced replacement characters, original wasn't double-encoded | |
| if (decoded.includes('\uFFFD')) { | |
| return text; | |
| } | |
| return decoded; | |
| } catch (e) { | |
| return text; | |
| } | |
| } | |
| /** | |
| * Add clipboard paste button to Deck Loader section | |
| * | |
| * Supports two deck list formats: | |
| * | |
| * 1. Legacy format: | |
| * - Lines starting with // are skipped (comments) | |
| * - "// COMMANDER" marks commander section | |
| * - Empty line ends commander section | |
| * - Remaining lines are deck cards | |
| * | |
| * 2. Bracket format: | |
| * - [SECTION_NAME] headers (e.g., [COMMANDER], [CREATURES], [LANDS]) | |
| * - [COMMANDER] section contains commanders | |
| * - [SIDEBOARD] and [MAYBEBOARD] sections are excluded | |
| * - All other sections are included in deck list | |
| * | |
| * Sets commander weights to 30 and auto-loads the deck. | |
| */ | |
| function createClipboardPasteButton() { | |
| try { | |
| // Find the Deck Loader title/header | |
| // Look for the element containing "Deck Loader" text | |
| const titles = document.querySelectorAll('.title, h1, h2, h3, p'); | |
| let deckLoaderTitle = null; | |
| for (let title of titles) { | |
| if (title.textContent.trim() === 'Deck Loader') { | |
| deckLoaderTitle = title; | |
| break; | |
| } | |
| } | |
| if (!deckLoaderTitle) { | |
| logWarn('Clipboard: Deck Loader title not found'); | |
| return; | |
| } | |
| // Check if button already exists | |
| if (document.getElementById('clipboard-paste-btn')) { | |
| logDebug('Clipboard: Button already exists'); | |
| return; | |
| } | |
| // Create paste button paragraph (match Load button structure) | |
| const pastePara = document.createElement('p'); | |
| const pasteButton = document.createElement('input'); | |
| pasteButton.type = 'button'; | |
| pasteButton.id = 'clipboard-paste-btn'; | |
| pasteButton.value = 'Paste from Clipboard'; | |
| // No fixed width - let button auto-size to fit text | |
| pastePara.appendChild(pasteButton); | |
| // Insert button paragraph after the title | |
| deckLoaderTitle.parentNode.insertBefore(pastePara, deckLoaderTitle.nextSibling); | |
| // Attach click handler | |
| pasteButton.addEventListener('click', function (e) { | |
| logDebug('Clipboard: Paste button clicked'); | |
| // Save current scroll position | |
| const scrollX = window.scrollX || window.pageXOffset; | |
| const scrollY = window.scrollY || window.pageYOffset; | |
| logDebug('Clipboard: Saved scroll position:', scrollX, scrollY); | |
| // Create a hidden textarea to capture paste event | |
| const tempTextarea = document.createElement('textarea'); | |
| tempTextarea.style.cssText = 'position: fixed; left: -9999px; top: -9999px; opacity: 0;'; | |
| tempTextarea.id = 'temp-clipboard-paste'; | |
| document.body.appendChild(tempTextarea); | |
| // Focus with preventScroll option to avoid jumping | |
| tempTextarea.focus({ preventScroll: true }); | |
| // Restore scroll position (in case preventScroll doesn't work in all browsers) | |
| window.scrollTo(scrollX, scrollY); | |
| logDebug('Clipboard: Temporary textarea created and focused'); | |
| logDebug('Clipboard: Waiting for paste event (Ctrl+V)...'); | |
| // Show a temporary instruction | |
| pasteButton.value = 'Press Ctrl+V to paste'; | |
| pasteButton.style.backgroundColor = '#3498db'; | |
| pasteButton.style.color = 'white'; | |
| // Listen for paste event | |
| const pasteHandler = function (pasteEvent) { | |
| logDebug('Clipboard: Paste event detected'); | |
| pasteEvent.preventDefault(); | |
| // Reset button | |
| pasteButton.value = 'Paste from Clipboard'; | |
| pasteButton.style.backgroundColor = ''; | |
| pasteButton.style.color = ''; | |
| let clipboardText = ''; | |
| // Get pasted data | |
| if (pasteEvent.clipboardData && pasteEvent.clipboardData.getData) { | |
| clipboardText = pasteEvent.clipboardData.getData('text'); | |
| // Fix UTF-8 mojibake (double-encoding issues) | |
| clipboardText = fixMojibake(clipboardText); | |
| } | |
| // Remove temp textarea and event listener | |
| tempTextarea.removeEventListener('paste', pasteHandler); | |
| document.body.removeChild(tempTextarea); | |
| if (!clipboardText || !clipboardText.trim()) { | |
| logDebug('Clipboard: No data pasted'); | |
| alert('No data pasted. Please try again.'); | |
| return; | |
| } | |
| try { | |
| // Process the deck list | |
| const lines = clipboardText.split('\n'); | |
| const commanders = []; | |
| const deckCards = []; | |
| let inCommanderSection = false; | |
| let foundEmptyLine = false; | |
| let currentSection = null; // Track current bracket section | |
| let inSkipSection = false; // Track if in SIDEBOARD/MAYBEBOARD | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i].trim(); | |
| // Check for bracket-style section headers [SECTION_NAME] | |
| if (line.startsWith('[') && line.endsWith(']')) { | |
| const sectionName = line.substring(1, line.length - 1).toUpperCase(); | |
| currentSection = sectionName; | |
| logDebug('Clipboard: Detected section:', sectionName); | |
| // Check if this is a section we should skip | |
| if (sectionName === 'SIDEBOARD' || sectionName === 'MAYBEBOARD') { | |
| inSkipSection = true; | |
| inCommanderSection = false; | |
| logDebug('Clipboard: Entering skip section:', sectionName); | |
| } else { | |
| inSkipSection = false; | |
| if (sectionName === 'COMMANDER') { | |
| inCommanderSection = true; | |
| logDebug('Clipboard: Entering commander section'); | |
| } else { | |
| inCommanderSection = false; | |
| } | |
| } | |
| continue; // Skip the section header line itself | |
| } | |
| // Check for // COMMANDER comment (legacy format) | |
| if (line.startsWith('// COMMANDER')) { | |
| inCommanderSection = true; | |
| currentSection = 'COMMANDER'; | |
| continue; | |
| } | |
| // Empty line marks end of commander section (legacy format) | |
| if (!line) { | |
| if (inCommanderSection && currentSection === 'COMMANDER') { | |
| // Legacy format: // COMMANDER followed by cards, then empty line | |
| foundEmptyLine = true; | |
| inCommanderSection = false; | |
| currentSection = null; | |
| logDebug('Clipboard: Exiting commander section (empty line)'); | |
| } | |
| continue; | |
| } | |
| // Skip cards if we're in a skip section | |
| if (inSkipSection) { | |
| logDebug('Clipboard: Skipping card in', currentSection + ':', line.substring(0, 50)); | |
| continue; | |
| } | |
| // Skip other comment lines (but not dual-faced cards like "Farm // Market") | |
| if (line.startsWith('//')) { | |
| logDebug('Clipboard: Skipping comment line:', line.substring(0, 50)); | |
| continue; | |
| } | |
| // Add to commanders or deck based on section | |
| if (inCommanderSection || currentSection === 'COMMANDER') { | |
| commanders.push(line); | |
| logDebug('Clipboard: Found commander:', line); | |
| } else if (foundEmptyLine || currentSection) { | |
| // In bracket format, any non-skip section adds to deck | |
| // In legacy format, after empty line adds to deck | |
| deckCards.push(line); | |
| } else { | |
| // Fallback: before empty line but no commander marker (legacy format) | |
| // Treat first line as commander | |
| if (commanders.length === 0) { | |
| commanders.push(line); | |
| logDebug('Clipboard: First line as commander:', line); | |
| } else { | |
| deckCards.push(line); | |
| } | |
| } | |
| } | |
| // Sort deck cards: basics first (in WUBRG order), then alphabetically | |
| const basicLands = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes']; | |
| const basics = []; | |
| const nonBasics = []; | |
| for (const card of deckCards) { | |
| // Check if it's a basic land by checking if the line contains the land name | |
| const lowerCard = card.toLowerCase(); | |
| const isBasic = basicLands.some(basic => | |
| lowerCard.includes(basic.toLowerCase()) | |
| ); | |
| if (isBasic) { | |
| basics.push(card); | |
| } else { | |
| nonBasics.push(card); | |
| } | |
| } | |
| // Sort basics in WUBRG(C) order by land name | |
| basics.sort((a, b) => { | |
| const lowerA = a.toLowerCase(); | |
| const lowerB = b.toLowerCase(); | |
| // Find which basic land type each is | |
| const indexA = basicLands.findIndex(basic => lowerA.includes(basic.toLowerCase())); | |
| const indexB = basicLands.findIndex(basic => lowerB.includes(basic.toLowerCase())); | |
| return indexA - indexB; | |
| }); | |
| // Sort non-basics alphabetically | |
| nonBasics.sort((a, b) => a.localeCompare(b, undefined, { sensitivity: 'base' })); | |
| // Combine: commanders first, then basics, then other cards | |
| const filteredLines = [...commanders, ...basics, ...nonBasics]; | |
| logDebug('Clipboard: Parsed', commanders.length, 'commanders,', basics.length, 'basics,', nonBasics.length, 'other cards'); | |
| if (commanders.length === 0) { | |
| alert('No valid commander found in deck list'); | |
| return; | |
| } | |
| // Extract commander names from lines (remove quantity prefix) | |
| const extractCommanderName = (line) => { | |
| const match = line.match(/^\d+x?\s+(.+)$/i); | |
| return match ? match[1].trim() : line; | |
| }; | |
| const commanderNames = commanders.map(extractCommanderName); | |
| logDebug('Clipboard: Extracted commander names:', commanderNames); | |
| // Fill commander fields | |
| const commander1Input = document.getElementById('commander-name-1'); | |
| const commander2Input = document.getElementById('commander-name-2'); | |
| const commander3Input = document.getElementById('commander-name-3'); | |
| logDebug('Clipboard: Commander input fields found:', { | |
| commander1: !!commander1Input, | |
| commander2: !!commander2Input, | |
| commander3: !!commander3Input | |
| }); | |
| if (commander1Input) { | |
| commander1Input.value = commanderNames[0] || ''; | |
| logDebug('Clipboard: Set commander 1 to:', commanderNames[0] || '(cleared)'); | |
| } | |
| if (commander2Input) { | |
| commander2Input.value = commanderNames[1] || ''; | |
| logDebug('Clipboard: Set commander 2 to:', commanderNames[1] || '(cleared)'); | |
| } | |
| if (commander3Input) { | |
| commander3Input.value = commanderNames[2] || ''; | |
| logDebug('Clipboard: Set commander 3 to:', commanderNames[2] || '(cleared)'); | |
| } | |
| // Set commander weights (all to 30) | |
| const weight1Select = document.getElementById('cmdr1-weight'); | |
| const weight2Select = document.getElementById('cmdr2-weight'); | |
| const weight3Select = document.getElementById('cmdr3-weight'); | |
| if (weight1Select) { | |
| weight1Select.value = '30'; | |
| } | |
| if (weight2Select) { | |
| weight2Select.value = commanderNames[1] ? '30' : '10'; | |
| } | |
| if (weight3Select) { | |
| weight3Select.value = commanderNames[2] ? '30' : '10'; | |
| } | |
| // Fill deck list | |
| const decklistTextarea = document.getElementById('decklist'); | |
| if (decklistTextarea) { | |
| decklistTextarea.value = filteredLines.join('\n'); | |
| } | |
| logDebug('Clipboard: Successfully processed', filteredLines.length, 'lines, commanders:', commanderNames); | |
| // Auto-trigger deck load | |
| const loadButton = document.getElementById('deck-load-button'); | |
| if (loadButton && !loadButton.disabled) { | |
| logDebug('Clipboard: Auto-triggering deck load'); | |
| setTimeout(() => { | |
| loadButton.click(); | |
| logDebug('Clipboard: Load button clicked, analyzers will auto-run'); | |
| }, 100); // Small delay to ensure form is populated | |
| } else { | |
| logWarn('Clipboard: Load button not available or disabled'); | |
| } | |
| } catch (error) { | |
| logError('Clipboard: Error processing pasted data:', error); | |
| alert('Error processing deck list: ' + error.message); | |
| } | |
| }; | |
| tempTextarea.addEventListener('paste', pasteHandler); | |
| // Timeout to reset button if no paste within 10 seconds | |
| setTimeout(() => { | |
| if (document.getElementById('temp-clipboard-paste')) { | |
| logDebug('Clipboard: Paste timeout, cleaning up'); | |
| tempTextarea.removeEventListener('paste', pasteHandler); | |
| document.body.removeChild(tempTextarea); | |
| pasteButton.value = 'Paste from Clipboard'; | |
| pasteButton.style.backgroundColor = ''; | |
| pasteButton.style.color = ''; | |
| } | |
| }, 10000); | |
| }); | |
| logDebug('Clipboard: Paste button created'); | |
| } catch (error) { | |
| logError('Clipboard: Error creating paste button:', error); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * INITIALIZATION | |
| * ================================================================= | |
| */ | |
| /** | |
| * Initialize History Feature | |
| * | |
| * Setup analyzer completion observers and create the history panel. | |
| */ | |
| function initializeHistory() { | |
| setupAnalyzerCompletionObservers(); | |
| createClipboardPasteButton(); | |
| createOptimizerPanel(); | |
| createHistoryPanel(); | |
| // Auto-load latest history entry | |
| try { | |
| const history = loadHistory(); | |
| if (history.length > 0) { | |
| const latestEntry = history[history.length - 1]; | |
| logDebug('History: Auto-loading latest entry from', latestEntry.timestamp); | |
| // Fill deck list | |
| const decklistTextarea = document.getElementById('decklist'); | |
| if (decklistTextarea && latestEntry.deckList) { | |
| decklistTextarea.value = latestEntry.deckList; | |
| } | |
| // Fill commander names | |
| const commander1Input = document.getElementById('commander-name-1'); | |
| const commander2Input = document.getElementById('commander-name-2'); | |
| const commander3Input = document.getElementById('commander-name-3'); | |
| if (commander1Input && latestEntry.commanders.primary) { | |
| commander1Input.value = latestEntry.commanders.primary; | |
| } | |
| if (commander2Input && latestEntry.commanders.partner) { | |
| commander2Input.value = latestEntry.commanders.partner; | |
| } | |
| if (commander3Input && latestEntry.commanders.companion) { | |
| commander3Input.value = latestEntry.commanders.companion; | |
| } | |
| // Fill commander importance weights | |
| const weight1Select = document.getElementById('cmdr1-weight'); | |
| const weight2Select = document.getElementById('cmdr2-weight'); | |
| const weight3Select = document.getElementById('cmdr3-weight'); | |
| if (weight1Select && latestEntry.commanders.primaryWeight) { | |
| weight1Select.value = latestEntry.commanders.primaryWeight; | |
| } | |
| if (weight2Select && latestEntry.commanders.partnerWeight) { | |
| weight2Select.value = latestEntry.commanders.partnerWeight; | |
| } | |
| if (weight3Select && latestEntry.commanders.companionWeight) { | |
| weight3Select.value = latestEntry.commanders.companionWeight; | |
| } | |
| logDebug('History: Latest entry auto-loaded, triggering deck load...'); | |
| // Trigger deck load after a short delay to ensure DOM is ready | |
| setTimeout(() => { | |
| const loadButton = document.getElementById('deck-load-button'); | |
| if (loadButton && !loadButton.disabled) { | |
| logDebug('History: Clicking load button to trigger analyzers'); | |
| loadButton.click(); | |
| // The existing auto-analyzer will handle triggering both analyzers | |
| // after "Deck loaded!" appears. Duplicate detection prevents | |
| // re-adding the same entry to history. | |
| } else { | |
| logWarn('History: Deck load button not available or disabled'); | |
| } | |
| }, 500); // 500ms delay to ensure all elements are ready | |
| } else { | |
| logDebug('History: No history to auto-load'); | |
| } | |
| } catch (error) { | |
| logError('History: Error auto-loading latest entry:', error); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - ANALYZER COMPLETION DETECTION | |
| * ================================================================= | |
| */ | |
| /** | |
| * State Management for Analyzer Completion | |
| * | |
| * Track whether each analyzer has completed to trigger history capture | |
| * only when both analyzers finish. | |
| */ | |
| let colorAnalyzerComplete = false; | |
| let tapAnalyzerComplete = false; | |
| /** | |
| * Reset analyzer completion flags | |
| * | |
| * Call this when deck load or compute buttons are triggered to prepare | |
| * for the next analysis cycle. | |
| */ | |
| function resetAnalyzerFlags() { | |
| colorAnalyzerComplete = false; | |
| tapAnalyzerComplete = false; | |
| logDebug('History: Analyzer flags reset'); | |
| } | |
| /** | |
| * State flag to prevent duplicate captures | |
| */ | |
| let captureScheduled = false; | |
| /** | |
| * Check if both analyzers have completed and trigger history capture | |
| * | |
| * This function is called by both analyzer observers. It only triggers | |
| * the history capture once, when both analyzers report completion. | |
| */ | |
| function checkBothAnalyzersComplete() { | |
| if (colorAnalyzerComplete && tapAnalyzerComplete && !captureScheduled) { | |
| logDebug('History: Both analyzers complete, scheduling capture...'); | |
| captureScheduled = true; | |
| // Add a small delay to ensure DOM has fully updated with results | |
| setTimeout(() => { | |
| logDebug('History: Triggering capture after delay'); | |
| captureHistoryEntry(); | |
| // Reset flags for next analysis | |
| resetAnalyzerFlags(); | |
| captureScheduled = false; | |
| }, 500); // 500ms delay to ensure tables are populated | |
| } | |
| } | |
| /** | |
| * Setup MutationObservers for analyzer completion detection | |
| * | |
| * Watches both #color-analyzer-result-box and #tap-analyzer-result-box | |
| * for changes. When content appears/changes, marks that analyzer as complete. | |
| */ | |
| function setupAnalyzerCompletionObservers() { | |
| // Color Analyzer Observer | |
| const colorResultBox = document.getElementById('color-analyzer-result-box'); | |
| if (colorResultBox) { | |
| const colorObserver = new MutationObserver((mutations) => { | |
| try { | |
| // Check if there's actual content (not just empty or loading) | |
| const resultElement = document.getElementById('color-analyzer-result'); | |
| if (resultElement && resultElement.innerText.trim().length > 0) { | |
| if (!colorAnalyzerComplete) { | |
| colorAnalyzerComplete = true; | |
| logDebug('History: Color Analyzer completed'); | |
| checkBothAnalyzersComplete(); | |
| } | |
| } | |
| } catch (error) { | |
| logError('History: Error in Color Analyzer observer:', error); | |
| } | |
| }); | |
| colorObserver.observe(colorResultBox, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true | |
| }); | |
| logDebug('History: Color Analyzer observer initialized'); | |
| } else { | |
| logWarn('History: color-analyzer-result-box not found'); | |
| } | |
| // Tap Analyzer Observer | |
| const tapResultBox = document.getElementById('tap-analyzer-result-box'); | |
| if (tapResultBox) { | |
| const tapObserver = new MutationObserver((mutations) => { | |
| try { | |
| // Check if there's actual content (not just empty or loading) | |
| const resultElement = document.getElementById('tap-analyzer-result'); | |
| if (resultElement && resultElement.innerText.trim().length > 0) { | |
| if (!tapAnalyzerComplete) { | |
| tapAnalyzerComplete = true; | |
| logDebug('History: Tap Analyzer completed'); | |
| checkBothAnalyzersComplete(); | |
| } | |
| } | |
| } catch (error) { | |
| logError('History: Error in Tap Analyzer observer:', error); | |
| } | |
| }); | |
| tapObserver.observe(tapResultBox, { | |
| childList: true, | |
| subtree: true, | |
| characterData: true | |
| }); | |
| logDebug('History: Tap Analyzer observer initialized'); | |
| } else { | |
| logWarn('History: tap-analyzer-result-box not found'); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - STORAGE MODULE | |
| * ================================================================= | |
| */ | |
| const HISTORY_STORAGE_KEY = 'manabase-history'; | |
| const MAX_HISTORY_ENTRIES = 100; | |
| /** | |
| * Load history from localStorage | |
| * | |
| * @returns {Array} Array of history entries, or empty array if none exist | |
| */ | |
| function loadHistory() { | |
| try { | |
| const data = localStorage.getItem(HISTORY_STORAGE_KEY); | |
| if (!data) { | |
| logDebug('History: No existing history found'); | |
| return []; | |
| } | |
| const history = JSON.parse(data); | |
| // Validate that we got an array | |
| if (!Array.isArray(history)) { | |
| logWarn('History: Corrupted history data (not an array), resetting'); | |
| localStorage.removeItem(HISTORY_STORAGE_KEY); | |
| return []; | |
| } | |
| logDebug(`History: Loaded ${history.length} entries from storage`); | |
| return history; | |
| } catch (error) { | |
| logError('History: Error loading history:', error); | |
| logWarn('History: Clearing corrupted history data'); | |
| localStorage.removeItem(HISTORY_STORAGE_KEY); | |
| return []; | |
| } | |
| } | |
| /** | |
| * Save history to localStorage with FIFO enforcement | |
| * | |
| * @param {Array} historyArray - Array of history entries to save | |
| */ | |
| function saveHistory(historyArray) { | |
| try { | |
| // Enforce FIFO limit: keep only the last MAX_HISTORY_ENTRIES | |
| if (historyArray.length > MAX_HISTORY_ENTRIES) { | |
| historyArray = historyArray.slice(-MAX_HISTORY_ENTRIES); | |
| logDebug(`History: Trimmed to ${MAX_HISTORY_ENTRIES} entries (FIFO)`); | |
| } | |
| const jsonData = JSON.stringify(historyArray); | |
| // Check storage size (rough estimate: 5-10MB typical localStorage limit) | |
| const sizeKB = Math.round(jsonData.length / 1024); | |
| logDebug(`History: Saving ${historyArray.length} entries (${sizeKB} KB)`); | |
| localStorage.setItem(HISTORY_STORAGE_KEY, jsonData); | |
| logDebug('History: Successfully saved to localStorage'); | |
| } catch (error) { | |
| if (error.name === 'QuotaExceededError' || error.code === 22) { | |
| logError('History: localStorage quota exceeded'); | |
| // Emergency trim: keep only 50 most recent entries | |
| const trimmedHistory = historyArray.slice(-50); | |
| try { | |
| localStorage.setItem(HISTORY_STORAGE_KEY, JSON.stringify(trimmedHistory)); | |
| logWarn('History: Emergency trim to 50 entries successful'); | |
| } catch (e) { | |
| logError('History: Failed to save even after emergency trim:', e); | |
| localStorage.removeItem(HISTORY_STORAGE_KEY); | |
| } | |
| } else { | |
| logError('History: Error saving history:', error); | |
| } | |
| } | |
| } | |
| /** | |
| * Add a new history entry | |
| * | |
| * @param {Object} entry - History entry object to add | |
| */ | |
| function addHistoryEntry(entry) { | |
| try { | |
| const history = loadHistory(); | |
| // Check if deck is same as most recent entry | |
| if (history.length > 0) { | |
| const lastEntry = history[history.length - 1]; | |
| if (lastEntry.deckList === entry.deckList) { | |
| logDebug('History: Skipping duplicate entry (same deck as most recent)'); | |
| return; | |
| } | |
| } | |
| history.push(entry); | |
| saveHistory(history); | |
| logDebug('History: Entry added successfully'); | |
| } catch (error) { | |
| logError('History: Error adding history entry:', error); | |
| } | |
| } | |
| /** | |
| * Clear all history (executed after confirmation) | |
| */ | |
| function clearHistoryConfirmed() { | |
| try { | |
| logDebug('History: Clearing all history'); | |
| localStorage.removeItem(HISTORY_STORAGE_KEY); | |
| logDebug('History: localStorage cleared'); | |
| renderHistoryPanel(); // Refresh UI to show empty state | |
| logDebug('History: UI refreshed'); | |
| } catch (error) { | |
| logError('History: Error clearing history:', error); | |
| } | |
| } | |
| /** | |
| * Delete a single history entry | |
| * | |
| * @param {number} index - Index of the entry to delete | |
| */ | |
| function deleteHistoryEntry(index) { | |
| try { | |
| logDebug('History: Deleting entry at index', index); | |
| const history = loadHistory(); | |
| if (index < 0 || index >= history.length) { | |
| logWarn('History: Invalid index for deletion:', index); | |
| return; | |
| } | |
| // Remove the entry | |
| history.splice(index, 1); | |
| // Save updated history | |
| saveHistory(history); | |
| logDebug('History: Entry deleted, remaining entries:', history.length); | |
| // Refresh UI | |
| renderHistoryPanel(); | |
| } catch (error) { | |
| logError('History: Error deleting entry:', error); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - DIFF CALCULATION MODULE | |
| * ================================================================= | |
| */ | |
| /** | |
| * Parse deck list text into a Map of card names to quantities | |
| * | |
| * @param {string} deckText - Raw deck list text | |
| * @returns {Map<string, number>} Map of card names to quantities | |
| */ | |
| function parseDeckList(deckText) { | |
| const cards = new Map(); | |
| if (!deckText || typeof deckText !== 'string') { | |
| return cards; | |
| } | |
| const lines = deckText.trim().split('\n'); | |
| for (let line of lines) { | |
| line = line.trim(); | |
| // Skip empty lines | |
| if (!line) continue; | |
| // Match pattern: "4 Sol Ring" or "1 Tropical Island" | |
| // Also handles variations like "4x Sol Ring" | |
| const match = line.match(/^(\d+)x?\s+(.+)$/i); | |
| if (match) { | |
| const qty = parseInt(match[1], 10); | |
| const name = match[2].trim(); | |
| // Accumulate quantities if card appears multiple times | |
| cards.set(name, (cards.get(name) || 0) + qty); | |
| } | |
| } | |
| return cards; | |
| } | |
| /** | |
| * Calculate the difference between two deck lists | |
| * | |
| * @param {string} oldDeck - Previous deck list text (or null for first entry) | |
| * @param {string} newDeck - Current deck list text | |
| * @returns {Object} Object with diff array and changeCount | |
| */ | |
| function calculateDiff(oldDeck, newDeck) { | |
| // Handle first entry case (no previous deck) | |
| if (!oldDeck) { | |
| return { diff: [], changeCount: 0 }; | |
| } | |
| const oldCards = parseDeckList(oldDeck); | |
| const newCards = parseDeckList(newDeck); | |
| const changes = []; | |
| // Find removed or decreased cards | |
| for (let [card, oldQty] of oldCards) { | |
| const newQty = newCards.get(card) || 0; | |
| if (newQty < oldQty) { | |
| changes.push({ card, delta: newQty - oldQty }); // negative | |
| } | |
| } | |
| // Find added or increased cards | |
| for (let [card, newQty] of newCards) { | |
| const oldQty = oldCards.get(card) || 0; | |
| if (newQty > oldQty) { | |
| changes.push({ card, delta: newQty - oldQty }); // positive | |
| } | |
| } | |
| // Calculate total change count (sum of absolute deltas) | |
| const changeCount = changes.reduce((sum, change) => sum + Math.abs(change.delta), 0); | |
| logDebug(`History: Diff calculated - ${changes.length} card types changed, ${changeCount} total card changes`); | |
| return { diff: changes, changeCount }; | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - HISTORY CAPTURE ORCHESTRATOR | |
| * ================================================================= | |
| */ | |
| /** | |
| * Capture a complete history entry | |
| * | |
| * This is the main orchestrator function that: | |
| * 1. Extracts all data from the page | |
| * 2. Gets the current deck list | |
| * 3. Calculates diff vs previous entry | |
| * 4. Assembles the complete entry object | |
| * 5. Validates and saves the entry | |
| * 6. Updates the UI (when implemented) | |
| * | |
| * @returns {boolean} True if entry was captured successfully, false otherwise | |
| */ | |
| function captureHistoryEntry() { | |
| try { | |
| logDebug('History: ========== Starting capture =========='); | |
| // Get current deck list | |
| const decklistTextarea = document.getElementById('decklist'); | |
| if (!decklistTextarea || !decklistTextarea.value.trim()) { | |
| logWarn('History: No deck list found, skipping capture'); | |
| return false; | |
| } | |
| // Clean deck list (remove empty lines, trim) | |
| const rawDeckList = decklistTextarea.value; | |
| const cleanedLines = rawDeckList.split('\n') | |
| .map(line => line.trim()) | |
| .filter(line => line.length > 0); | |
| // Limit to 110 lines | |
| const deckList = cleanedLines.slice(0, 110).join('\n'); | |
| logDebug('History: Deck list has', cleanedLines.length, 'lines'); | |
| // Extract all metrics | |
| logDebug('History: Extracting manabase metrics...'); | |
| const manabase = extractManabaseMetrics(); | |
| logDebug('History: Extracting commander metrics...'); | |
| const commanders = extractCommanderMetrics(); | |
| logDebug('History: Extracting summary...'); | |
| const summary = extractSummary(); | |
| logDebug('History: Extracting tap percent...'); | |
| const tapPercent = extractTapPercent(); | |
| logDebug('History: Extracting basic lands...'); | |
| const basicLands = extractBasicLands(deckList); | |
| // Log what we extracted | |
| logDebug('History: Extracted data:', { | |
| manabase, | |
| commanders: { | |
| primary: commanders.primary, | |
| primaryWeight: commanders.primaryWeight, | |
| primaryCastRate: commanders.primaryCastRate | |
| }, | |
| summary: summary.substring(0, 50), | |
| tapPercent, | |
| basicLands | |
| }); | |
| // Validate that we have some meaningful data | |
| if (!manabase.castRate && !commanders.primary) { | |
| logWarn('History: Insufficient data for entry (no metrics or commander), skipping'); | |
| return false; | |
| } | |
| // Calculate diff vs previous entry (skip if commander changed) | |
| const history = loadHistory(); | |
| const previousEntry = history.length > 0 ? history[history.length - 1] : null; | |
| let diff = []; | |
| let changeCount = 0; | |
| // Only calculate diff if we have a previous entry AND commander hasn't changed | |
| if (previousEntry) { | |
| const previousCommander = previousEntry.commanders.primary || ''; | |
| const currentCommander = commanders.primary || ''; | |
| if (previousCommander.toLowerCase() === currentCommander.toLowerCase()) { | |
| // Same commander - calculate diff | |
| const previousDeckList = previousEntry.deckList; | |
| const diffResult = calculateDiff(previousDeckList, deckList); | |
| diff = diffResult.diff; | |
| changeCount = diffResult.changeCount; | |
| logDebug('History: Calculating diff (same commander)'); | |
| } else { | |
| // Different commander - skip diff | |
| logDebug('History: Skipping diff (commander changed from', previousCommander, 'to', currentCommander, ')'); | |
| } | |
| } else { | |
| // No previous entry | |
| logDebug('History: First entry, no diff to calculate'); | |
| } | |
| // Assemble complete entry | |
| const entry = { | |
| timestamp: new Date().toISOString(), | |
| deckList: deckList, | |
| commanders: commanders, | |
| manabase: manabase, | |
| summary: summary, | |
| tapPercent: tapPercent, | |
| basicLands: basicLands, | |
| diff: diff, | |
| changeCount: changeCount | |
| }; | |
| // Log entry details | |
| logDebug('History: Entry assembled:', { | |
| timestamp: entry.timestamp, | |
| commander: entry.commanders.primary, | |
| manabaseMetrics: entry.manabase, | |
| changes: entry.changeCount, | |
| summaryHTML: entry.summary.substring(0, 100) | |
| }); | |
| // Save entry | |
| addHistoryEntry(entry); | |
| // Update UI | |
| renderHistoryPanel(); | |
| logDebug('History: Capture completed successfully'); | |
| return true; | |
| } catch (error) { | |
| logError('History: Error capturing history entry:', error); | |
| return false; | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - UI MODULE | |
| * ================================================================= | |
| */ | |
| /** | |
| * Create the history panel and insert it into the page | |
| * | |
| * The panel spans the full page width and is shown immediately on page load. | |
| */ | |
| function createHistoryPanel() { | |
| try { | |
| // Check if panel already exists | |
| if (document.getElementById('history-panel')) { | |
| logDebug('History: Panel already exists'); | |
| return; | |
| } | |
| // Find the main container to insert the panel after all analyzers | |
| // Look for body or main content area | |
| const body = document.body; | |
| if (!body) { | |
| logWarn('History: Body not found, cannot create history panel'); | |
| return; | |
| } | |
| // Create panel container (full width, not constrained by .analyzer class) | |
| const panel = document.createElement('div'); | |
| panel.id = 'history-panel'; | |
| // Custom styling for full-width panel that clears floats | |
| panel.style.cssText = ` | |
| width: 100%; | |
| max-width: 1200px; | |
| margin: 30px auto; | |
| padding: 20px; | |
| background-color: #EEEEEE; | |
| border: 2px solid black; | |
| border-radius: 10px; | |
| box-sizing: border-box; | |
| clear: both; | |
| `; | |
| // Create panel HTML structure | |
| panel.innerHTML = ` | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 15px;"> | |
| <h2 style="margin: 0; font-size: 20px; color: #333;">Analysis History</h2> | |
| <div style="display: flex; gap: 10px;"> | |
| <input type="button" id="clear-history-btn" value="Clear History" | |
| style="width: auto; padding: 5px 15px; font-size: 13px; cursor: pointer;"> | |
| <input type="button" id="collapse-all-btn" value="Collapse All" | |
| style="width: auto; padding: 5px 15px; font-size: 13px; cursor: pointer;"> | |
| <input type="button" id="expand-all-btn" value="Expand All" | |
| style="width: auto; padding: 5px 15px; font-size: 13px; cursor: pointer;"> | |
| </div> | |
| </div> | |
| <div id="history-list"></div> | |
| `; | |
| // Append to column A if it exists, otherwise body | |
| const columnA = document.querySelector('.column-a'); | |
| const target = columnA || body; | |
| target.appendChild(panel); | |
| logDebug('History: Panel created and appended'); | |
| // Attach event listener to clear button with click-to-confirm pattern | |
| const clearButton = document.getElementById('clear-history-btn'); | |
| if (clearButton) { | |
| let confirmState = false; | |
| const originalValue = clearButton.value; | |
| clearButton.addEventListener('click', function (e) { | |
| logDebug('History: Clear button clicked, confirmState:', confirmState); | |
| if (!confirmState) { | |
| // First click - ask for confirmation | |
| confirmState = true; | |
| clearButton.value = 'Click Again to Confirm'; | |
| clearButton.style.backgroundColor = '#e74c3c'; | |
| clearButton.style.color = 'white'; | |
| logDebug('History: Waiting for confirmation click'); | |
| // Reset after 3 seconds if not clicked again | |
| setTimeout(() => { | |
| if (confirmState) { | |
| confirmState = false; | |
| clearButton.value = originalValue; | |
| clearButton.style.backgroundColor = ''; | |
| clearButton.style.color = ''; | |
| logDebug('History: Confirmation timeout, reset button'); | |
| } | |
| }, 3000); | |
| } else { | |
| // Second click - execute clear | |
| logDebug('History: Confirmed, clearing history'); | |
| confirmState = false; | |
| clearButton.value = originalValue; | |
| clearButton.style.backgroundColor = ''; | |
| clearButton.style.color = ''; | |
| clearHistoryConfirmed(); | |
| } | |
| }); | |
| logDebug('History: Clear button event listener attached'); | |
| } else { | |
| logWarn('History: Clear button not found, event listener not attached'); | |
| } | |
| // Attach event listener to expand all button | |
| const expandAllButton = document.getElementById('expand-all-btn'); | |
| if (expandAllButton) { | |
| expandAllButton.addEventListener('click', function (e) { | |
| logDebug('History: Expand all clicked'); | |
| const allHeaders = document.querySelectorAll('.history-entry-header'); | |
| allHeaders.forEach(header => { | |
| const details = header.nextElementSibling; | |
| const icon = header.querySelector('.expand-icon'); | |
| if (details && icon && details.style.display === 'none') { | |
| details.style.display = 'block'; | |
| icon.textContent = '▼'; | |
| } | |
| }); | |
| }); | |
| logDebug('History: Expand all button event listener attached'); | |
| } else { | |
| logWarn('History: Expand all button not found'); | |
| } | |
| // Attach event listener to collapse all button | |
| const collapseAllButton = document.getElementById('collapse-all-btn'); | |
| if (collapseAllButton) { | |
| collapseAllButton.addEventListener('click', function (e) { | |
| logDebug('History: Collapse all clicked'); | |
| const allHeaders = document.querySelectorAll('.history-entry-header'); | |
| allHeaders.forEach(header => { | |
| const details = header.nextElementSibling; | |
| const icon = header.querySelector('.expand-icon'); | |
| if (details && icon && details.style.display !== 'none') { | |
| details.style.display = 'none'; | |
| icon.textContent = '▶'; | |
| } | |
| }); | |
| }); | |
| logDebug('History: Collapse all button event listener attached'); | |
| } else { | |
| logWarn('History: Collapse all button not found'); | |
| } | |
| // Initial render - show history immediately | |
| renderHistoryPanel(); | |
| } catch (error) { | |
| logError('History: Error creating history panel:', error); | |
| } | |
| } | |
| /** | |
| * Render the history panel with all entries | |
| * | |
| * Displays entries in reverse chronological order (newest first). | |
| * Shows a message if no history exists yet. | |
| */ | |
| function renderHistoryPanel() { | |
| try { | |
| const listContainer = document.getElementById('history-list'); | |
| if (!listContainer) { | |
| logWarn('History: history-list container not found'); | |
| return; | |
| } | |
| const history = loadHistory(); | |
| if (history.length === 0) { | |
| listContainer.innerHTML = '<p style="color: #666; font-style: italic; padding: 10px;">No history entries yet. Load and analyze a deck to get started.</p>'; | |
| logDebug('History: No entries to display'); | |
| return; | |
| } | |
| // Render entries in reverse order (newest first) | |
| const entriesHTML = history.slice().reverse().map((entry, idx) => { | |
| const actualIndex = history.length - 1 - idx; | |
| return renderHistoryEntry(entry, actualIndex); | |
| }).join(''); | |
| listContainer.innerHTML = entriesHTML; | |
| // Fix summary HTML rendering (template literals may escape) | |
| // Re-insert summary content as proper HTML for each entry | |
| document.querySelectorAll('.history-summary').forEach((summaryDiv, idx) => { | |
| const entryIndex = parseInt(summaryDiv.dataset.index); | |
| const entry = history[entryIndex]; | |
| if (entry && entry.summary) { | |
| summaryDiv.innerHTML = entry.summary; | |
| } | |
| }); | |
| // Attach event listeners | |
| attachHistoryEventListeners(); | |
| logDebug(`History: Rendered ${history.length} entries`); | |
| } catch (error) { | |
| logError('History: Error rendering history panel:', error); | |
| } | |
| } | |
| /** | |
| * Render a single history entry | |
| * | |
| * @param {Object} entry - History entry object | |
| * @param {number} index - Index in the history array | |
| * @returns {string} HTML string for the entry | |
| */ | |
| function renderHistoryEntry(entry, index) { | |
| try { | |
| const date = new Date(entry.timestamp); | |
| const formattedDate = date.toLocaleString(); | |
| // Format commander display | |
| let commanderDisplay = entry.commanders.primary || 'Unknown'; | |
| if (entry.commanders.partner) { | |
| commanderDisplay += ' + ' + entry.commanders.partner; | |
| } | |
| if (entry.commanders.companion) { | |
| commanderDisplay += ' (' + entry.commanders.companion + ')'; | |
| } | |
| // Format diff text in a grid for better use of space | |
| let diffText = '<span style="color: #666; font-style: italic;">No changes</span>'; | |
| if (entry.diff && entry.diff.length > 0) { | |
| // Split into additions and removals for cleaner display | |
| const additions = entry.diff.filter(d => d.delta > 0); | |
| const removals = entry.diff.filter(d => d.delta < 0); | |
| let diffHTML = '<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 20px;">'; | |
| if (additions.length > 0) { | |
| diffHTML += '<div><strong style="color: green;">Added:</strong><br>'; | |
| diffHTML += additions.map(d => | |
| `<span style="color: green;">+${d.delta} ${d.card}</span>` | |
| ).join('<br>'); | |
| diffHTML += '</div>'; | |
| } | |
| if (removals.length > 0) { | |
| diffHTML += '<div><strong style="color: red;">Removed:</strong><br>'; | |
| diffHTML += removals.map(d => | |
| `<span style="color: red;">${d.delta} ${d.card}</span>` | |
| ).join('<br>'); | |
| diffHTML += '</div>'; | |
| } | |
| diffHTML += '</div>'; | |
| diffText = diffHTML; | |
| } | |
| // Build HTML with improved layout for full width | |
| return ` | |
| <div class="history-entry" data-index="${index}" | |
| style="border: 1px solid #ccc; border-radius: 5px; padding: 15px; margin-bottom: 12px; background: #fff; box-shadow: 0 1px 3px rgba(0,0,0,0.1);"> | |
| <div class="history-entry-header" style="cursor: pointer; user-select: none;"> | |
| <div style="display: grid; grid-template-columns: auto 1fr auto auto; gap: 20px; align-items: center;"> | |
| <span class="expand-icon" style="font-size: 14px;">▶</span> | |
| <div> | |
| <strong style="font-size: 15px;">${formattedDate}</strong><br> | |
| <span style="font-size: 13px; color: #555;">Commander: ${commanderDisplay}</span> | |
| </div> | |
| <div style="text-align: right;"> | |
| <span style="font-size: 13px; color: #555;">Tap: ${entry.tapPercent || '0.0'}%, AD: ${entry.manabase.avgDelay || 'N/A'}, CR: ${entry.manabase.castRate || 'N/A'}</span><br> | |
| <span style="font-size: 12px;">${formatBasicLandsShort(entry.basicLands)}</span> | |
| </div> | |
| <div style="text-align: right;"> | |
| <span style="font-size: 13px; color: ${entry.changeCount > 0 ? '#e67e22' : '#999'}; font-weight: ${entry.changeCount > 0 ? 'bold' : 'normal'};"> | |
| ${entry.changeCount || 0} cards changed | |
| </span> | |
| </div> | |
| </div> | |
| </div> | |
| <div class="history-entry-details" style="display: none; margin-top: 15px; padding-top: 15px; border-top: 1px solid #ddd;"> | |
| <div style="margin-bottom: 15px;"> | |
| <h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Manabase Performance</h4> | |
| <p style="margin: 5px 0; font-size: 13px; line-height: 1.4;"> | |
| Current Manabase: <strong>${entry.manabase.castRate || 'N/A'}</strong> cast rate / <strong>${entry.manabase.avgDelay || 'N/A'}</strong> average delay<br> | |
| Tap Statistics: <strong>${entry.tapPercent || '0.0'}%</strong> of lands enter tapped<br> | |
| Basic Lands: <strong>${formatBasicLandsFull(entry.basicLands)}</strong> | |
| </p> | |
| </div> | |
| <div style="margin-bottom: 15px;"> | |
| <h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Commander Metrics</h4> | |
| <p style="margin: 5px 0; font-size: 13px;">${entry.commanders.primary}: <strong>${entry.commanders.primaryCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.primaryAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.primaryWeight || 'N/A'}</strong>)</p> | |
| ${entry.commanders.partner ? `<p style="margin: 5px 0; font-size: 13px;">${entry.commanders.partner}: <strong>${entry.commanders.partnerCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.partnerAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.partnerWeight || 'N/A'}</strong>)</p>` : ''} | |
| ${entry.commanders.companion ? `<p style="margin: 5px 0; font-size: 13px;">${entry.commanders.companion}: <strong>${entry.commanders.companionCastRate || 'N/A'}</strong> cast rate / <strong>${entry.commanders.companionAvgDelay || 'N/A'}</strong> average delay (weight: <strong>${entry.commanders.companionWeight || 'N/A'}</strong>)</p>` : ''} | |
| </div> | |
| <div style="margin-bottom: 15px;"> | |
| <h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Summary</h4> | |
| <div class="history-summary" data-index="${index}" style="margin: 5px 0; font-size: 13px; line-height: 1.6;">${entry.summary || 'N/A'}</div> | |
| </div> | |
| <div style="margin-top: 15px;"> | |
| <h4 style="margin: 0 0 10px 0; font-size: 14px; color: #333;">Deck Changes</h4> | |
| ${diffText} | |
| </div> | |
| <div style="margin-top: 20px; display: flex; justify-content: space-between; align-items: center;"> | |
| <input type="button" class="delete-entry-button" data-index="${index}" value="Delete Entry" | |
| style="cursor: pointer; padding: 8px 20px; font-size: 14px; background: #e74c3c; color: white; border: none; border-radius: 4px; font-weight: bold;"> | |
| <input type="button" class="restore-button" data-index="${index}" value="Restore This Entry" | |
| style="cursor: pointer; padding: 8px 20px; font-size: 14px; background: #3498db; color: white; border: none; border-radius: 4px; font-weight: bold;"> | |
| </div> | |
| </div> | |
| </div> | |
| `; | |
| } catch (error) { | |
| logError('History: Error rendering entry:', error); | |
| return '<div style="color: red;">Error rendering entry</div>'; | |
| } | |
| } | |
| /** | |
| * Attach event listeners to history entries | |
| * | |
| * Sets up expand/collapse functionality and restore button handlers. | |
| */ | |
| function attachHistoryEventListeners() { | |
| try { | |
| // Expand/collapse entries | |
| document.querySelectorAll('.history-entry-header').forEach(header => { | |
| header.addEventListener('click', function () { | |
| const details = this.nextElementSibling; | |
| const icon = this.querySelector('.expand-icon'); | |
| if (details && icon) { | |
| if (details.style.display === 'none') { | |
| details.style.display = 'block'; | |
| icon.textContent = '▼'; | |
| } else { | |
| details.style.display = 'none'; | |
| icon.textContent = '▶'; | |
| } | |
| } | |
| }); | |
| }); | |
| // Restore buttons | |
| document.querySelectorAll('.restore-button').forEach(button => { | |
| button.addEventListener('click', function (e) { | |
| e.stopPropagation(); // Prevent header click | |
| const index = parseInt(this.dataset.index); | |
| restoreHistoryEntry(index); | |
| }); | |
| }); | |
| // Delete entry buttons | |
| document.querySelectorAll('.delete-entry-button').forEach(button => { | |
| button.addEventListener('click', function (e) { | |
| e.stopPropagation(); // Prevent header click | |
| const index = parseInt(this.dataset.index); | |
| // Confirm deletion | |
| const entry = loadHistory()[index]; | |
| if (entry) { | |
| const entryDate = new Date(entry.timestamp).toLocaleString(); | |
| const entryCommander = entry.commanders.primary || 'Unknown'; | |
| // Use click-to-confirm pattern (same as Clear History) | |
| if (this.dataset.confirmState === 'true') { | |
| // Second click - delete | |
| deleteHistoryEntry(index); | |
| this.dataset.confirmState = 'false'; | |
| } else { | |
| // First click - ask for confirmation | |
| this.dataset.confirmState = 'true'; | |
| this.value = 'Click Again to Confirm'; | |
| this.style.backgroundColor = '#c0392b'; | |
| // Reset after 3 seconds | |
| setTimeout(() => { | |
| this.dataset.confirmState = 'false'; | |
| this.value = 'Delete Entry'; | |
| this.style.backgroundColor = '#e74c3c'; | |
| }, 3000); | |
| } | |
| } | |
| }); | |
| }); | |
| logDebug('History: Event listeners attached'); | |
| } catch (error) { | |
| logError('History: Error attaching event listeners:', error); | |
| } | |
| } | |
| /** | |
| * Restore a history entry to the deck input and re-run analyzers | |
| * | |
| * @param {number} index - Index of the entry in the history array | |
| */ | |
| function restoreHistoryEntry(index) { | |
| try { | |
| const history = loadHistory(); | |
| const entry = history[index]; | |
| if (!entry) { | |
| logWarn('History: Entry not found at index', index); | |
| return; | |
| } | |
| logDebug('History: Restoring entry from', entry.timestamp); | |
| // Fill deck list | |
| const decklistTextarea = document.getElementById('decklist'); | |
| if (decklistTextarea) { | |
| decklistTextarea.value = entry.deckList; | |
| } | |
| // Fill commander names | |
| const commander1Input = document.getElementById('commander-name-1'); | |
| const commander2Input = document.getElementById('commander-name-2'); | |
| const commander3Input = document.getElementById('commander-name-3'); | |
| if (commander1Input) { | |
| commander1Input.value = entry.commanders.primary || ''; | |
| } | |
| if (commander2Input) { | |
| commander2Input.value = entry.commanders.partner || ''; | |
| } | |
| if (commander3Input) { | |
| commander3Input.value = entry.commanders.companion || ''; | |
| } | |
| // Fill commander importance weights | |
| const weight1Select = document.getElementById('cmdr1-weight'); | |
| const weight2Select = document.getElementById('cmdr2-weight'); | |
| const weight3Select = document.getElementById('cmdr3-weight'); | |
| if (weight1Select && entry.commanders.primaryWeight) { | |
| weight1Select.value = entry.commanders.primaryWeight; | |
| } | |
| if (weight2Select && entry.commanders.partnerWeight) { | |
| weight2Select.value = entry.commanders.partnerWeight; | |
| } | |
| if (weight3Select && entry.commanders.companionWeight) { | |
| weight3Select.value = entry.commanders.companionWeight; | |
| } | |
| // Scroll iframe to top to show what was restored | |
| // Note: Cannot scroll parent window due to cross-origin restrictions | |
| // (parent is salubrioussnail.com, iframe is ianrh125.github.io) | |
| window.scrollTo({ | |
| top: 0, | |
| behavior: 'smooth' | |
| }); | |
| logDebug('History: Scrolled iframe to top'); | |
| // Scroll column A (deck+history column) to top | |
| const columnA = document.querySelector('.column-a'); | |
| if (columnA) { | |
| columnA.scrollTo({ | |
| top: 0, | |
| behavior: 'smooth' | |
| }); | |
| logDebug('History: Scrolled column A to top'); | |
| } | |
| // Trigger deck load | |
| const loadButton = document.getElementById('deck-load-button'); | |
| if (loadButton && !loadButton.disabled) { | |
| logDebug('History: Triggering deck load...'); | |
| loadButton.click(); | |
| // The existing auto-analyzer will handle triggering both analyzers | |
| // after "Deck loaded!" appears | |
| } else { | |
| logWarn('History: Deck load button not available or disabled'); | |
| } | |
| } catch (error) { | |
| logError('History: Error restoring entry:', error); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * HISTORY FEATURE - DATA EXTRACTION MODULE | |
| * ================================================================= | |
| */ | |
| /** | |
| * Extract manabase metrics from the Color Analyzer results table | |
| * | |
| * @returns {Object} Object containing castRate and avgDelay, or empty strings if not found | |
| */ | |
| function extractManabaseMetrics() { | |
| try { | |
| // Actual table ID from website is 'color-test-table-0' | |
| const table = document.getElementById('color-test-table-0'); | |
| if (!table) { | |
| logWarn('History: color-test-table-0 not found'); | |
| return { castRate: '', avgDelay: '' }; | |
| } | |
| logDebug('History: color-test-table-0 found with', table.rows.length, 'rows'); | |
| // Find the row with "Current Manabase" | |
| let manabaseRow = null; | |
| for (let i = 0; i < table.rows.length; i++) { | |
| const row = table.rows[i]; | |
| if (row.cells[0] && row.cells[0].innerText.trim().toLowerCase().includes('current manabase')) { | |
| manabaseRow = row; | |
| logDebug('History: Found manabase row at index', i); | |
| break; | |
| } | |
| } | |
| if (!manabaseRow) { | |
| logWarn('History: Could not find "Current Manabase" row'); | |
| // Try row 1 as fallback (row 0 is header) | |
| if (table.rows.length > 1) { | |
| manabaseRow = table.rows[1]; | |
| logDebug('History: Using fallback row 1'); | |
| } else { | |
| return { castRate: '', avgDelay: '' }; | |
| } | |
| } | |
| if (!manabaseRow.cells || manabaseRow.cells.length < 3) { | |
| logWarn('History: Insufficient cells in manabase row, found', manabaseRow.cells.length); | |
| return { castRate: '', avgDelay: '' }; | |
| } | |
| const castRate = manabaseRow.cells[1]?.innerText?.trim() || ''; | |
| const avgDelay = manabaseRow.cells[2]?.innerText?.trim() || ''; | |
| logDebug('History: Extracted manabase metrics:', { castRate, avgDelay }); | |
| return { castRate, avgDelay }; | |
| } catch (error) { | |
| logError('History: Error extracting manabase metrics:', error); | |
| return { castRate: '', avgDelay: '' }; | |
| } | |
| } | |
| /** | |
| * Extract commander importance weights from dropdowns | |
| * | |
| * @returns {Object} Object containing weight values for each commander slot | |
| */ | |
| function extractCommanderWeights() { | |
| try { | |
| const weight1 = document.getElementById('cmdr1-weight'); | |
| const weight2 = document.getElementById('cmdr2-weight'); | |
| const weight3 = document.getElementById('cmdr3-weight'); | |
| return { | |
| primaryWeight: weight1 ? weight1.value : '', | |
| partnerWeight: weight2 ? weight2.value : '', | |
| companionWeight: weight3 ? weight3.value : '' | |
| }; | |
| } catch (error) { | |
| logError('History: Error extracting commander weights:', error); | |
| return { primaryWeight: '', partnerWeight: '', companionWeight: '' }; | |
| } | |
| } | |
| /** | |
| * Extract commander metrics from the Color Analyzer card table | |
| * | |
| * @returns {Object} Object containing commander names, weights, cast rates, and delays | |
| */ | |
| function extractCommanderMetrics() { | |
| try { | |
| // Get commander names | |
| const commander1Input = document.getElementById('commander-name-1'); | |
| const commander2Input = document.getElementById('commander-name-2'); | |
| const commander3Input = document.getElementById('commander-name-3'); | |
| const commanderName1 = commander1Input ? commander1Input.value.trim() : ''; | |
| const commanderName2 = commander2Input ? commander2Input.value.trim() : ''; | |
| const commanderName3 = commander3Input ? commander3Input.value.trim() : ''; | |
| logDebug('History: Commander names:', { commanderName1, commanderName2, commanderName3 }); | |
| // Get commander weights | |
| const weights = extractCommanderWeights(); | |
| // Initialize commander metrics object | |
| const commanders = { | |
| primary: commanderName1, | |
| primaryWeight: weights.primaryWeight, | |
| partner: commanderName2, | |
| partnerWeight: weights.partnerWeight, | |
| companion: commanderName3, | |
| companionWeight: weights.companionWeight, | |
| primaryCastRate: '', | |
| primaryAvgDelay: '', | |
| partnerCastRate: '', | |
| partnerAvgDelay: '', | |
| companionCastRate: '', | |
| companionAvgDelay: '' | |
| }; | |
| // Extract cast rates and delays from card table | |
| // Actual table ID from website is 'color-test-table-1' | |
| const cardTable = document.getElementById('color-test-table-1'); | |
| if (!cardTable) { | |
| logWarn('History: color-test-table-1 not found'); | |
| return commanders; | |
| } | |
| logDebug('History: color-test-table-1 found with', cardTable.rows.length, 'rows'); | |
| for (let i = 0; i < cardTable.rows.length; i++) { | |
| const row = cardTable.rows[i]; | |
| if (!row.cells || row.cells.length < 4) { | |
| logDebug('History: Skipping row', i, 'insufficient cells'); | |
| continue; | |
| } | |
| const cardName = row.cells[0]?.innerText?.trim() || ''; | |
| // Debug log for first few rows | |
| if (i < 5) { | |
| logDebug('History: Row', i, 'card:', cardName, 'cells:', row.cells.length); | |
| } | |
| // Helper function to match commander names (handles truncated names like "Karlov of the Ghost...") | |
| const matchesCommander = (fullName, tableName) => { | |
| const fullLower = fullName.toLowerCase(); | |
| const tableLower = tableName.toLowerCase(); | |
| // Exact match | |
| if (fullLower === tableLower) return true; | |
| // Table name includes full name (normal case) | |
| if (tableLower.includes(fullLower)) return true; | |
| // Table name is truncated (ends with "..."), check if full name starts with truncated part | |
| if (tableLower.endsWith('...')) { | |
| const truncatedPart = tableLower.replace(/\.\.\.+$/, '').trim(); | |
| if (fullLower.startsWith(truncatedPart)) return true; | |
| } | |
| return false; | |
| }; | |
| // Match primary commander | |
| if (commanderName1 && matchesCommander(commanderName1, cardName)) { | |
| commanders.primaryCastRate = row.cells[2]?.innerText?.trim() || ''; | |
| commanders.primaryAvgDelay = row.cells[3]?.innerText?.trim() || ''; | |
| logDebug('History: Found primary commander metrics:', commanders.primaryCastRate, commanders.primaryAvgDelay); | |
| } | |
| // Match partner | |
| if (commanderName2 && matchesCommander(commanderName2, cardName)) { | |
| commanders.partnerCastRate = row.cells[2]?.innerText?.trim() || ''; | |
| commanders.partnerAvgDelay = row.cells[3]?.innerText?.trim() || ''; | |
| logDebug('History: Found partner metrics:', commanders.partnerCastRate, commanders.partnerAvgDelay); | |
| } | |
| // Match companion | |
| if (commanderName3 && matchesCommander(commanderName3, cardName)) { | |
| commanders.companionCastRate = row.cells[2]?.innerText?.trim() || ''; | |
| commanders.companionAvgDelay = row.cells[3]?.innerText?.trim() || ''; | |
| logDebug('History: Found companion metrics:', commanders.companionCastRate, commanders.companionAvgDelay); | |
| } | |
| } | |
| logDebug('History: Final commander metrics:', commanders); | |
| return commanders; | |
| } catch (error) { | |
| logError('History: Error extracting commander metrics:', error); | |
| return { | |
| primary: '', primaryWeight: '', partner: '', partnerWeight: '', | |
| companion: '', companionWeight: '', primaryCastRate: '', | |
| primaryAvgDelay: '', partnerCastRate: '', partnerAvgDelay: '', | |
| companionCastRate: '', companionAvgDelay: '' | |
| }; | |
| } | |
| } | |
| /** | |
| * Extract summary text from Color Analyzer results | |
| * | |
| * @returns {string} Summary text or empty string if not found | |
| */ | |
| function extractSummary() { | |
| try { | |
| const resultElement = document.getElementById('color-analyzer-result'); | |
| if (!resultElement) { | |
| logWarn('History: color-analyzer-result element not found'); | |
| return ''; | |
| } | |
| const resultHTML = resultElement.innerHTML; | |
| logDebug('History: color-analyzer-result HTML length:', resultHTML.length); | |
| // Extract everything after "<strong>Summary:</strong><br>" | |
| // Keep the HTML formatting (especially <strong> tags) | |
| const summaryMatch = resultHTML.match(/<strong>Summary:<\/strong><br\s*\/?>(.*?)$/is); | |
| if (!summaryMatch || !summaryMatch[1]) { | |
| logWarn('History: Could not extract summary with regex'); | |
| return ''; | |
| } | |
| let summary = summaryMatch[1].trim(); | |
| // Clean up whitespace between tags but preserve HTML | |
| summary = summary.replace(/>\s+</g, '><'); | |
| summary = summary.replace(/\s+/g, ' '); | |
| logDebug('History: Extracted summary (with HTML):', summary.substring(0, 100) + (summary.length > 100 ? '...' : '')); | |
| return summary; | |
| } catch (error) { | |
| logError('History: Error extracting summary:', error); | |
| return ''; | |
| } | |
| } | |
| /** | |
| * Extract tap percentage from Tap Analyzer results | |
| * | |
| * @returns {string} Tap percentage or '0.0' if not found | |
| */ | |
| function extractTapPercent() { | |
| try { | |
| const tapResult = document.getElementById('tap-analyzer-result'); | |
| if (!tapResult) { | |
| logWarn('History: tap-analyzer-result element not found'); | |
| return '0.0'; | |
| } | |
| const tapHTML = tapResult.innerHTML; | |
| // Look for pattern: "enter tapped <strong>XX.X%" | |
| const tapMatch = tapHTML.match(/enter tapped <strong>([\d.]+)%/); | |
| const tapPercent = tapMatch ? tapMatch[1] : '0.0'; | |
| logDebug('History: Extracted tap percent:', tapPercent); | |
| return tapPercent; | |
| } catch (error) { | |
| logError('History: Error extracting tap percent:', error); | |
| return '0.0'; | |
| } | |
| } | |
| /** | |
| * Extract basic land counts from deck list | |
| * | |
| * @param {string} deckList - The deck list text | |
| * @returns {Object} Object with counts for each basic land type | |
| */ | |
| function extractBasicLands(deckList) { | |
| try { | |
| const basicLands = { | |
| W: 0, // Plains | |
| U: 0, // Island | |
| B: 0, // Swamp | |
| R: 0, // Mountain | |
| G: 0, // Forest | |
| C: 0 // Wastes | |
| }; | |
| if (!deckList || typeof deckList !== 'string') { | |
| return basicLands; | |
| } | |
| const lines = deckList.trim().split('\n'); | |
| for (let line of lines) { | |
| line = line.trim(); | |
| if (!line) continue; | |
| // Match pattern: "4 Plains" or "4x Plains" | |
| const match = line.match(/^(\d+)x?\s+(.+)$/i); | |
| if (!match) continue; | |
| const qty = parseInt(match[1], 10); | |
| const cardName = match[2].trim().toLowerCase(); | |
| // Check for basic lands (case-insensitive) | |
| if (cardName === 'plains') basicLands.W += qty; | |
| else if (cardName === 'island') basicLands.U += qty; | |
| else if (cardName === 'swamp') basicLands.B += qty; | |
| else if (cardName === 'mountain') basicLands.R += qty; | |
| else if (cardName === 'forest') basicLands.G += qty; | |
| else if (cardName === 'wastes') basicLands.C += qty; | |
| } | |
| logDebug('History: Extracted basic lands:', basicLands); | |
| return basicLands; | |
| } catch (error) { | |
| logError('History: Error extracting basic lands:', error); | |
| return { W: 0, U: 0, B: 0, R: 0, G: 0, C: 0 }; | |
| } | |
| } | |
| /** | |
| * Format basic lands for display in header | |
| * | |
| * @param {Object} basicLands - Object with W, U, B, R, G, C counts | |
| * @returns {string} Formatted string like "W3 | U3 | B2" | |
| */ | |
| function formatBasicLandsShort(basicLands) { | |
| if (!basicLands) return ''; | |
| const colorMap = { | |
| W: '#F0E68C', // Pale yellow/gold for white | |
| U: '#4A90E2', // Blue | |
| B: '#9B59B6', // Purple for black | |
| R: '#D94B3D', // Red | |
| G: '#50A050', // Green | |
| C: '#999999' // Gray for colorless | |
| }; | |
| const parts = []; | |
| const colors = ['W', 'U', 'B', 'R', 'G', 'C']; | |
| for (let color of colors) { | |
| if (basicLands[color] > 0) { | |
| parts.push(`<span style="color: ${colorMap[color]}; font-weight: bold;">${color}</span><span style="font-weight: bold;">${basicLands[color]}</span>`); | |
| } | |
| } | |
| return parts.length > 0 ? parts.join(' | ') : 'No basics'; | |
| } | |
| /** | |
| * Format basic lands for display in expandable section | |
| * | |
| * @param {Object} basicLands - Object with W, U, B, R, G, C counts | |
| * @returns {string} Formatted string like "3 Plains, 3 Islands, 2 Swamps" | |
| */ | |
| function formatBasicLandsFull(basicLands) { | |
| if (!basicLands) return 'None'; | |
| const names = { | |
| W: 'Plains', | |
| U: 'Islands', | |
| B: 'Swamps', | |
| R: 'Mountains', | |
| G: 'Forests', | |
| C: 'Wastes' | |
| }; | |
| const colorMap = { | |
| W: '#F0E68C', // Pale yellow/gold for white | |
| U: '#4A90E2', // Blue | |
| B: '#9B59B6', // Purple for black | |
| R: '#D94B3D', // Red | |
| G: '#50A050', // Green | |
| C: '#999999' // Gray for colorless | |
| }; | |
| const parts = []; | |
| const colors = ['W', 'U', 'B', 'R', 'G', 'C']; | |
| for (let color of colors) { | |
| if (basicLands[color] > 0) { | |
| parts.push(`<span style="font-weight: bold;">${basicLands[color]} <span style="color: ${colorMap[color]};">${names[color]}</span></span>`); | |
| } | |
| } | |
| return parts.length > 0 ? parts.join(', ') : 'None'; | |
| } | |
| /** | |
| * ================================================================= | |
| * OPTIMIZER PANEL | |
| * ================================================================= | |
| */ | |
| /** | |
| * Analyze deck complexity from website's global variables | |
| * Returns { deckColors, totalBasics } | |
| */ | |
| function analyzeDeckComplexity() { | |
| // Get deck info from the website's global variables (set by loadDict) | |
| const deckColors = typeof window.deckColors !== 'undefined' ? window.deckColors : 0; | |
| // Count basic lands from deck text for complexity check | |
| // (website doesn't expose a basic-only count, we need to calculate it) | |
| const deckTextarea = document.getElementById('deck-list'); | |
| const deckText = deckTextarea?.value || ''; | |
| const lines = deckText.split('\n'); | |
| let totalBasics = 0; | |
| for (const line of lines) { | |
| const trimmed = line.trim().toLowerCase(); | |
| const match = trimmed.match(/^(\d+)\s+(.+)$/); | |
| if (!match) continue; | |
| const qty = parseInt(match[1]); | |
| const card = match[2]; | |
| // Count basic lands | |
| if (['plains', 'island', 'swamp', 'mountain', 'forest', 'wastes'].includes(card)) { | |
| totalBasics += qty; | |
| } | |
| } | |
| logDebug(`Deck complexity: ${deckColors} colors, ${totalBasics} basics`); | |
| return { deckColors, totalBasics }; | |
| } | |
| /** | |
| * Update color analyzer complexity warning (approx-config div) based on deck properties | |
| */ | |
| function updateColorAnalyzerComplexityWarning(complexity) { | |
| const approxConfigDiv = document.getElementById('approx-config'); | |
| if (!approxConfigDiv) return; | |
| const { deckColors } = complexity; | |
| // Show/hide the approx-config warning based on color count | |
| if (deckColors >= 5) { | |
| approxConfigDiv.hidden = false; | |
| logDebug('Color Analyzer: Showing approx-config for 5-color deck'); | |
| } else { | |
| approxConfigDiv.hidden = true; | |
| logDebug('Color Analyzer: Hiding approx-config'); | |
| } | |
| } | |
| /** | |
| * Update optimizer complexity warning based on deck properties | |
| */ | |
| function updateOptimizerComplexityWarning(complexity) { | |
| const warningDiv = document.getElementById('optimizer-complexity-warning'); | |
| if (!warningDiv) return; | |
| const { deckColors, totalBasics } = complexity; | |
| // Show/hide warning based on complexity | |
| const isComplex = deckColors >= 4 || totalBasics >= 15; | |
| if (isComplex) { | |
| let message = ''; | |
| if (deckColors >= 5 && totalBasics >= 15) { | |
| message = `⚠️ <strong>Warning:</strong> 5-color deck with many basics (${totalBasics}) will take several minutes to optimize. You can cancel at any time.`; | |
| } else if (deckColors >= 5) { | |
| message = `⚠️ <strong>Warning:</strong> 5-color decks require complex calculations and may take several minutes. You can cancel at any time.`; | |
| } else if (deckColors >= 4 && totalBasics >= 15) { | |
| message = `⚠️ <strong>Warning:</strong> 4+ color deck with many basics (${totalBasics}) may take a few minutes to optimize.`; | |
| } else if (deckColors >= 4) { | |
| message = `⚠️ <strong>Warning:</strong> 4+ color decks require more calculations. May take a minute or two.`; | |
| } else if (totalBasics >= 15) { | |
| message = `⚠️ <strong>Warning:</strong> Large number of basics (${totalBasics}) increases optimization time.`; | |
| } | |
| warningDiv.innerHTML = message; | |
| warningDiv.style.display = 'block'; | |
| logDebug(`Optimizer: Showing complexity warning (${deckColors} colors, ${totalBasics} basics)`); | |
| } else { | |
| warningDiv.style.display = 'none'; | |
| logDebug('Optimizer: Hiding complexity warning'); | |
| } | |
| } | |
| function createOptimizerPanel() { | |
| try { | |
| // Check if panel already exists | |
| if (document.getElementById('optimizer-panel')) { | |
| logDebug('Optimizer: Panel already exists'); | |
| return; | |
| } | |
| const body = document.body; | |
| if (!body) { | |
| logWarn('Optimizer: Body not found, cannot create panel'); | |
| return; | |
| } | |
| // Create panel matching .analyzer class styling | |
| const panel = document.createElement('div'); | |
| panel.id = 'optimizer-panel'; | |
| panel.className = 'analyzer'; | |
| // Override float and width for full-width display | |
| panel.style.cssText = ` | |
| width: 100%; | |
| max-width: 1200px; | |
| float: none; | |
| margin: 15px auto; | |
| clear: both; | |
| `; | |
| panel.innerHTML = ` | |
| <div style="display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px;"> | |
| <h2 style="margin: 0; font-size: 18px; font-weight: bold;">Basic Land Optimizer</h2> | |
| <div style="display: flex; gap: 10px; align-items: center;"> | |
| <label id="optimizer-timeout-label" style="font-size: 13px; color: #555;"> | |
| Timeout (seconds): | |
| <input type="number" id="optimizer-timeout" value="60" min="10" max="600" step="10" | |
| style="width: 70px; padding: 3px; margin-left: 5px;" title="Maximum time to run optimization" disabled> | |
| </label> | |
| <input type="button" id="optimize-lands-btn" value="Optimize Basic Lands" disabled> | |
| </div> | |
| </div> | |
| <div id="optimizer-status" style="margin-bottom: 10px; font-size: 14px; color: #888;">Load a deck to enable optimizer</div> | |
| <div id="optimizer-complexity-warning" style="display: none; margin-bottom: 10px; padding: 10px; background-color: #fff3cd; border: 1px solid #ffc107; border-radius: 4px; font-size: 14px; color: #856404;"></div> | |
| <div id="optimizer-results" style="display: none;"></div> | |
| `; | |
| // Append to column C if it exists, otherwise body | |
| const columnC = window.columnC || body; | |
| columnC.appendChild(panel); | |
| logInfo('Optimizer: Panel created'); | |
| // Attach optimize button handler | |
| const optimizeBtn = document.getElementById('optimize-lands-btn'); | |
| if (optimizeBtn) { | |
| optimizeBtn.addEventListener('click', handleOptimizeClick); | |
| } | |
| } catch (error) { | |
| logError('Optimizer: Error creating panel:', error); | |
| } | |
| } | |
| async function handleOptimizeClick(event) { | |
| console.log('Optimizer: Button clicked'); | |
| const optimizeBtn = document.getElementById('optimize-lands-btn'); | |
| // If button says "Cancel", it means we're already running - the onclick handler will deal with it | |
| if (optimizeBtn && optimizeBtn.value === 'Cancel Optimization') { | |
| console.log('Optimizer: Cancel button clicked, onclick handler will process'); | |
| return; // Let the onclick handler process the cancellation | |
| } | |
| console.log('ManabaseOptimizer version:', ManabaseOptimizer.version || 'unknown'); | |
| // Get timeout from input (defined here so error handler can access it) | |
| const timeoutInput = document.getElementById('optimizer-timeout'); | |
| const timeoutSeconds = timeoutInput ? parseInt(timeoutInput.value) : 60; | |
| try { | |
| const statusDiv = document.getElementById('optimizer-status'); | |
| const resultsDiv = document.getElementById('optimizer-results'); | |
| console.log('Optimizer: Elements found', { statusDiv, resultsDiv, optimizeBtn }); | |
| // Extract deck data | |
| const deckListElem = document.getElementById('decklist'); | |
| if (!deckListElem || !deckListElem.value.trim()) { | |
| statusDiv.textContent = '❌ No deck list found. Please load a deck first.'; | |
| statusDiv.style.color = '#d32f2f'; | |
| return; | |
| } | |
| const deckText = deckListElem.value; | |
| console.log('Optimizer: Deck text length:', deckText.length); | |
| // Extract commanders | |
| const commanders = []; | |
| const cmdr1 = document.getElementById('commander-name-1'); | |
| const cmdr2 = document.getElementById('commander-name-2'); | |
| const cmdr3 = document.getElementById('commander-name-3'); | |
| if (cmdr1 && cmdr1.value.trim()) commanders.push(cmdr1.value.trim()); | |
| if (cmdr2 && cmdr2.value.trim()) commanders.push(cmdr2.value.trim()); | |
| if (cmdr3 && cmdr3.value.trim()) commanders.push(cmdr3.value.trim()); | |
| // Extract commander weights from dropdowns | |
| const cmdr1WeightElem = document.getElementById('cmdr1-weight'); | |
| const cmdr2WeightElem = document.getElementById('cmdr2-weight'); | |
| const cmdr3WeightElem = document.getElementById('cmdr3-weight'); | |
| const cmdr1Weight = cmdr1WeightElem ? parseInt(cmdr1WeightElem.selectedOptions[0].value) : 30; | |
| const cmdr2Weight = cmdr2WeightElem ? parseInt(cmdr2WeightElem.selectedOptions[0].value) : 30; | |
| const cmdr3Weight = cmdr3WeightElem ? parseInt(cmdr3WeightElem.selectedOptions[0].value) : 15; | |
| // Extract approx colors and samples from page | |
| const approxColorsElem = document.getElementById('approx-colors'); | |
| const approxSamplesElem = document.getElementById('approx-samples'); | |
| const approxColors = approxColorsElem ? parseFloat(approxColorsElem.value) || 5 : 5; | |
| const approxSamples = approxSamplesElem ? parseFloat(approxSamplesElem.value) || 100000 : 100000; | |
| // Count basic lands | |
| const lines = deckText.split('\n'); | |
| const startingLands = { w: 0, u: 0, b: 0, r: 0, g: 0, c: 0 }; | |
| for (const line of lines) { | |
| const trimmed = line.trim().toLowerCase(); | |
| const match = trimmed.match(/^(\d+)\s+(.+)$/); | |
| if (!match) continue; | |
| const qty = parseInt(match[1]); | |
| const card = match[2]; | |
| if (card === 'plains') startingLands.w += qty; | |
| else if (card === 'island') startingLands.u += qty; | |
| else if (card === 'swamp') startingLands.b += qty; | |
| else if (card === 'mountain') startingLands.r += qty; | |
| else if (card === 'forest') startingLands.g += qty; | |
| else if (card === 'wastes') startingLands.c += qty; | |
| } | |
| const totalBasics = Object.values(startingLands).reduce((a, b) => a + b, 0); | |
| console.log('Optimizer: Starting lands:', startingLands, 'total:', totalBasics); | |
| if (totalBasics === 0) { | |
| statusDiv.textContent = '⚠️ No basic lands found in deck.'; | |
| statusDiv.style.color = '#ff9800'; | |
| resultsDiv.style.display = 'none'; | |
| return; | |
| } | |
| // Get ignore/discount list from textarea | |
| const ignoreTextarea = document.getElementById('ignore'); | |
| const ignoreList = ignoreTextarea ? ignoreTextarea.value : ''; | |
| console.log('Optimizer: Ignore/discount list:', ignoreList ? ignoreList.split('\n').length + ' entries' : 'none'); | |
| // Calculate timeout in milliseconds | |
| const timeoutMs = timeoutSeconds * 1000; | |
| // Create AbortController for cancellation | |
| const abortController = new AbortController(); | |
| // Timer for elapsed time display | |
| const startTime = Date.now(); | |
| let timerInterval = null; | |
| const updateTimer = () => { | |
| const elapsed = Math.floor((Date.now() - startTime) / 1000); | |
| const remaining = Math.max(0, timeoutSeconds - elapsed); | |
| statusDiv.innerHTML = `⏳ Optimizing... Elapsed: ${elapsed}s / Timeout: ${timeoutSeconds}s (${remaining}s remaining)`; | |
| }; | |
| // Change button to Cancel with red styling, hide timeout controls | |
| optimizeBtn.disabled = false; // Enable so it can be clicked to cancel | |
| optimizeBtn.value = 'Cancel Optimization'; | |
| optimizeBtn.style.backgroundColor = '#e74c3c'; | |
| optimizeBtn.style.color = 'white'; | |
| // Hide and disable timeout controls during optimization | |
| if (timeoutInput) { | |
| timeoutInput.disabled = true; | |
| timeoutInput.style.display = 'none'; | |
| } | |
| const timeoutLabel = document.getElementById('optimizer-timeout-label'); | |
| if (timeoutLabel) timeoutLabel.style.display = 'none'; | |
| optimizeBtn.onclick = () => { | |
| console.log('Optimizer: User cancelled optimization'); | |
| if (timerInterval) clearInterval(timerInterval); | |
| statusDiv.textContent = '⏹️ Cancelling...'; | |
| statusDiv.style.color = '#ff9800'; | |
| abortController.abort(); | |
| }; | |
| // Get testDict from page if available (global var from website) | |
| // Note: Even with @grant none, we're in page context so can access directly | |
| let testDict = null; | |
| try { | |
| if (typeof window.testDict !== 'undefined' && window.testDict) { | |
| testDict = window.testDict; | |
| } | |
| } catch (e) { | |
| console.warn('Optimizer: Could not access testDict:', e); | |
| } | |
| console.log('Optimizer: testDict available:', testDict ? Object.keys(testDict).length + ' cards' : 'none'); | |
| // Show appropriate loading message | |
| if (testDict) { | |
| statusDiv.textContent = '⏳ Using loaded deck data...'; | |
| } else { | |
| statusDiv.textContent = '⏳ Loading cards from Scryfall...'; | |
| } | |
| statusDiv.style.color = '#555'; | |
| resultsDiv.style.display = 'none'; | |
| // Start timer once optimization begins | |
| timerInterval = setInterval(updateTimer, 1000); | |
| console.log('Optimizer: About to call optimizeLands, ManabaseOptimizer:', typeof ManabaseOptimizer); | |
| console.log('Optimizer: optimizeLands function:', typeof ManabaseOptimizer.optimizeLands); | |
| // Run optimization with timeout | |
| // Use setTimeout to ensure we release the event loop before starting heavy work | |
| let result; | |
| try { | |
| await new Promise(resolve => setTimeout(resolve, 10)); | |
| const optimizePromise = ManabaseOptimizer.optimizeLands({ | |
| deckList: deckText, | |
| commanders: commanders, | |
| startingLands: startingLands, | |
| ignoreList: ignoreList, | |
| options: { | |
| topN: 5, | |
| maxIterations: 50, | |
| testDict: testDict, | |
| signal: abortController.signal, | |
| calculatorOptions: { | |
| approxColors: approxColors, | |
| approxSamples: approxSamples, | |
| cmdr1Weight: cmdr1Weight, | |
| cmdr2Weight: commanders.length >= 2 ? cmdr2Weight : undefined, | |
| cmdr3Weight: commanders.length >= 3 ? cmdr3Weight : undefined | |
| }, | |
| onProgress: (current, total, status, topResults) => { | |
| console.log('Optimizer progress:', status); | |
| // Display live top 5 results asynchronously | |
| Promise.resolve().then(() => { | |
| if (topResults && topResults.length > 0) { | |
| displayOptimizerResults({ results: topResults, statistics: { tested: current, improved: 0, duration: 0 } }, startingLands); | |
| resultsDiv.style.display = 'block'; | |
| } | |
| }); | |
| } | |
| } | |
| }); | |
| const timeoutPromise = new Promise((_, reject) => { | |
| const timeoutId = setTimeout(() => { | |
| console.log('Optimizer: Timeout reached, aborting...'); | |
| if (timerInterval) clearInterval(timerInterval); | |
| statusDiv.textContent = '⏱️ Timeout reached, stopping...'; | |
| statusDiv.style.color = '#ff9800'; | |
| abortController.abort(); | |
| reject(new Error(`Optimization timed out after ${timeoutSeconds}s`)); | |
| }, timeoutMs); | |
| // Clean up timeout if optimization finishes first | |
| optimizePromise.finally(() => clearTimeout(timeoutId)); | |
| }); | |
| result = await Promise.race([optimizePromise, timeoutPromise]); | |
| console.log('Optimizer: Got result:', result); | |
| // Clear timer on success | |
| if (timerInterval) clearInterval(timerInterval); | |
| } catch (innerError) { | |
| console.error('Optimizer: Error in optimizeLands call:', innerError); | |
| if (timerInterval) clearInterval(timerInterval); | |
| throw innerError; | |
| } | |
| // Display results | |
| displayOptimizerResults(result, startingLands); | |
| statusDiv.textContent = `✅ Optimization complete! Tested ${result.statistics.tested} configurations in ${(result.statistics.duration / 1000).toFixed(1)}s`; | |
| statusDiv.style.color = '#2e7d32'; | |
| } catch (error) { | |
| console.error('Optimizer: Error during optimization:', error); | |
| console.error('Optimizer: Error stack:', error.stack); | |
| logError('Optimizer: Error during optimization:', error); | |
| const statusDiv = document.getElementById('optimizer-status'); | |
| // Check if it's a cancellation or timeout (user actions, not errors) | |
| const isCancelled = error.message.includes('cancel') || error.message.includes('abort'); | |
| const isTimeout = error.message.includes('timed out'); | |
| if (statusDiv) { | |
| if (isCancelled) { | |
| console.log('Optimizer: Setting cancelled status'); | |
| statusDiv.textContent = '⏹️ Optimization cancelled'; | |
| statusDiv.style.color = '#ff9800'; // Orange for cancelled | |
| } else if (isTimeout) { | |
| console.log('Optimizer: Setting timeout status'); | |
| statusDiv.textContent = `⏱️ Optimization timed out after ${timeoutSeconds}s`; | |
| statusDiv.style.color = '#ff9800'; // Orange for timeout | |
| } else { | |
| // Actual error | |
| console.log('Optimizer: Setting error status'); | |
| statusDiv.textContent = `❌ Error: ${error.message}`; | |
| statusDiv.style.color = '#d32f2f'; | |
| } | |
| } | |
| // Show partial results if available (they were displayed during progress updates) | |
| // Results display is already visible from onProgress updates, just update status | |
| } finally { | |
| const optimizeBtn = document.getElementById('optimize-lands-btn'); | |
| const timeoutInput = document.getElementById('optimizer-timeout'); | |
| const timeoutLabel = document.getElementById('optimizer-timeout-label'); | |
| if (optimizeBtn) { | |
| optimizeBtn.disabled = false; | |
| optimizeBtn.value = 'Optimize Basic Lands'; | |
| optimizeBtn.style.backgroundColor = ''; | |
| optimizeBtn.style.color = ''; | |
| optimizeBtn.onclick = null; // Remove cancel handler | |
| } | |
| if (timeoutInput) { | |
| timeoutInput.disabled = false; | |
| timeoutInput.style.display = ''; // Show timeout input again | |
| } | |
| if (timeoutLabel) { | |
| timeoutLabel.style.display = ''; // Show timeout label again | |
| } | |
| } | |
| } | |
| function displayOptimizerResults(result, startingLands) { | |
| const resultsDiv = document.getElementById('optimizer-results'); | |
| if (!resultsDiv) return; | |
| console.log('Displaying', result.results.length, 'results'); | |
| let html = '<div class="result-box" style="margin-top: 10px;">'; | |
| html += '<strong>Recommended Configurations:</strong><br><br>'; | |
| for (let i = 0; i < result.results.length; i++) { | |
| const config = result.results[i]; | |
| const isCurrent = ManabaseOptimizer.hashLands(config.lands) === ManabaseOptimizer.hashLands(startingLands); | |
| html += '<div style="margin: 8px 0; padding: 8px; border: 1px solid #ccc;'; | |
| if (isCurrent) html += ' background-color: #e3f2fd; border-color: #2196F3;'; | |
| html += '">'; | |
| if (isCurrent) { | |
| html += '<strong style="color: #1976D2;">Current Configuration</strong><br>'; | |
| } | |
| html += `<div style="display: flex; justify-content: space-between; align-items: center;">`; | |
| html += `<div style="flex: 1;">`; | |
| html += `<div style="margin: 5px 0;">`; | |
| html += formatLandDistribution(config.lands); | |
| html += '</div>'; | |
| html += `<div style="font-size: 13px; color: #333;">`; | |
| html += `Cast Rate: <strong>${(config.castRate * 100).toFixed(1)}%</strong> | `; | |
| html += `Avg Delay: <strong>${config.avgDelay.toFixed(3)}</strong> turns`; | |
| html += '</div>'; | |
| if (!isCurrent) { | |
| const changes = ManabaseOptimizer.describeLandChanges(startingLands, config.lands); | |
| html += `<div style="margin-top: 5px; font-size: 12px; font-style: italic;">`; | |
| html += `${changes}`; | |
| html += '</div>'; | |
| } | |
| html += '</div>'; // close flex: 1 | |
| // Apply button matching website style | |
| if (!isCurrent) { | |
| html += `<button class="apply-lands-btn" data-lands="${encodeURIComponent(JSON.stringify(config.lands))}" style=" | |
| background-color: #77ee77; | |
| border-radius: 15px; | |
| border: 1px solid #383; | |
| cursor: pointer; | |
| padding: 5px 15px; | |
| font-size: 15px; | |
| font-weight: bold; | |
| box-shadow: 0 3px #383; | |
| transition: 0.3s ease-out; | |
| margin-left: 10px; | |
| " | |
| onmouseover="this.style.backgroundColor='#3e3'" | |
| onmouseout="this.style.backgroundColor='#77ee77'" | |
| onmousedown="this.style.backgroundColor='#5c5'; this.style.boxShadow='0 1px #383'; this.style.transform='translateY(2px)'" | |
| onmouseup="this.style.boxShadow='0 3px #383'; this.style.transform='translateY(0px)'" | |
| title="Replace basic lands in deck list with this configuration">Apply</button>`; | |
| } | |
| html += '</div>'; // close flex container | |
| html += '</div>'; | |
| } | |
| html += '</div>'; | |
| resultsDiv.innerHTML = html; | |
| resultsDiv.style.display = 'block'; | |
| // Attach apply button handlers | |
| const applyButtons = resultsDiv.querySelectorAll('.apply-lands-btn'); | |
| applyButtons.forEach(btn => { | |
| btn.addEventListener('click', (e) => { | |
| const landsJson = decodeURIComponent(e.target.dataset.lands); | |
| const lands = JSON.parse(landsJson); | |
| applyLandsToDeckList(lands); | |
| }); | |
| }); | |
| } | |
| function applyLandsToDeckList(lands) { | |
| const decklistTextarea = document.getElementById('decklist'); | |
| if (!decklistTextarea) { | |
| console.error('Optimizer: Could not find decklist textarea'); | |
| return; | |
| } | |
| // Get current deck list | |
| const currentDeckList = decklistTextarea.value; | |
| const lines = currentDeckList.split('\n'); | |
| // Basic land names in WUBRG(C) order | |
| const basicNames = ['Plains', 'Island', 'Swamp', 'Mountain', 'Forest', 'Wastes']; | |
| const landMap = { w: 'Plains', u: 'Island', b: 'Swamp', r: 'Mountain', g: 'Forest', c: 'Wastes' }; | |
| const landOrder = ['w', 'u', 'b', 'r', 'g', 'c']; // WUBRG(C) order | |
| // Track where first basic was found | |
| let firstBasicIndex = -1; | |
| // Collect basics to insert (in WUBRG order) | |
| const basicsToInsert = []; | |
| for (const key of landOrder) { | |
| if (lands[key] > 0) { | |
| basicsToInsert.push(`${lands[key]} ${landMap[key]}`); | |
| } | |
| } | |
| // Remove all existing basics from deck list | |
| const newLines = []; | |
| for (let i = 0; i < lines.length; i++) { | |
| const line = lines[i]; | |
| const trimmed = line.trim().toLowerCase(); | |
| if (!trimmed) { | |
| newLines.push(line); // Keep empty lines | |
| continue; | |
| } | |
| // Check if line starts with number and contains a basic land name | |
| const match = trimmed.match(/^(\d+)\s+(.+)$/); | |
| if (!match) { | |
| newLines.push(line); // Not a card line, keep it | |
| continue; | |
| } | |
| const cardName = match[2]; | |
| const isBasic = basicNames.some(basic => | |
| cardName === basic.toLowerCase() || | |
| cardName.startsWith(basic.toLowerCase() + ' (') | |
| ); | |
| if (isBasic) { | |
| // Track where first basic was found for insertion point | |
| if (firstBasicIndex === -1) { | |
| firstBasicIndex = newLines.length; | |
| } | |
| // Skip all basics - we'll insert them sorted later | |
| } else { | |
| // Not a basic, keep it | |
| newLines.push(line); | |
| } | |
| } | |
| // Insert all basics (sorted in WUBRG order) at the first basic position | |
| if (basicsToInsert.length > 0) { | |
| if (firstBasicIndex >= 0) { | |
| // Insert at first basic position | |
| newLines.splice(firstBasicIndex, 0, ...basicsToInsert); | |
| } else { | |
| // No basics found in original, add at end | |
| newLines.push(...basicsToInsert); | |
| } | |
| } | |
| const newDeckList = newLines.join('\n'); | |
| decklistTextarea.value = newDeckList; | |
| // Scroll to the decklist textarea | |
| decklistTextarea.scrollIntoView({ behavior: 'smooth', block: 'center' }); | |
| console.log('Optimizer: Applied new basic land configuration'); | |
| } | |
| function formatLandDistribution(lands) { | |
| const names = { w: 'Plains', u: 'Island', b: 'Swamp', r: 'Mountain', g: 'Forest', c: 'Wastes' }; | |
| const colors = { w: '#F0E68C', u: '#4A90E2', b: '#9B59B6', r: '#D94B3D', g: '#50A050', c: '#999999' }; | |
| const parts = []; | |
| for (const [key, name] of Object.entries(names)) { | |
| if (lands[key] > 0) { | |
| parts.push(`<span style="font-weight: bold;">${lands[key]} <span style="color: ${colors[key]};">${name}</span></span>`); | |
| } | |
| } | |
| return parts.length > 0 ? parts.join(', ') : 'None'; | |
| } | |
| /** | |
| * ================================================================= | |
| * LAYOUT RESTRUCTURING | |
| * ================================================================= | |
| */ | |
| function restructureLayout() { | |
| try { | |
| logDebug('Layout: Restructuring to 3-column layout'); | |
| // Inject CSS for 3-column layout | |
| const style = document.createElement('style'); | |
| style.textContent = ` | |
| body { | |
| margin: 0 !important; | |
| padding: 0 !important; | |
| } | |
| .layout-container { | |
| display: flex; | |
| width: 100%; | |
| min-height: 100vh; | |
| gap: 0; | |
| } | |
| .layout-column { | |
| flex: 1; | |
| width: 33.333%; | |
| padding: 10px; | |
| overflow-y: auto; | |
| max-height: 100vh; | |
| box-sizing: border-box; | |
| } | |
| .column-a { | |
| background-color: #f8f8f8; | |
| } | |
| .column-b { | |
| background-color: #ffffff; | |
| } | |
| .column-c { | |
| background-color: #f8f8f8; | |
| } | |
| .analyzer { | |
| width: 100% !important; | |
| margin: 10px 0 !important; | |
| } | |
| `; | |
| document.head.appendChild(style); | |
| // Get the analyzers | |
| const deckLoader = document.querySelector('.analyzer[name="deckEntry"]'); | |
| const colorAnalyzer = document.querySelector('.analyzer[name="colorCalc"]'); | |
| const tapAnalyzer = document.querySelector('.analyzer[name="tapCalc"]'); | |
| const historyPanel = document.getElementById('history-panel'); | |
| const optimizerPanel = document.getElementById('optimizer-panel'); | |
| if (!deckLoader || !colorAnalyzer || !tapAnalyzer) { | |
| logError('Layout: Could not find required elements'); | |
| return; | |
| } | |
| // Create container and columns | |
| const container = document.createElement('div'); | |
| container.className = 'layout-container'; | |
| const columnA = document.createElement('div'); | |
| columnA.className = 'layout-column column-a'; | |
| const columnB = document.createElement('div'); | |
| columnB.className = 'layout-column column-b'; | |
| const columnC = document.createElement('div'); | |
| columnC.className = 'layout-column column-c'; | |
| // Column A: Deck Loader + History | |
| columnA.appendChild(deckLoader); | |
| if (historyPanel) { | |
| columnA.appendChild(historyPanel); | |
| } | |
| // Column B: Color Analyzer + Tap Analyzer | |
| columnB.appendChild(colorAnalyzer); | |
| columnB.appendChild(tapAnalyzer); | |
| // Column C: Optimizer (will be added by createOptimizerPanel) | |
| if (optimizerPanel) { | |
| columnC.appendChild(optimizerPanel); | |
| } | |
| // Add columns to container | |
| container.appendChild(columnA); | |
| container.appendChild(columnB); | |
| container.appendChild(columnC); | |
| // Replace body content | |
| document.body.innerHTML = ''; | |
| document.body.appendChild(container); | |
| // Store column C reference for optimizer panel | |
| window.columnC = columnC; | |
| logInfo('Layout: 3-column layout created successfully'); | |
| } catch (error) { | |
| logError('Layout: Error restructuring layout:', error); | |
| } | |
| } | |
| /** | |
| * ================================================================= | |
| * STARTUP | |
| * ================================================================= | |
| */ | |
| /** | |
| * Set Page Title and Favicon | |
| * | |
| * Changes the page title from "Document" to "Mana Tool" and sets | |
| * the favicon to match the Salubrious Snail website. | |
| */ | |
| function setPageMetadata() { | |
| // Set title | |
| document.title = 'Mana Tool'; | |
| // Set favicon - using the same as https://www.salubrioussnail.com/ | |
| const favicon = document.querySelector('link[rel="icon"]') || document.createElement('link'); | |
| favicon.rel = 'icon'; | |
| favicon.href = 'https://lh3.googleusercontent.com/sitesv/APaQ0STwZejRnUtnxosdcNAeWiC1wGxAp1hshR_MqcxUooRZyEjbrGrKEVigv_nKftwDGhFLmlyRgjfoI-2ixV4vGfaoz6XHbPANR1swqcXFbUc1F96CQO0VUioiI1-yftvtyE2KcuJ6sAuG2UOaSoSlELhK7TljxSG6Uf_tj6ldHdOjiHLy8dloAuFLSMwy0q5cNi7JJgJjCyj2HXBQ20E'; | |
| if (!document.querySelector('link[rel="icon"]')) { | |
| document.head.appendChild(favicon); | |
| } | |
| } | |
| // Set page metadata immediately | |
| setPageMetadata(); | |
| // Wait for DOM to be ready before initializing | |
| if (document.readyState === 'loading') { | |
| document.addEventListener('DOMContentLoaded', () => { | |
| restructureLayout(); | |
| initializeScript(); | |
| initializeHistory(); | |
| }); | |
| } else { | |
| // DOM already loaded (in case script runs late) | |
| restructureLayout(); | |
| initializeScript(); | |
| initializeHistory(); | |
| } | |
| })(); |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment