import ConfettiManager from "./confetttiManager"; import Overlay from "./Overlay"; import { calculateRelativeLuminance } from "./utils"; /** The overlay builder for the color filter Blue Marble window. * @description This class handles the overlay UI for the color filter window of the Blue Marble userscript. * @class WindowFilter * @since 0.88.329 * @see {@link Overlay} for examples */ export default class WindowFilter extends Overlay { /** Constructor for the color filter window * @param {*} executor - The executing class * @since 0.88.329 * @see {@link Overlay#constructor} */ constructor(executor) { super(executor.name, executor.version); // Executes the code in the Overlay constructor this.window = null; // Contains the *window* DOM tree this.windowID = 'bm-window-filter'; // The ID attribute for this window this.windowParent = document.body; // The parent of the window DOM tree /** The templateManager instance currently being used. @type {TemplateManager} */ this.templateManager = executor.apiManager?.templateManager; // Eye icons this.eyeOpen = ''; this.eyeClosed = ''; // Localization formats this.localizeNumber = new Intl.NumberFormat(); this.localizePercent = new Intl.NumberFormat(undefined, { style: 'percent', minimumFractionDigits: 2, maximumFractionDigits: 2 }); // Localization string formatting for "Remaining Time" in color filter window. // This is more of a hint than anything, as browsers seem to ignore it >:( this.localizeDateTimeOptions = { month: 'long', // July day: 'numeric', // 23 hour: '2-digit', // 17 minute: '2-digit', // 47 second: '2-digit' // 00 } // Obtains the color palette Blue Marble currently uses const { palette: palette, LUT: _ } = this.templateManager.paletteBM; this.palette = palette; // Tile quantity information this.tilesLoadedTotal = 0; // Number of tiles that have been loaded in this session this.tilesTotal = 0; // Number of tiles total, across all templates } /** Spawns a Color Filter window. * If another color filter window already exists, we DON'T spawn another! * Parent/child relationships in the DOM structure below are indicated by indentation. * @since 0.88.149 */ buildWindow() { // If a color filter window already exists, throw an error and return early if (document.querySelector(`#${this.windowID}`)) { this.handleDisplayError('Color Filter window already exists!'); return; } // Creates a new color filter window this.window = this.addDiv({'id': this.windowID, 'class': 'bm-window'}) .addDragbar() .addButton({'class': 'bm-button-circle', 'textContent': '▼', 'aria-label': 'Minimize window "Color Filter"', 'data-button-status': 'expanded'}, (instance, button) => { button.onclick = () => instance.handleMinimization(button); button.ontouchend = () => {button.click()}; // Needed only to negate weird interaction with dragbar }).buildElement() .addDiv().buildElement() // Contains the minimized h1 element .addButton({'class': 'bm-button-circle', 'textContent': '🞪', 'aria-label': 'Close window "Color Filter"'}, (instance, button) => { button.onclick = () => {document.querySelector(`#${this.windowID}`)?.remove();}; button.ontouchend = () => {button.click();}; // Needed only to negate weird interaction with dragbar }).buildElement() .buildElement() .addDiv({'class': 'bm-window-content'}) .addDiv({'class': 'bm-container bm-center-vertically'}) .addHeader(1, {'textContent': 'Color Filter'}).buildElement() .buildElement() .addHr().buildElement() .addDiv({'class': 'bm-container bm-flex-between bm-center-vertically', 'style': 'gap: 1.5ch;'}) .addButton({'textContent': 'Select All'}, (instance, button) => { button.onclick = () => this.#selectColorList(false); }).buildElement() .addButton({'textContent': 'Unselect All'}, (instance, button) => { button.onclick = () => this.#selectColorList(true); }).buildElement() .buildElement() .addDiv({'class': 'bm-container bm-scrollable'}) .addDiv({'class': 'bm-container', 'style': 'margin-left: 2.5ch; margin-right: 2.5ch;'}) .addDiv({'class': 'bm-container'}) .addSpan({'id': 'bm-filter-tile-load', 'innerHTML': 'Tiles Loaded: 0 / ???'}).buildElement() .addBr().buildElement() .addSpan({'id': 'bm-filter-tot-correct', 'innerHTML': 'Correct Pixels: ???'}).buildElement() .addBr().buildElement() .addSpan({'id': 'bm-filter-tot-total', 'innerHTML': 'Total Pixels: ???'}).buildElement() .addBr().buildElement() .addSpan({'id': 'bm-filter-tot-remaining', 'innerHTML': 'Complete: ??? (???)'}).buildElement() .addBr().buildElement() .addSpan({'id': 'bm-filter-tot-completed', 'innerHTML': '??? ???'}).buildElement() .buildElement() .addDiv({'class': 'bm-container'}) .addP({'innerHTML': `Colors with the icon ${this.eyeOpen.replace(' { button.onclick = (event) => { event.preventDefault(); // Stop default form submission // Get the form data const formData = new FormData(document.querySelector(`#${this.windowID} form`)); const formValues = {}; for (const [input, value] of formData) { formValues[input] = value; } console.log(`Primary: ${formValues['sortPrimary']}; Secondary: ${formValues['sortSecondary']}; Unused: ${formValues['showUnused'] == 'on'}`); // Sort the color list this.#sortColorList(formValues['sortPrimary'], formValues['sortSecondary'], formValues['showUnused'] == 'on'); } }).buildElement() .buildElement() .buildElement() .buildElement() // Color list will appear here in the DOM tree .buildElement() .buildElement() .buildElement().buildOverlay(this.windowParent); // Creates dragging capability on the drag bar for dragging the window this.handleDrag(`#${this.windowID}.bm-window`, `#${this.windowID} .bm-dragbar`); // Obtains the scrollable container to put the color filter in const scrollableContainer = document.querySelector(`#${this.windowID} .bm-container.bm-scrollable`); // Pixel totals let allPixelsTotal = 0; let allPixelsCorrectTotal = 0; const allPixelsCorrect = new Map(); const allPixelsColor = new Map(); // Sum the pixel totals across all templates. // If there is no total for a template, it defaults to zero for (const template of this.templateManager.templatesArray) { const total = template.pixelCount?.total ?? 0; allPixelsTotal += total ?? 0; // Sums the pixels placed as "total" per everything const colors = template.pixelCount?.colors ?? new Map(); // Sums the color pixels placed as "total" per color ID for (const [colorID, colorPixels] of colors) { const _colorPixels = Number(colorPixels) || 0; // Boilerplate const allPixelsColorSoFar = allPixelsColor.get(colorID) ?? 0; // The total color pixels for this color ID so far, or zero if none counted so far allPixelsColor.set(colorID, allPixelsColorSoFar + _colorPixels); } // Object that contains the tiles which contain Maps as correct pixels per tile as the value in the key-value pair const correctObject = template.pixelCount?.correct ?? {}; this.tilesLoadedTotal += Object.keys(correctObject).length; // Sums the total loaded tiles per template this.tilesTotal += Object.keys(template.chunked).length; // Sums the total tiles per template // Sums the pixels placed as "correct" per color ID for (const map of Object.values(correctObject)) { // Per (loaded) tile per template for (const [colorID, correctPixels] of map) { // Per color per (loaded) tile per template const _correctPixels = Number(correctPixels) || 0; // Boilerplate allPixelsCorrectTotal += _correctPixels; // Sums the pixels placed as "correct" per everything const allPixelsCorrectSoFar = allPixelsCorrect.get(colorID) ?? 0; // The total correct pixels for this color ID so far, or zero if none counted so far allPixelsCorrect.set(colorID, allPixelsCorrectSoFar + _correctPixels); } } } console.log(`Tiles loaded: ${this.tilesLoadedTotal} / ${this.tilesTotal}`); // If the template is complete, and the pixel count is non-zero, and at least 1 template exists, and all template tiles have been loaded this session... if ((allPixelsCorrectTotal >= allPixelsTotal) && !!allPixelsTotal && (this.tilesLoadedTotal == this.tilesTotal)) { // Basically, only run if Blue Marble can confirm with 100% certanty that all (>0) templates are complete. // Create confetti in the color filter window const confettiManager = new ConfettiManager(); confettiManager.createConfetti(document.querySelector(`#${this.windowID}`)); } // Calculates the date & time the user will complete the templates const timeRemaining = new Date(((allPixelsTotal - allPixelsCorrectTotal) * 30 * 1000) + Date.now()); const timeRemainingLocalized = timeRemaining.toLocaleString(undefined, this.localizeDateTimeOptions); // "30" is seconds. "1000" converts to milliseconds. "undefined" forces the localization to be the users. // Displays some template statistics to the user this.updateInnerHTML('#bm-filter-tile-load', `Tiles Loaded: ${this.localizeNumber.format(this.tilesLoadedTotal)} / ${this.localizeNumber.format(this.tilesTotal)}`); this.updateInnerHTML('#bm-filter-tot-correct', `Correct Pixels: ${this.localizeNumber.format(allPixelsCorrectTotal)}`); this.updateInnerHTML('#bm-filter-tot-total', `Total Pixels: ${this.localizeNumber.format(allPixelsTotal)}`); this.updateInnerHTML('#bm-filter-tot-remaining', `Remaining: ${this.localizeNumber.format((allPixelsTotal || 0) - (allPixelsCorrectTotal || 0))} (${this.localizePercent.format(((allPixelsTotal || 0) - (allPixelsCorrectTotal || 0)) / (allPixelsTotal || 1))})`); this.updateInnerHTML('#bm-filter-tot-completed', `Completed at: `); // These run when the user opens the Color Filter window this.#buildColorList(scrollableContainer, allPixelsCorrect, allPixelsColor); this.#sortColorList('id', 'ascending', false); } /** Creates the color list container. * @param {HTMLElement} parentElement - Parent element to add the color list to as a child * @param {Map} allPixelsCorrect - All pixels that are considered correct per color for all templates * @param {Map} allPixelsColor - All pixels that are considered that color, totaled across all templates * @since 0.88.222 */ #buildColorList(parentElement, allPixelsCorrect, allPixelsColor) { const colorList = new Overlay(this.name, this.version); colorList.addDiv({'class': 'bm-filter-flex'}) // We leave it open so we can add children to the grid // For each color in the palette... for (const color of this.palette) { // Relative Luminance const lumin = calculateRelativeLuminance(color.rgb); // Calculates if white or black text would contrast better with the palette color let textColorForPaletteColorBackground = (((1.05) / (lumin + 0.05)) > ((lumin + 0.05) / 0.05)) ? 'white' : 'black'; // However, if the color is "Transparent" (or there is no color ID), then we make the text color transparent if (!color.id) { textColorForPaletteColorBackground = 'transparent'; } // Changes the luminance of the hover/focus button effect const bgEffectForButtons = (textColorForPaletteColorBackground == 'white') ? 'bm-button-hover-white' : 'bm-button-hover-black'; // Turns "total" color into a string of a number; "0" if unknown const colorTotal = allPixelsColor.get(color.id) ?? 0 const colorTotalLocalized = this.localizeNumber.format(colorTotal); // This will be displayed if the total pixels for this color is zero let colorCorrect = 0; let colorCorrectLocalized = '0'; let colorPercent = this.localizePercent.format(1); // This will be displayed if the total pixels for this color is non-zero if (colorTotal != 0) { // Determines the correct pixels, or the proper fallback colorCorrect = allPixelsCorrect.get(color.id) ?? '???'; if ((typeof colorCorrect != 'number') && (this.tilesLoadedTotal == this.tilesTotal) && !!color.id) { colorCorrect = 0; } colorCorrectLocalized = (typeof colorCorrect == 'string') ? colorCorrect : this.localizeNumber.format(colorCorrect); colorPercent = isNaN(colorCorrect / colorTotal) ? '???' : this.localizePercent.format(colorCorrect / colorTotal); } // There are four outcomes: // 1. The correct pixel count is displayed, because there are correct pixels. // 2. There are NO correct pixels, and the color is not transparent, but since all tiles are loaded, we know that the correct pixel count is actually 0. // 3. There are NO correct pixels, and the color is not transparent, and not all tiles are loaded. We don't know if there are correct pixels or not, so we display "???" instead. // 4. There are NO correct pixels, and the color is transparent, so we display '???' because tracking the "Transparent" color is currently disabled. // Incorrect pixels for this color const colorIncorrect = parseInt(colorTotal) - parseInt(colorCorrect); const isColorHidden = !!(this.templateManager.shouldFilterColor.get(color.id) || false); // Construct the DOM tree for color in color list colorList.addDiv({'class': 'bm-container bm-filter-color bm-flex-between', 'data-id': color.id, 'data-name': color.name, 'data-premium': +color.premium, 'data-correct': !Number.isNaN(parseInt(colorCorrect)) ? colorCorrect : '0', 'data-total': colorTotal, 'data-percent': (colorPercent.slice(-1) == '%') ? colorPercent.slice(0, -1) : '0', 'data-incorrect': colorIncorrect || 0 }).addDiv({'class': 'bm-filter-container-rgb', 'style': `background-color: rgb(${color.rgb?.map(channel => Number(channel) || 0).join(',')});`}) .addButton({ 'class': 'bm-button-trans ' + bgEffectForButtons, 'data-state': isColorHidden ? 'hidden' : 'shown', 'aria-label': isColorHidden ? `Show the color ${color.name || ''} on templates.` : `Hide the color ${color.name || ''} on templates.`, 'innerHTML': isColorHidden ? this.eyeClosed.replace(' { // When the button is clicked button.onclick = () => { button.style.textDecoration = 'none'; button.disabled = true; if (button.dataset['state'] == 'shown') { button.innerHTML = this.eyeClosed.replace(' { const indexValue = index.getAttribute('data-' + sortPrimary); const nextIndexValue = nextIndex.getAttribute('data-' + sortPrimary); const indexValueNumber = parseFloat(indexValue); const nextIndexValueNumber = parseFloat(nextIndexValue); const indexValueNumberIsNumber = !isNaN(indexValueNumber); const nextIndexValueNumberIsNumber = !isNaN(nextIndexValueNumber); // If the user wants to show unused colors... if (showUnused) { index.classList.remove('bm-color-hide'); // Show the color } else if (!Number(index.getAttribute('data-total'))) { // ...else if the user wants to hide unused colors, and this color is unused... index.classList.add('bm-color-hide'); // Hide the color } // If both index values are numbers... if (indexValueNumberIsNumber && nextIndexValueNumberIsNumber) { // Perform numeric comparison return sortSecondary === 'ascending' ? indexValueNumber - nextIndexValueNumber : nextIndexValueNumber - indexValueNumber; } else { // Otherwise, perform string comparison const indexValueString = indexValue.toLowerCase(); const nextIndexValueString = nextIndexValue.toLowerCase(); if (indexValueString < nextIndexValueString) return sortSecondary === 'ascending' ? -1 : 1; if (indexValueString > nextIndexValueString) return sortSecondary === 'ascending' ? 1 : -1; return 0; } }); colors.forEach(color => colorList.appendChild(color)); } /** (Un)selects all colors in the color list that are visible to the user. * @param {boolean} userWantsUnselect - Does the user want to unselect colors? * @since 0.88.222 */ #selectColorList(userWantsUnselect) { // Gets the colors const colorList = document.querySelector('.bm-filter-flex'); const colors = Array.from(colorList.children); // For each color... for (const color of colors) { // Skip this color if it is hidden if (color.classList?.contains('bm-color-hide')) {continue;} // Gets the button to click const button = color.querySelector('.bm-filter-container-rgb button'); // Exits early if the button is in its proper state if ((button.dataset['state'] == 'hidden') && !userWantsUnselect) {continue;} // If the button is selected, and the user wants to select all buttons, then skip this one if ((button.dataset['state'] == 'shown') && userWantsUnselect) {continue;} // If the button is not selected, and the user wants to unselect all buttons, then skip this one button.click(); // If the button is not in its proper state, then we click it } } }