Unified GeoPixels enhancement suite - by Pixelcons
// ==UserScript==
// @name GeoPixelcons++
// @namespace http://tampermonkey.net/
// @version 1.0.2
// @description Unified GeoPixels enhancement suite - by Pixelcons
// @author ariapokoteng, Manako, D.V.H.
// @match *://geopixels.net/*
// @match *://*.geopixels.net/*
// @grant GM_xmlhttpRequest
// @grant GM_setValue
// @grant GM_getValue
// @grant GM_registerMenuCommand
// @grant unsafeWindow
// @connect *
// @license MIT
// @icon https://www.google.com/s2/favicons?sz=64&domain=geopixels.net
// ==/UserScript==
(function () {
'use strict';
const VERSION = '1.0.2';
// ============================================================
// SETTINGS SYSTEM
// ============================================================
const STORAGE_KEY = 'geopixelcons_settings';
const FEATURE_LIST = [
{ key: 'bulkPurchaseColors', name: 'Bulk Purchase Colors', icon: '🛒', desc: 'Advanced color purchasing with queue management.', features: ['Bulk color purchase with preview modal', 'Queue management in profile panel', 'Duplicate detection & insufficient-pixels handling', 'Purchase progress tracking'] },
{ key: 'ghostPaletteSearch', name: 'Ghost Palette Color Search', icon: '🔍', desc: 'Adds a searchable color filter to the ghost image palette.', features: ['Search ghost palette colors by hex code', 'Hide unmatched colors with a toggle', 'Real-time glow/highlight on matching swatches'] },
{ key: 'ghostTemplateManager', name: 'Ghost Template Manager', icon: '👻', desc: 'Full ghost image template history with import/export and overlay preview.', features: ['IndexedDB-backed template history', 'Import/export ghost templates as files', 'Preview overlay on the map', 'Position encoding in image header', 'Duplicate detection'] },
{ key: 'guildOverhaul', name: 'Guild Overhaul', icon: '⚔️', desc: 'Comprehensive guild interface improvements.', features: ['Enhanced member management UI', 'Bank/treasury system', 'Color limit tracking', 'Role hierarchy display', 'Guild-specific moderation tools'] },
{ key: 'hidePaintMenu', name: 'Paint Menu Controls', icon: '🫣', desc: 'Adds a collapse/expand toggle for the bottom controls panel.', features: ['Collapse & expand the bottom paint controls', 'Reposition controls (left/center/right)', 'Smooth CSS animations'] },
{ key: 'paintBrushSwap', name: 'Paint Brush Swap', icon: '🖌️', desc: 'Rapid paintbrush tool switching with keyboard shortcuts.', features: ['Configurable keyboard shortcuts for brush swap', 'Brush preset profiles for different painting patterns', 'Quick-switch between brush types'] },
{ key: 'regionScreenshot', name: 'Region Screenshot', icon: '📸', desc: 'Capture region-level screenshots with coordinate overlays.', features: ['Region image capture with coordinate overlay', 'Alpha channel support', 'Save as PNG directly'] },
{ key: 'regionsHighscore', name: 'Regions Highscore', icon: '🏆', desc: 'Displays regional pixel/color contribution rankings.', features: ['Sort rankings by player or guild', 'Filter by pixel count, color, or region', 'Historical contribution statistics'] },
{ key: 'themeEditor', name: 'Theme Editor', icon: '🎨', desc: 'Visual map theme editor — edit MapLibre GL styles with color pickers, save/load/manage custom themes.', features: ['Bundled themes (Fjord, Obsidian, Monokai, Ayu Mirage, etc.)', 'Simple & Full color editing modes', 'Live preview toggle for instant feedback', 'Import/export themes as JSON files', 'Quick theme-switch submenu in the dropdown', 'Theme manager with create, edit & delete'] },
];
const EXTENSION_LIST = [
{ key: 'extAutoHoverMenus', name: 'Auto-open Menus on Hover', icon: '🖱️', desc: 'Automatically opens group button dropdown menus when you hover over them.', features: ['Hover over any group button to auto-open its dropdown', 'Configurable vertical hover zone (250px)', 'Per-button cooldown to prevent rapid toggles', 'MutationObserver-based — detects new buttons automatically'] },
{ key: 'extGoToLastLocation', name: 'Auto-Go to Last Location', icon: '📍', desc: 'Automatically returns you to your last location on page load if you spawned at the default area.', features: ['Detects if you spawned in the default area', 'Auto-clicks the "Last Location" button on load', 'One-shot — only fires once per page load', 'Automatic cleanup after 10 seconds to prevent leaks'] },
{ key: 'extPillHoverLabels', name: 'Hover Labels', icon: '💊', desc: 'Adds the expanding pill-style hover animation with text labels to all submenu buttons under controls-left.', features: ['Expanding pill animation on hover', 'Shows button title/name as a label', 'Applies to all native dropdown submenu buttons', 'Respects dark mode colors', 'MutationObserver-based — detects dynamically added buttons'] },
];
const DEFAULT_SETTINGS = { useEmojiIcon: false };
FEATURE_LIST.forEach(f => DEFAULT_SETTINGS[f.key] = true);
EXTENSION_LIST.forEach(f => DEFAULT_SETTINGS[f.key] = f.key === 'extPillHoverLabels' ? true : false);
function loadSettings() {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (!raw) return { ...DEFAULT_SETTINGS };
const parsed = JSON.parse(raw);
// Merge with defaults so new features default to enabled
return { ...DEFAULT_SETTINGS, ...parsed };
} catch (e) {
return { ...DEFAULT_SETTINGS };
}
}
function saveSettings(settings) {
localStorage.setItem(STORAGE_KEY, JSON.stringify(settings));
}
const _settings = loadSettings();
let _themeEditor = null; // Populated by theme editor module
let _regionScreenshot = null; // Populated by region screenshot module
let _regionsHighscore = null; // Populated by regions highscore module
// ─── Shared coord cache for screenshot/highscore flyouts ────────
const COORD_CACHE_KEY = 'gpc_cachedCoords';
const AUTO_SS_KEY = 'gpc_autoScreenshotEnabled';
function loadCachedCoords() { try { return JSON.parse(localStorage.getItem(COORD_CACHE_KEY)); } catch { return null; } }
function saveCachedCoords(c) { localStorage.setItem(COORD_CACHE_KEY, JSON.stringify(c)); }
function isAutoScreenshotEnabled() { return localStorage.getItem(AUTO_SS_KEY) === '1'; }
function setAutoScreenshot(on) { localStorage.setItem(AUTO_SS_KEY, on ? '1' : '0'); }
const _featureStatus = {}; // key => 'ok' | 'error' | 'disabled'
FEATURE_LIST.forEach(f => {
_featureStatus[f.key] = _settings[f.key] ? 'pending' : 'disabled';
});
EXTENSION_LIST.forEach(f => {
_featureStatus[f.key] = _settings[f.key] ? 'pending' : 'disabled';
});
// ============================================================
// DARK THEME DETECTION (Geopixels++ compatibility)
// ============================================================
function isDarkMode() {
const gppSettings = localStorage.getItem('geo++_settings');
if (gppSettings) {
try {
const parsed = JSON.parse(gppSettings);
if (parsed.theme && parsed.theme !== 'system') {
return parsed.theme === 'simple_black';
}
} catch(e) {}
}
return document.body.classList.contains('dark') ||
window.matchMedia('(prefers-color-scheme: dark)').matches;
}
// Theme-aware colors
function t(light, dark) { return isDarkMode() ? dark : light; }
// ============================================================
// UI: SETTINGS MODAL (Tabbed)
// ============================================================
function createSettingsModal() {
// Remove existing
const existing = document.getElementById('gpc-settings-modal');
if (existing) { existing.remove(); return; }
const dark = isDarkMode();
const overlay = document.createElement('div');
overlay.id = 'gpc-settings-modal';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 100000;
background: rgba(0,0,0,0.5); display: flex;
align-items: center; justify-content: center;
font-family: system-ui, -apple-system, sans-serif;
`;
const modal = document.createElement('div');
modal.style.cssText = `
background: ${dark ? '#1e1e2e' : '#ffffff'};
color: ${dark ? '#cdd6f4' : '#1e293b'};
border-radius: 12px; padding: 0; width: 460px; max-width: 95vw;
box-shadow: 0 20px 60px rgba(0,0,0,0.3);
overflow: hidden;
`;
// Header
const header = document.createElement('div');
header.style.cssText = `
padding: 16px 20px; display: flex; align-items: center;
justify-content: space-between;
background: ${dark ? '#313244' : '#f1f5f9'};
border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
`;
header.innerHTML = `<span style="font-weight:700;font-size:16px;">⚙️ GeoPixelcons++</span>`;
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `
background:none; border:none; font-size:18px; cursor:pointer;
color:${dark ? '#a6adc8' : '#64748b'}; padding:4px 8px; border-radius:4px;
`;
closeBtn.onmouseenter = () => closeBtn.style.background = dark ? '#45475a' : '#e2e8f0';
closeBtn.onmouseleave = () => closeBtn.style.background = 'none';
closeBtn.onclick = () => overlay.remove();
header.appendChild(closeBtn);
modal.appendChild(header);
// Tab bar
const tabBar = document.createElement('div');
tabBar.style.cssText = `
display: flex; background: ${dark ? '#1e1e2e' : '#ffffff'};
border-bottom: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
`;
const tabs = ['Extensions', 'GeoPixelcons++ Settings'];
const tabBtns = [];
const tabPanels = [];
tabs.forEach((tabName, i) => {
const btn = document.createElement('button');
btn.textContent = tabName;
btn.style.cssText = `
flex: 1; padding: 10px 16px; font-size: 13px; font-weight: 600;
border: none; cursor: pointer; transition: 0.2s;
background: ${i === 0 ? (dark ? '#1e1e2e' : '#ffffff') : (dark ? '#313244' : '#f1f5f9')};
color: ${i === 0 ? (dark ? '#cdd6f4' : '#1e293b') : (dark ? '#6c7086' : '#94a3b8')};
border-bottom: 2px solid ${i === 0 ? '#22c55e' : 'transparent'};
`;
btn.addEventListener('click', () => switchTab(i));
tabBtns.push(btn);
tabBar.appendChild(btn);
});
modal.appendChild(tabBar);
function switchTab(idx) {
tabBtns.forEach((b, i) => {
const active = i === idx;
b.style.background = active ? (dark ? '#1e1e2e' : '#ffffff') : (dark ? '#313244' : '#f1f5f9');
b.style.color = active ? (dark ? '#cdd6f4' : '#1e293b') : (dark ? '#6c7086' : '#94a3b8');
b.style.borderBottom = active ? '2px solid #22c55e' : '2px solid transparent';
});
tabPanels.forEach((p, i) => {
p.style.display = i === idx ? 'block' : 'none';
});
}
// Warning banner (hidden by default)
const banner = document.createElement('div');
banner.id = 'gpc-restart-banner';
banner.style.cssText = `
display: none; padding: 10px 20px;
background: ${dark ? '#f9e2af33' : '#fef3c7'};
color: ${dark ? '#f9e2af' : '#92400e'};
font-size: 13px; font-weight: 600;
border-bottom: 1px solid ${dark ? '#f9e2af44' : '#fde68a'};
`;
banner.textContent = '⚠️ Refresh the page to apply changes';
modal.appendChild(banner);
// ---- Floating tooltip helper ----
let activeTooltip = null;
function removeTooltip() {
if (activeTooltip) { activeTooltip.remove(); activeTooltip = null; }
}
function showTooltip(e, feature) {
removeTooltip();
const tip = document.createElement('div');
tip.style.cssText = `
position: fixed; z-index: 100001; padding: 12px 16px; border-radius: 8px;
background: ${dark ? '#313244' : '#ffffff'}; color: ${dark ? '#cdd6f4' : '#1e293b'};
box-shadow: 0 8px 24px rgba(0,0,0,0.25); font-size: 13px; max-width: 280px;
border: 1px solid ${dark ? '#45475a' : '#e2e8f0'}; pointer-events: none;
`;
let html = `<div style="font-weight:700;margin-bottom:6px;">${feature.icon} ${feature.name}</div>`;
html += `<div style="margin-bottom:6px;color:${dark ? '#a6adc8' : '#64748b'};">${feature.desc}</div>`;
html += '<ul style="margin:0;padding-left:18px;">';
feature.features.forEach(f => { html += `<li style="margin-bottom:2px;">${f}</li>`; });
html += '</ul>';
tip.innerHTML = html;
document.body.appendChild(tip);
// Position near cursor
const tipW = tip.offsetWidth, tipH = tip.offsetHeight;
let tx = e.clientX + 12, ty = e.clientY + 12;
if (tx + tipW > window.innerWidth - 8) tx = e.clientX - tipW - 12;
if (ty + tipH > window.innerHeight - 8) ty = e.clientY - tipH - 12;
tip.style.left = tx + 'px';
tip.style.top = ty + 'px';
activeTooltip = tip;
}
// ---- Navigate to a feature's UI element ----
function navigateToFeature(key) {
function flashEl(el) {
if (!el) return;
el.scrollIntoView?.({ behavior: 'smooth', block: 'center' });
el.style.transition = 'box-shadow .3s';
el.style.boxShadow = '0 0 0 3px #facc15, 0 0 16px 4px rgba(250,204,21,.5)';
setTimeout(() => { el.style.boxShadow = ''; setTimeout(() => { el.style.transition = ''; }, 300); }, 1500);
}
function flashAll(els) { els.forEach(el => flashEl(el)); }
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const nav = {
ghostPaletteSearch: () => {
// Open ghost modal, then flash the color search container
if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(true);
setTimeout(() => flashEl(document.querySelector('.color-search-container')), 400);
},
ghostTemplateManager: () => {
// Open ghost modal, then flash the toolbar buttons
if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(true);
setTimeout(() => flashAll(Array.from(document.querySelectorAll('.gp-to-btn'))), 400);
},
guildOverhaul: () => {
const guildBtn = document.querySelector('#guildMenuBtn');
if (guildBtn) guildBtn.click();
},
hidePaintMenu: () => {
flashEl(document.querySelector('#gpc-hide-paint-toggle'));
},
paintBrushSwap: () => {
flashEl(document.querySelector('#brush-swap-toggle'));
},
regionsHighscore: () => {
if (_regionsHighscore) _regionsHighscore.toggleSelectionMode();
},
regionScreenshot: () => {
if (_regionScreenshot) _regionScreenshot.toggleSelectionMode();
},
bulkPurchaseColors: () => {
if (typeof _pw.toggleProfile === 'function') _pw.toggleProfile();
setTimeout(() => flashEl(document.querySelector('#gp-bulk-profile-card')), 400);
},
themeEditor: () => {
if (_themeEditor) _themeEditor.toggleModal();
},
extAutoHoverMenus: () => {
// No specific UI to navigate to
},
extGoToLastLocation: () => {
// No specific UI to navigate to
},
extPillHoverLabels: () => {
// No specific UI to navigate to
},
};
const fn = nav[key];
if (fn) fn();
}
// ---- Helper: build a toggle row ----
function buildToggleRow(f, showHelp) {
const status = _featureStatus[f.key];
const enabled = _settings[f.key] !== false;
const row = document.createElement('div');
row.style.cssText = `
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: 8px;
background: ${enabled
? (status === 'error' ? (dark ? '#f9e2af22' : '#fefce8') : (dark ? '#a6e3a122' : '#f0fdf4'))
: (dark ? '#f38ba822' : '#fef2f2')};
border: 1px solid ${enabled
? (status === 'error' ? (dark ? '#f9e2af44' : '#fde68a') : (dark ? '#a6e3a144' : '#bbf7d0'))
: (dark ? '#f38ba844' : '#fecaca')};
transition: all 0.2s;
`;
const labelWrap = document.createElement('div');
labelWrap.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;min-width:0;';
const statusDot = status === 'error' ? '🟡' : (enabled ? '🟢' : '🔴');
const iconSpan = document.createElement('span');
iconSpan.textContent = f.icon;
const nameSpan = document.createElement('span');
nameSpan.textContent = f.name;
nameSpan.style.cssText = 'white-space:nowrap;overflow:hidden;text-overflow:ellipsis;cursor:pointer;border-bottom:1px dashed ' + (dark ? '#6c7086' : '#94a3b8') + ';transition:color .15s,border-color .15s;';
nameSpan.addEventListener('mouseenter', () => { nameSpan.style.color = '#3b82f6'; nameSpan.style.borderBottomColor = '#3b82f6'; });
nameSpan.addEventListener('mouseleave', () => { nameSpan.style.color = ''; nameSpan.style.borderBottomColor = dark ? '#6c7086' : '#94a3b8'; });
nameSpan.addEventListener('click', () => {
overlay.remove();
navigateToFeature(f.key);
});
const dotSpan = document.createElement('span');
dotSpan.style.cssText = 'font-size:10px;';
dotSpan.textContent = statusDot;
labelWrap.appendChild(iconSpan);
labelWrap.appendChild(nameSpan);
labelWrap.appendChild(dotSpan);
if (showHelp && f.desc) {
const helpBtn = document.createElement('span');
helpBtn.textContent = '❓';
helpBtn.style.cssText = 'cursor:help;font-size:14px;flex-shrink:0;margin-left:2px;';
helpBtn.addEventListener('mouseenter', (ev) => showTooltip(ev, f));
helpBtn.addEventListener('mouseleave', removeTooltip);
labelWrap.appendChild(helpBtn);
}
// Toggle switch
const toggle = document.createElement('label');
toggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0; margin-left:8px;';
const input = document.createElement('input');
input.type = 'checkbox';
input.checked = enabled;
input.style.cssText = 'opacity:0;width:0;height:0;';
const slider = document.createElement('span');
slider.style.cssText = `
position:absolute; inset:0; border-radius:12px; transition:0.2s;
background: ${enabled ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
`;
const knob = document.createElement('span');
knob.style.cssText = `
position:absolute; top:2px; left:${enabled ? '22px' : '2px'};
width:20px; height:20px; border-radius:50%; transition:0.2s;
background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
`;
slider.appendChild(knob);
input.addEventListener('change', () => {
_settings[f.key] = input.checked;
saveSettings(_settings);
slider.style.background = input.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
knob.style.left = input.checked ? '22px' : '2px';
row.style.background = input.checked
? (dark ? '#a6e3a122' : '#f0fdf4')
: (dark ? '#f38ba822' : '#fef2f2');
row.style.borderColor = input.checked
? (dark ? '#a6e3a144' : '#bbf7d0')
: (dark ? '#f38ba844' : '#fecaca');
labelWrap.querySelector('span:last-of-type').textContent = input.checked ? '🟢' : '🔴';
banner.style.display = 'block';
});
toggle.appendChild(input);
toggle.appendChild(slider);
row.appendChild(labelWrap);
row.appendChild(toggle);
return row;
}
// ============ TAB 1: Extensions ============
const extPanel = document.createElement('div');
extPanel.style.cssText = 'padding: 12px 20px; display: flex; flex-direction: column; gap: 8px; max-height: 50vh; overflow-y: auto;';
// Section: Built-in features
const builtinLabel = document.createElement('div');
builtinLabel.style.cssText = `font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:${dark ? '#6c7086' : '#94a3b8'};margin-bottom:2px;`;
builtinLabel.textContent = 'Built-in Features';
extPanel.appendChild(builtinLabel);
FEATURE_LIST.forEach(f => extPanel.appendChild(buildToggleRow(f, true)));
// Section: Additional extensions
const extLabel = document.createElement('div');
extLabel.style.cssText = `font-size:11px;font-weight:700;text-transform:uppercase;letter-spacing:0.5px;color:${dark ? '#6c7086' : '#94a3b8'};margin-top:8px;margin-bottom:2px;`;
extLabel.textContent = 'Additional Extensions';
extPanel.appendChild(extLabel);
EXTENSION_LIST.forEach(f => extPanel.appendChild(buildToggleRow(f, true)));
tabPanels.push(extPanel);
modal.appendChild(extPanel);
// ============ TAB 2: GeoPixelcons++ Settings ============
const settingsPanel = document.createElement('div');
settingsPanel.style.cssText = 'padding: 12px 20px; display: none; flex-direction: column; gap: 12px;';
// Emoji icon toggle
const emojiRow = document.createElement('div');
emojiRow.style.cssText = `
display: flex; align-items: center; justify-content: space-between;
padding: 10px 14px; border-radius: 8px;
background: ${dark ? '#313244' : '#f1f5f9'};
border: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
`;
const emojiLabel = document.createElement('div');
emojiLabel.style.cssText = 'display:flex;align-items:center;gap:8px;font-size:14px;font-weight:500;';
emojiLabel.innerHTML = '<span>😢</span><span>Use emoji for menu button</span>';
const emojiToggle = document.createElement('label');
emojiToggle.style.cssText = 'position:relative; width:44px; height:24px; cursor:pointer; flex-shrink:0;';
const emojiInput = document.createElement('input');
emojiInput.type = 'checkbox';
emojiInput.checked = !!_settings.useEmojiIcon;
emojiInput.style.cssText = 'opacity:0;width:0;height:0;';
const emojiSlider = document.createElement('span');
emojiSlider.style.cssText = `
position:absolute; inset:0; border-radius:12px; transition:0.2s;
background: ${_settings.useEmojiIcon ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1')};
`;
const emojiKnob = document.createElement('span');
emojiKnob.style.cssText = `
position:absolute; top:2px; left:${_settings.useEmojiIcon ? '22px' : '2px'};
width:20px; height:20px; border-radius:50%; transition:0.2s;
background: white; box-shadow: 0 1px 3px rgba(0,0,0,0.2);
`;
emojiSlider.appendChild(emojiKnob);
emojiInput.addEventListener('change', () => {
_settings.useEmojiIcon = emojiInput.checked;
saveSettings(_settings);
emojiSlider.style.background = emojiInput.checked ? '#22c55e' : (dark ? '#585b70' : '#cbd5e1');
emojiKnob.style.left = emojiInput.checked ? '22px' : '2px';
// Live-update the button
const mainBtn = document.getElementById('geopixelconsGroupBtn');
if (mainBtn) {
if (emojiInput.checked) {
mainBtn.style.backgroundImage = 'none';
mainBtn.textContent = '😢';
mainBtn.style.fontSize = '20px';
} else {
mainBtn.textContent = '';
mainBtn.style.fontSize = '';
mainBtn.style.backgroundImage = mainBtn.dataset.iconBg || '';
}
}
});
emojiToggle.appendChild(emojiInput);
emojiToggle.appendChild(emojiSlider);
emojiRow.appendChild(emojiLabel);
emojiRow.appendChild(emojiToggle);
settingsPanel.appendChild(emojiRow);
// Desc text
const emojiDesc = document.createElement('div');
emojiDesc.style.cssText = `font-size:12px;color:${dark ? '#6c7086' : '#94a3b8'};padding:0 14px;`;
emojiDesc.textContent = 'When enabled, replaces the GeoPixelcons++ button icon with the 😢 emoji.';
settingsPanel.appendChild(emojiDesc);
tabPanels.push(settingsPanel);
modal.appendChild(settingsPanel);
// Footer
const footer = document.createElement('div');
footer.style.cssText = `
padding: 12px 20px;
background: ${dark ? '#313244' : '#f8fafc'};
border-top: 1px solid ${dark ? '#45475a' : '#e2e8f0'};
font-size: 11px;
color: ${dark ? '#6c7086' : '#94a3b8'};
text-align: center;
`;
footer.textContent = 'GeoPixelcons++ v' + VERSION;
modal.appendChild(footer);
overlay.appendChild(modal);
overlay.addEventListener('click', (e) => {
if (e.target === overlay) { removeTooltip(); overlay.remove(); }
});
document.body.appendChild(overlay);
}
// ============================================================
// UI: CONTROLS-LEFT SUBMENU
// ============================================================
function waitForControlsLeft(cb) {
const el = document.getElementById('controls-left');
if (el) return cb(el);
setTimeout(() => waitForControlsLeft(cb), 500);
}
waitForControlsLeft((controlsLeft) => {
// Create the group container
const group = document.createElement('div');
group.className = 'relative';
// Main button
const mainBtn = document.createElement('button');
mainBtn.id = 'geopixelconsGroupBtn';
mainBtn.className = 'w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer';
mainBtn.title = 'GeoPixelcons++';
const _iconBg = 'url(data:image/png;base64,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);background-size:cover;background-position:center;border:none;padding:0;';
// Apply icon to button
mainBtn.style.cssText = 'background-image:' + _iconBg;
mainBtn.dataset.iconBg = _iconBg.substring(0, _iconBg.indexOf(');') + 1);
if (_settings.useEmojiIcon) {
mainBtn.style.backgroundImage = 'none';
mainBtn.textContent = '😢';
mainBtn.style.fontSize = '20px';
}
// Dropdown
const dropdown = document.createElement('div');
dropdown.className = 'dropdown-menu absolute left-0 mt-2 flex flex-col gap-2 transition-all duration-150 ease-out opacity-0 scale-95 hidden';
// ── Theme-aware color tokens for the dropdown UI ──
// Use CSS custom properties so colors follow GeoPixels++ theme changes live
const C = {
pillBg: 'var(--color-white, #fff)',
pillHover: 'var(--color-gray-100, #f3f4f6)',
pillText: 'var(--color-gray-700, #374151)',
flyBg: 'var(--color-white, #fff)',
flyHover: 'var(--color-gray-100, #f3f4f6)',
flyText: 'var(--color-gray-700, #374151)',
flyMuted: 'var(--color-gray-500, #6b7280)',
inputBg: 'var(--color-white, #fff)',
inputBorder: 'var(--color-gray-300, #d1d5db)',
inputText: 'var(--color-gray-900, #111827)',
shadow: '0 1px 3px rgba(0,0,0,.12)',
activeBg: 'var(--color-green-100, #bbf7d0)',
activeText: 'var(--color-green-800, #166534)',
teBtnBg: 'var(--color-purple-500, #7c3aed)',
teBtnText: 'var(--color-purple-50, #f3e8ff)',
teActiveBg: 'var(--color-purple-400, #c4b5fd)',
teInactiveBg:'var(--color-white, #fff)',
teHover: 'var(--color-purple-100, #ede9fe)',
navBg: 'var(--color-white, #fff)',
navText: 'var(--color-gray-500, #6b7280)',
};
dropdown.id = 'geopixelconsDropdown';
function openDropdown() {
dropdown.classList.remove('hidden');
setTimeout(() => {
dropdown.classList.remove('opacity-0', 'scale-95');
}, 10);
}
function closeDropdown() {
dropdown.classList.add('opacity-0', 'scale-95');
setTimeout(() => {
dropdown.classList.add('hidden');
}, 150);
}
mainBtn.addEventListener('click', (e) => {
e.stopPropagation();
// Close other dropdowns using the site's function if available
if (typeof closeAllDropdowns === 'function') {
closeAllDropdowns();
} else {
document.querySelectorAll('.dropdown-menu').forEach(d => {
if (d !== dropdown && !d.classList.contains('hidden')) {
d.classList.add('opacity-0', 'scale-95');
setTimeout(() => d.classList.add('hidden'), 150);
}
});
}
const isOpen = !dropdown.classList.contains('hidden');
if (isOpen) closeDropdown(); else openDropdown();
});
function makeSubBtn(icon, label, onClick) {
const btn = document.createElement('button');
btn.className = 'gpc-pill-btn';
btn.title = label;
btn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
const iconSpan = document.createElement('span');
iconSpan.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
iconSpan.textContent = icon;
const labelSpan = document.createElement('span');
labelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
labelSpan.textContent = label;
btn.appendChild(iconSpan);
btn.appendChild(labelSpan);
btn.addEventListener('mouseenter', () => {
const textW = labelSpan.scrollWidth + 12;
btn.style.width = (40 + textW) + 'px';
labelSpan.style.opacity = '1';
btn.style.background = C.pillHover;
});
btn.addEventListener('mouseleave', () => {
btn.style.width = '40px';
labelSpan.style.opacity = '0';
btn.style.background = C.pillBg;
});
btn.addEventListener('click', (e) => {
e.stopPropagation();
btn.style.width = '40px';
labelSpan.style.opacity = '0';
btn.style.background = C.pillBg;
closeDropdown();
onClick();
});
return btn;
}
// ─── Shared flyout close registry (only one open at a time) ──
const _flyoutClosers = [];
function closeAllFlyouts() { for (const fn of _flyoutClosers) fn(); }
// ─── Shared flyout builder for Screenshot / Highscore ─────────
function buildFeatureFlyout(opts) {
// opts: { id, icon, title, featureKey, getModule, color }
const group = document.createElement('div');
group.style.cssText = 'position:relative;';
const mainBtn = document.createElement('button');
mainBtn.className = 'gpc-pill-btn';
mainBtn.title = opts.title;
mainBtn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
mainBtn.id = 'gpc-' + opts.id + '-sub';
const mainIcon = document.createElement('span');
mainIcon.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
mainIcon.textContent = opts.icon;
const mainLabel = document.createElement('span');
mainLabel.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
mainLabel.textContent = opts.title;
mainBtn.appendChild(mainIcon);
mainBtn.appendChild(mainLabel);
mainBtn.addEventListener('mouseenter', () => {
if (flyoutOpen) return;
const textW = mainLabel.scrollWidth + 12;
mainBtn.style.width = (40 + textW) + 'px';
mainLabel.style.opacity = '1';
mainBtn.style.background = C.pillHover;
});
mainBtn.addEventListener('mouseleave', () => {
mainBtn.style.width = '40px';
mainLabel.style.opacity = '0';
mainBtn.style.background = C.pillBg;
});
function collapsePill() {
mainBtn.style.width = '40px';
mainLabel.style.opacity = '0';
mainBtn.style.background = C.pillBg;
}
const flyout = document.createElement('div');
flyout.id = 'gpc-' + opts.id + '-flyout';
Object.assign(flyout.style, {
position: 'absolute', left: 'calc(100% + 8px)', top: '0',
transform: 'scale(0.95)',
display: 'flex', flexDirection: 'column', gap: '4px',
transition: 'all 0.15s ease-out',
opacity: '0', pointerEvents: 'none', zIndex: '21',
});
let flyoutOpen = false;
const flyBtnStyle = 'display:flex;align-items:center;gap:6px;min-width:200px;padding:5px 10px;background:'+C.flyBg+';box-shadow:'+C.shadow+';border-radius:6px;border:none;cursor:pointer;font-size:11px;font-weight:500;color:'+C.flyText+';white-space:nowrap;height:28px;transition:background .12s;';
const flyBtnActiveStyle = flyBtnStyle.replace('background:'+C.flyBg,'background:'+C.activeBg).replace('color:'+C.flyText,'color:'+C.activeText);
function makeFlyBtn(label, emoji, onClick, extraId) {
const btn = document.createElement('button');
btn.style.cssText = flyBtnStyle;
btn.innerHTML = emoji + ' ' + label;
if (extraId) btn.id = extraId;
btn.addEventListener('mouseenter', () => { if (!btn.dataset.active) btn.style.background = C.flyHover; });
btn.addEventListener('mouseleave', () => { if (!btn.dataset.active) btn.style.background = C.flyBg; });
btn.addEventListener('click', (e) => { e.stopPropagation(); closeFly(); closeDropdown(); onClick(); });
return btn;
}
function closeFly() {
flyoutOpen = false;
flyout.style.opacity = '0';
flyout.style.pointerEvents = 'none';
flyout.style.transform = 'scale(0.95)';
}
// ── 1) Select Area (ad-hoc drag) ──
flyout.appendChild(makeFlyBtn('Select Area', '🔲', () => {
const triggerBtn = document.getElementById('gpc-' + opts.id + '-trigger');
if (triggerBtn) triggerBtn.click();
}));
// ── 2) Pick Points (click two corners) ──
flyout.appendChild(makeFlyBtn('Pick Points', '📌', () => {
startPickPointsMode(opts);
}));
// ── 3) Input Coords ──
const coordForm = document.createElement('div');
Object.assign(coordForm.style, {
display: 'flex', flexDirection: 'column', gap: '4px',
minWidth: '200px', padding: '6px 10px',
background: C.flyBg, boxShadow: C.shadow,
borderRadius: '6px', fontSize: '11px',
});
const cached = loadCachedCoords();
const _inputSt = 'width:60px;padding:2px 4px;border:1px solid '+C.inputBorder+';border-radius:4px;font-size:11px;background:'+C.inputBg+';color:'+C.inputText+';';
coordForm.innerHTML =
'<div style="font-weight:600;color:'+C.flyText+';margin-bottom:2px;">📝 Input Coords</div>' +
'<div style="display:flex;gap:4px;align-items:center;">' +
' <span style="width:22px;color:'+C.flyMuted+';font-size:10px;">NW</span>' +
' <input id="gpc-' + opts.id + '-nw-x" type="number" placeholder="X" style="'+_inputSt+'" value="' + (cached ? cached.minX : '') + '">' +
' <input id="gpc-' + opts.id + '-nw-y" type="number" placeholder="Y" style="'+_inputSt+'" value="' + (cached ? cached.maxY : '') + '">' +
'</div>' +
'<div style="display:flex;gap:4px;align-items:center;">' +
' <span style="width:22px;color:'+C.flyMuted+';font-size:10px;">SE</span>' +
' <input id="gpc-' + opts.id + '-se-x" type="number" placeholder="X" style="'+_inputSt+'" value="' + (cached ? cached.maxX : '') + '">' +
' <input id="gpc-' + opts.id + '-se-y" type="number" placeholder="Y" style="'+_inputSt+'" value="' + (cached ? cached.minY : '') + '">' +
'</div>' +
'<button id="gpc-' + opts.id + '-coord-go" style="margin-top:2px;padding:4px 8px;background:' + (opts.color || '#3b82f6') + ';color:white;border:none;border-radius:4px;font-size:11px;font-weight:600;cursor:pointer;">Go</button>';
coordForm.addEventListener('click', (e) => e.stopPropagation());
flyout.appendChild(coordForm);
// Wire the Go button after appending
setTimeout(() => {
const goBtn = document.getElementById('gpc-' + opts.id + '-coord-go');
if (goBtn) goBtn.addEventListener('click', () => {
const minX = parseInt(document.getElementById('gpc-' + opts.id + '-nw-x').value);
const maxY = parseInt(document.getElementById('gpc-' + opts.id + '-nw-y').value);
const maxX = parseInt(document.getElementById('gpc-' + opts.id + '-se-x').value);
const minY = parseInt(document.getElementById('gpc-' + opts.id + '-se-y').value);
if ([minX, maxY, maxX, minY].some(isNaN)) return;
const bounds = { minX: Math.min(minX, maxX), maxX: Math.max(minX, maxX), minY: Math.min(minY, maxY), maxY: Math.max(minY, maxY) };
saveCachedCoords(bounds);
closeFly(); closeDropdown();
const mod = opts.getModule();
if (mod && mod.processWithBounds) mod.processWithBounds(bounds);
});
}, 0);
// ── 4) Auto-screenshot toggle (screenshot only) ──
if (opts.id === 'screenshot') {
const autoBtn = document.createElement('button');
autoBtn.id = 'gpc-auto-screenshot-btn';
const isOn = isAutoScreenshotEnabled() && loadCachedCoords();
autoBtn.style.cssText = isOn ? flyBtnActiveStyle : flyBtnStyle;
autoBtn.innerHTML = '📷 Auto-save on paint';
if (isOn) autoBtn.dataset.active = '1';
autoBtn.title = 'Takes a screenshot of the cached area every time you paint. Requires Input Coords.';
autoBtn.addEventListener('mouseenter', () => { if (!autoBtn.dataset.active) autoBtn.style.background = C.flyHover; });
autoBtn.addEventListener('mouseleave', () => { if (!autoBtn.dataset.active) autoBtn.style.background = isAutoScreenshotEnabled() && loadCachedCoords() ? C.activeBg : C.flyBg; });
autoBtn.addEventListener('click', (e) => {
e.stopPropagation();
const coords = loadCachedCoords();
if (!coords) {
_gpcNotify('Set coords first (Input Coords or Pick Points).', true);
return;
}
const nowOn = !isAutoScreenshotEnabled();
setAutoScreenshot(nowOn);
autoBtn.style.cssText = nowOn ? flyBtnActiveStyle : flyBtnStyle;
if (nowOn) { autoBtn.dataset.active = '1'; autoBtn.innerHTML = '📷 Auto-save on paint ✅'; }
else { delete autoBtn.dataset.active; autoBtn.innerHTML = '📷 Auto-save on paint'; }
_gpcNotify(nowOn ? 'Auto-screenshot ON' : 'Auto-screenshot OFF');
});
flyout.appendChild(autoBtn);
}
_flyoutClosers.push(closeFly);
mainBtn.addEventListener('click', (e) => {
e.stopPropagation();
collapsePill();
const wasOpen = flyoutOpen;
closeAllFlyouts();
if (!wasOpen) {
flyoutOpen = true;
// Refresh cached coord values in inputs
const cc = loadCachedCoords();
const setVal = (suffix, val) => { const el = document.getElementById('gpc-' + opts.id + '-' + suffix); if (el) el.value = val ?? ''; };
setVal('nw-x', cc?.minX); setVal('nw-y', cc?.maxY); setVal('se-x', cc?.maxX); setVal('se-y', cc?.minY);
flyout.style.opacity = '1'; flyout.style.pointerEvents = 'auto'; flyout.style.transform = 'scale(1)';
// Refresh auto-screenshot button state
const ab = document.getElementById('gpc-auto-screenshot-btn');
if (ab) {
const isOn = isAutoScreenshotEnabled() && loadCachedCoords();
ab.style.cssText = isOn ? flyBtnActiveStyle : flyBtnStyle;
ab.innerHTML = isOn ? '📷 Auto-save on paint ✅' : '📷 Auto-save on paint';
if (isOn) ab.dataset.active = '1'; else delete ab.dataset.active;
}
}
});
document.addEventListener('click', (e) => { if (flyoutOpen && !group.contains(e.target)) closeFly(); });
group.appendChild(mainBtn);
group.appendChild(flyout);
return group;
}
// ─── Shared "Pick Points" mode ──────────────────────────────
let _pickState = null;
function startPickPointsMode(opts) {
if (_pickState) cleanupPickPoints();
const map = _getMapRef();
if (!map) { _gpcNotify('Map not ready.', true); return; }
_pickState = { opts, step: 0, markers: [], handler: null, keyHandler: null };
_gpcNotify('Click top-left corner…');
document.body.style.cursor = 'crosshair';
_pickState.handler = function(e) {
const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
const merc = turf.toMercator([e.lngLat.lng, e.lngLat.lat]);
const gx = Math.round(merc[0] / gSize);
const gy = Math.round(merc[1] / gSize);
if (_pickState.step === 0) {
_pickState.p1 = { x: gx, y: gy };
// Add marker
const el = _createPickMarker('NW', '#ef4444');
const marker = new maplibregl.Marker({ element: el }).setLngLat(e.lngLat).addTo(map);
_pickState.markers.push(marker);
_pickState.step = 1;
_gpcNotify('Click bottom-right corner…');
} else {
_pickState.p2 = { x: gx, y: gy };
const el = _createPickMarker('SE', '#3b82f6');
const marker = new maplibregl.Marker({ element: el }).setLngLat(e.lngLat).addTo(map);
_pickState.markers.push(marker);
const p1 = _pickState.p1, p2 = _pickState.p2;
const bounds = {
minX: Math.min(p1.x, p2.x), maxX: Math.max(p1.x, p2.x),
minY: Math.min(p1.y, p2.y), maxY: Math.max(p1.y, p2.y),
};
saveCachedCoords(bounds);
cleanupPickPoints();
_gpcNotify('Coords applied! (' + bounds.minX + ',' + bounds.maxY + ') → (' + bounds.maxX + ',' + bounds.minY + ')');
}
};
_pickState.keyHandler = function(e) {
if (e.key === 'Escape') { cleanupPickPoints(); _gpcNotify('Cancelled.'); }
};
map.on('click', _pickState.handler);
document.addEventListener('keydown', _pickState.keyHandler);
}
function cleanupPickPoints() {
if (!_pickState) return;
const map = _getMapRef();
if (map) {
if (_pickState.handler) map.off('click', _pickState.handler);
}
for (const m of _pickState.markers) m.remove();
if (_pickState.keyHandler) document.removeEventListener('keydown', _pickState.keyHandler);
document.body.style.cursor = '';
_pickState = null;
}
function _createPickMarker(label, color) {
const el = document.createElement('div');
el.style.cssText = 'display:flex;flex-direction:column;align-items:center;pointer-events:none;';
el.innerHTML =
'<svg width="28" height="40" viewBox="0 0 24 36"><path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="' + color + '"/><circle cx="12" cy="11" r="4.5" fill="white"/></svg>' +
'<span style="font-size:10px;font-weight:700;color:' + color + ';text-shadow:0 0 2px white,0 0 2px white;">' + label + '</span>';
return el;
}
function _getMapRef() {
try { const m = (0, eval)('map'); if (m && typeof m.setStyle === 'function') return m; } catch {}
if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.setStyle === 'function') return m; } catch {} }
return null;
}
function _gpcNotify(msg, isError) {
const existing = document.getElementById('gpc-flyout-toast'); if (existing) existing.remove();
const toast = document.createElement('div'); toast.id = 'gpc-flyout-toast'; toast.textContent = msg;
Object.assign(toast.style, { position:'fixed',top:'70px',left:'50%',transform:'translateX(-50%)',background:isError?'#fca5a5':'#bbf7d0',color:isError?'#7f1d1d':'#166534',padding:'8px 18px',borderRadius:'8px',fontSize:'13px',fontWeight:'600',zIndex:'100001',boxShadow:'0 4px 12px rgba(0,0,0,.2)',transition:'opacity .3s',fontFamily:"system-ui,sans-serif" });
document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2500);
}
// Screenshot button with flyout (only if enabled)
if (_settings.regionScreenshot) {
dropdown.appendChild(buildFeatureFlyout({
id: 'screenshot', icon: '📸', title: 'Region Screenshot',
featureKey: 'regionScreenshot', color: '#10b981',
getModule: () => _regionScreenshot,
}));
}
// Highscore button with flyout (only if enabled)
if (_settings.regionsHighscore) {
dropdown.appendChild(buildFeatureFlyout({
id: 'highscore', icon: '🏆', title: 'Region Highscore',
featureKey: 'regionsHighscore', color: '#3b82f6',
getModule: () => _regionsHighscore,
}));
}
// Theme Editor button with right-expanding flyout (only if enabled)
// Note: _themeEditor is populated later by the feature module, so we
// only check it lazily (on click), not at dropdown-build time.
if (_settings.themeEditor) {
const teGroup = document.createElement('div');
teGroup.style.cssText = 'position:relative;';
const teBtn = document.createElement('button');
teBtn.className = 'gpc-pill-btn';
teBtn.title = 'Theme Editor';
teBtn.style.cssText = 'position:relative;width:40px;height:40px;border-radius:9999px;background:'+C.pillBg+';box-shadow:'+C.shadow+';display:flex;align-items:center;justify-content:flex-start;border:none;cursor:pointer;overflow:hidden;transition:width .25s cubic-bezier(.4,0,.2,1);padding:0;font-size:16px;flex-shrink:0;';
teBtn.id = 'gpc-theme-sub';
const teIcon = document.createElement('span');
teIcon.style.cssText = 'width:40px;min-width:40px;text-align:center;flex-shrink:0;line-height:40px;';
teIcon.textContent = '🎨';
const teLabelSpan = document.createElement('span');
teLabelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:'+C.pillText+';opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
teLabelSpan.textContent = 'Theme Editor';
teBtn.appendChild(teIcon);
teBtn.appendChild(teLabelSpan);
teBtn.addEventListener('mouseenter', () => {
if (teFlyoutOpen) return;
const textW = teLabelSpan.scrollWidth + 12;
teBtn.style.width = (40 + textW) + 'px';
teLabelSpan.style.opacity = '1';
teBtn.style.background = C.pillHover;
});
teBtn.addEventListener('mouseleave', () => {
teBtn.style.width = '40px';
teLabelSpan.style.opacity = '0';
teBtn.style.background = C.pillBg;
});
function teCollapsePill() {
teBtn.style.width = '40px';
teLabelSpan.style.opacity = '0';
teBtn.style.background = C.pillBg;
}
const teFlyout = document.createElement('div');
teFlyout.id = 'gpc-theme-flyout';
Object.assign(teFlyout.style, {
position: 'absolute', left: 'calc(100% + 8px)', top: '0',
transform: 'scale(0.95)',
display: 'flex', flexDirection: 'column', gap: '3px',
transition: 'all 0.15s ease-out',
opacity: '0', pointerEvents: 'none', zIndex: '21',
});
let teFlyoutOpen = false;
let teSubPage = 0;
const TE_PER_PAGE = 4;
function _escHTML(s) { const d = document.createElement('div'); d.textContent = s; return d.innerHTML; }
function renderThemeFlyout() {
teFlyout.innerHTML = '';
if (!_themeEditor) return;
const themes = _themeEditor.loadThemes();
const activeName = _themeEditor.getActiveThemeName();
const allNames = Object.keys(themes).sort((a, b) => {
const pa = a === 'Default' ? 0 : a === 'Default Dark' ? 1 : 2;
const pb = b === 'Default' ? 0 : b === 'Default Dark' ? 1 : 2;
return pa !== pb ? pa - pb : a.localeCompare(b);
});
const totalPages = Math.max(1, Math.ceil(allNames.length / TE_PER_PAGE));
if (teSubPage >= totalPages) teSubPage = 0;
const start = teSubPage * TE_PER_PAGE;
const page = allNames.slice(start, start + TE_PER_PAGE);
for (const name of page) {
const theme = themes[name];
const isActive = name === activeName;
const btn = document.createElement('button');
Object.assign(btn.style, {
display: 'flex', alignItems: 'center', gap: '6px',
minWidth: '130px', maxWidth: '190px', padding: '4px 8px',
background: isActive ? C.teActiveBg : C.teInactiveBg,
boxShadow: C.shadow,
borderRadius: '6px', border: 'none', cursor: 'pointer',
fontSize: '11px', fontWeight: isActive ? '700' : '500',
color: isActive ? C.teBtnText : C.flyText,
whiteSpace: 'nowrap', overflow: 'hidden', textOverflow: 'ellipsis',
transition: 'background .12s', height: '28px',
});
const bgColor = theme.overrides?.['background::background-color'] || theme.overrides?.['water::fill-color'] || '#808080';
btn.innerHTML = '<span style="width:14px;height:14px;border-radius:3px;border:1px solid rgba(0,0,0,.15);flex-shrink:0;background:' + bgColor + '"></span>' + _escHTML(name);
btn.title = name;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await _themeEditor.applyThemeByName(name);
renderThemeFlyout();
});
btn.addEventListener('mouseenter', () => { if (!isActive) btn.style.background = C.teHover; });
btn.addEventListener('mouseleave', () => { if (!isActive) btn.style.background = C.teInactiveBg; });
teFlyout.appendChild(btn);
}
if (totalPages > 1) {
const nav = document.createElement('button');
Object.assign(nav.style, {
display: 'flex', alignItems: 'center', justifyContent: 'center',
minWidth: '130px', maxWidth: '190px', padding: '3px 8px',
background: C.navBg, boxShadow: C.shadow,
borderRadius: '6px', border: 'none', cursor: 'pointer',
fontSize: '10px', color: C.navText, height: '22px', transition: 'background .12s',
});
nav.textContent = '▸ ' + (teSubPage + 1) + '/' + totalPages;
nav.title = 'Next page';
nav.addEventListener('click', (e) => {
e.stopPropagation();
teSubPage = (teSubPage + 1) % totalPages;
renderThemeFlyout();
});
teFlyout.appendChild(nav);
}
// "Editor" button to open full modal
const editorBtn = document.createElement('button');
Object.assign(editorBtn.style, {
display: 'flex', alignItems: 'center', justifyContent: 'center', gap: '4px',
minWidth: '130px', maxWidth: '190px', padding: '4px 8px',
background: C.teBtnBg, boxShadow: C.shadow,
borderRadius: '6px', border: 'none', cursor: 'pointer',
fontSize: '11px', fontWeight: '600', color: C.teBtnText,
whiteSpace: 'nowrap', height: '28px', transition: 'filter .12s',
});
editorBtn.textContent = '⚙️ Editor';
editorBtn.title = 'Open full Theme Editor';
editorBtn.addEventListener('mouseenter', () => { editorBtn.style.filter = 'brightness(1.1)'; });
editorBtn.addEventListener('mouseleave', () => { editorBtn.style.filter = ''; });
editorBtn.addEventListener('click', (e) => {
e.stopPropagation();
closeDropdown();
closeFlyout();
if (_themeEditor) _themeEditor.toggleModal();
});
teFlyout.appendChild(editorBtn);
}
function closeFlyout() {
teFlyoutOpen = false;
teFlyout.style.opacity = '0';
teFlyout.style.pointerEvents = 'none';
teFlyout.style.transform = 'scale(0.95)';
}
_flyoutClosers.push(closeFlyout);
teBtn.addEventListener('click', (e) => {
e.stopPropagation();
teCollapsePill();
const wasOpen = teFlyoutOpen;
closeAllFlyouts();
if (!wasOpen) {
teFlyoutOpen = true;
renderThemeFlyout();
teFlyout.style.opacity = '1';
teFlyout.style.pointerEvents = 'auto';
teFlyout.style.transform = 'scale(1)';
}
});
document.addEventListener('click', (e) => {
if (teFlyoutOpen && !teGroup.contains(e.target)) closeFlyout();
});
teGroup.appendChild(teBtn);
teGroup.appendChild(teFlyout);
dropdown.appendChild(teGroup);
}
// Settings button (always visible)
dropdown.appendChild(makeSubBtn('⚙️', 'Settings...', createSettingsModal));
group.appendChild(mainBtn);
group.appendChild(dropdown);
// Ensure GeoPixelcons++ is always after GeoPixels++ in controls-left
function positionAfterGeoPixelsPP() {
const gppGroup = controlsLeft.querySelector('#geopixels-plusplus')?.closest('.relative');
if (gppGroup && gppGroup.nextSibling !== group) {
gppGroup.after(group);
return true;
}
return false;
}
controlsLeft.appendChild(group);
if (!positionAfterGeoPixelsPP()) {
// GeoPixels++ may not be loaded yet — watch for it
const obs = new MutationObserver(() => {
if (positionAfterGeoPixelsPP()) obs.disconnect();
});
obs.observe(controlsLeft, { childList: true, subtree: true });
// Stop watching after 30s to avoid leaks
setTimeout(() => obs.disconnect(), 30000);
}
});
// ============================================================
// FEATURE MODULES
// Each wrapped in a try/catch to set status
// ============================================================
// ============================================================
// FEATURE: Ghost Palette Color Search [ghostPaletteSearch]
// ============================================================
if (_settings.ghostPaletteSearch) {
try {
(function _init_ghostPaletteSearch() {
// Wait for the ghostColorPalette to exist
function waitForElement(selector, callback) {
const element = document.querySelector(selector);
if (element) {
callback(element);
} else {
setTimeout(() => waitForElement(selector, callback), 500);
}
}
// Add CSS for the glow effect
const style = document.createElement('style');
style.textContent = `
.color-search-glow {
box-shadow: 0 0 8px 2px rgba(255, 215, 0, 0.8) !important;
animation: pulse-glow 1.5s ease-in-out infinite;
}
@keyframes pulse-glow {
0%, 100% {
box-shadow: 0 0 8px 2px rgba(255, 215, 0, 0.8) !important;
}
50% {
box-shadow: 0 0 12px 3px rgba(255, 215, 0, 1) !important;
}
}
.color-search-container {
margin-bottom: 12px;
padding: 12px;
background: var(--color-gray-200, #f9fafb);
border-radius: 8px;
border: 1px solid var(--color-gray-300, #e5e7eb);
}
.color-search-input {
width: 100%;
padding: 8px 12px;
border: 2px solid var(--color-gray-400, #d1d5db);
border-radius: 6px;
font-size: 14px;
transition: border-color 0.2s;
background: var(--color-gray-100, #fff);
color: var(--color-gray-900, inherit);
}
.color-search-input:focus {
outline: none;
border-color: #3b82f6;
}
.color-search-input::placeholder {
color: var(--color-gray-600, #9ca3af);
}
.hide-unmatched-checkbox {
display: flex;
align-items: center;
gap: 6px;
margin-top: 8px;
font-size: 14px;
color: var(--color-gray-800, #374151);
}
.hide-unmatched-checkbox input {
width: 16px;
height: 16px;
cursor: pointer;
}
.hide-unmatched-checkbox label {
cursor: pointer;
user-select: none;
}
`;
document.head.appendChild(style);
// Main functionality
waitForElement('#ghostColorPalette', (paletteDiv) => {
// Create search container
const searchContainer = document.createElement('div');
searchContainer.className = 'color-search-container';
// Create search input
const searchInput = document.createElement('input');
searchInput.type = 'text';
searchInput.className = 'color-search-input';
searchInput.placeholder = 'Search color(s) (comma separated)';
// Create checkbox container
const checkboxContainer = document.createElement('div');
checkboxContainer.className = 'hide-unmatched-checkbox';
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.id = 'hideUnmatchedColors';
const checkboxLabel = document.createElement('label');
checkboxLabel.htmlFor = 'hideUnmatchedColors';
checkboxLabel.textContent = 'Hide unmatched colors';
checkboxContainer.appendChild(checkbox);
checkboxContainer.appendChild(checkboxLabel);
// Assemble search container
searchContainer.appendChild(searchInput);
searchContainer.appendChild(checkboxContainer);
// Insert before the palette
paletteDiv.parentNode.insertBefore(searchContainer, paletteDiv);
// Search and highlight function
function performSearch() {
const searchValue = searchInput.value.trim();
const hideUnmatched = checkbox.checked;
// Get all color buttons in the palette
const colorButtons = paletteDiv.querySelectorAll('[title^="#"]');
// Clear all previous glows and hidden states
colorButtons.forEach(btn => {
btn.classList.remove('color-search-glow');
btn.classList.remove('hidden');
});
// If search is empty, exit early
if (!searchValue) {
return;
}
// Parse search terms (comma separated)
const searchTerms = searchValue
.split(',')
.map(term => term.trim().toUpperCase())
.filter(term => term.length > 0);
if (searchTerms.length === 0) {
return;
}
// Find matching buttons
const matchingButtons = [];
colorButtons.forEach(btn => {
const colorTitle = btn.getAttribute('title');
if (!colorTitle) return;
const colorHex = colorTitle.toUpperCase();
// Check if any search term is a substring of this color
const isMatch = searchTerms.some(term => colorHex.includes(term));
if (isMatch) {
btn.classList.add('color-search-glow');
matchingButtons.push(btn);
}
});
// Hide unmatched if checkbox is selected
if (hideUnmatched && searchTerms.length > 0) {
colorButtons.forEach(btn => {
if (!matchingButtons.includes(btn)) {
btn.classList.add('hidden');
}
});
}
}
// Add event listeners
searchInput.addEventListener('input', performSearch);
checkbox.addEventListener('change', performSearch);
// Track the number of color buttons to detect palette resets
let previousButtonCount = 0;
// Also watch for dynamically added buttons
const observer = new MutationObserver(() => {
const currentButtonCount = paletteDiv.querySelectorAll('[title^="#"]').length;
// If the button count changed significantly, it's likely a new image
// Clear the search field to reset the UI
if (previousButtonCount > 0 && Math.abs(currentButtonCount - previousButtonCount) > 5) {
searchInput.value = '';
checkbox.checked = false;
}
previousButtonCount = currentButtonCount;
performSearch();
});
observer.observe(paletteDiv, {
childList: true,
subtree: true
});
// Initialize the button count
previousButtonCount = paletteDiv.querySelectorAll('[title^="#"]').length;
});
})();
_featureStatus.ghostPaletteSearch = 'ok';
console.log('[GeoPixelcons++] ✅ Ghost Palette Color Search loaded');
} catch (err) {
_featureStatus.ghostPaletteSearch = 'error';
console.error('[GeoPixelcons++] ❌ Ghost Palette Color Search failed:', err);
}
}
// ============================================================
// FEATURE: Paint Menu Controls [hidePaintMenu]
// ============================================================
if (_settings.hidePaintMenu) {
try {
(function _init_hidePaintMenu() {
const init = () => {
const bottomControls = document.getElementById('bottomControls');
const energyDisplay = document.getElementById('currentEnergyDisplay');
if (!bottomControls || !energyDisplay) {
console.log('Elements not found, retrying...');
setTimeout(init, 500);
return;
}
// --- 1. CONFIGURATION & STATE ---
let isCollapsed = false;
let isTop = false; // whether panel is docked to top
let dragOffsetX = 0; // px offset from center (persisted)
const DRAG_STORAGE_KEY = 'gpc-paint-drag-offset';
const TOP_STORAGE_KEY = 'gpc-paint-is-top';
try { dragOffsetX = parseFloat(localStorage.getItem(DRAG_STORAGE_KEY)) || 0; } catch {}
try { isTop = localStorage.getItem(TOP_STORAGE_KEY) === 'true'; } catch {}
// --- 2. CONTAINER STYLING ---
// Remove conflicting Tailwind classes
bottomControls.classList.remove('-translate-x-1/2');
bottomControls.classList.remove('left-1/2');
// Keep the original width behavior but add positioning control
bottomControls.style.position = 'fixed';
bottomControls.style.bottom = '1rem';
bottomControls.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
// Remove any width override to preserve original responsive behavior
bottomControls.style.width = '';
bottomControls.style.maxWidth = '';
// Start centered (preserve original behavior)
bottomControls.style.left = '50%';
bottomControls.style.transform = 'translateX(-50%)';
// --- 3. CREATE UI ELEMENTS ---
// A. Top bar container (holds drag handle, collapse button, reset button)
const topBar = document.createElement('div');
topBar.style.cssText = `
position: absolute;
top: -24px;
left: 0;
right: 0;
height: 24px;
display: flex;
align-items: center;
justify-content: center;
z-index: 20;
pointer-events: none;
`;
// B. Collapse Button (first, on the left)
const toggleBtn = document.createElement('button');
toggleBtn.innerHTML = '▼';
toggleBtn.id = 'gpc-hide-paint-toggle';
toggleBtn.style.cssText = `
pointer-events: auto;
width: 28px;
height: 24px;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
`;
toggleBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';
// C. Drag handle bar (to the right of collapse)
const dragBar = document.createElement('div');
dragBar.id = 'gpc-paint-drag-bar';
dragBar.style.cssText = `
pointer-events: auto;
cursor: grab;
height: 24px;
width: 28px;
border-radius: 8px 8px 0 0;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
border-bottom: none;
margin-left: 2px;
`;
dragBar.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-400 dark:text-gray-500';
dragBar.innerHTML = '<span style="font-size:10px;pointer-events:none;">⋮⋮</span>';
// D. Reset position button
const resetBtn = document.createElement('button');
resetBtn.id = 'gpc-paint-reset-pos';
resetBtn.title = 'Reset position to center';
resetBtn.innerHTML = '↺';
resetBtn.style.cssText = `
pointer-events: auto;
width: 28px;
height: 24px;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
font-size: 13px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 2px;
`;
resetBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';
// E. Flip top/bottom button
const flipBtn = document.createElement('button');
flipBtn.id = 'gpc-paint-flip-pos';
flipBtn.title = 'Move to top / bottom';
flipBtn.innerHTML = isTop ? '⬇' : '⬆';
flipBtn.style.cssText = `
pointer-events: auto;
width: 28px;
height: 24px;
border-bottom: none;
border-radius: 8px 8px 0 0;
cursor: pointer;
font-size: 12px;
display: flex;
align-items: center;
justify-content: center;
margin-left: 2px;
`;
flipBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300';
// F. Close (switch to inspect mode) button — next to energy display
const closeBtn = document.createElement('button');
closeBtn.id = 'gpc-paint-close';
closeBtn.title = 'Switch to Inspect Mode';
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `
width: 24px;
height: 24px;
border-radius: 6px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
display: inline-flex;
align-items: center;
justify-content: center;
margin-left: 6px;
flex-shrink: 0;
vertical-align: middle;
`;
closeBtn.className = 'bg-white dark:bg-gray-700 border border-gray-200 dark:border-gray-600 text-gray-500 dark:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-600';
closeBtn.addEventListener('click', () => {
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
if (typeof _pw.togglePrimaryMode === 'function') _pw.togglePrimaryMode();
});
energyDisplay.parentElement.style.display = 'flex';
energyDisplay.parentElement.style.alignItems = 'center';
energyDisplay.insertAdjacentElement('afterend', closeBtn);
topBar.appendChild(toggleBtn);
topBar.appendChild(dragBar);
topBar.appendChild(resetBtn);
topBar.appendChild(flipBtn);
bottomControls.appendChild(topBar);
// --- "Paint Here" button injected into hoverInfo ---
function injectPaintHereButton() {
if (document.getElementById('gpc-paint-here-btn')) return;
const hoverInfo = document.getElementById('hoverInfo');
if (!hoverInfo) return;
const paintBtn = document.createElement('button');
paintBtn.id = 'gpc-paint-here-btn';
paintBtn.className = 'w-full bg-green-500 text-white py-2 px-4 rounded-lg hover:bg-green-600 transition-colors flex items-center justify-center gap-2 cursor-pointer';
paintBtn.style.marginTop = '8px';
paintBtn.innerHTML = '🎨 Paint Here';
paintBtn.addEventListener('click', () => {
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
if (typeof _pw.togglePrimaryMode === 'function') _pw.togglePrimaryMode();
});
// Insert after the Share Location button's parent div
const shareBtn = document.getElementById('shareLocationBtn');
const shareContainer = shareBtn?.parentElement;
if (shareContainer) {
shareContainer.insertAdjacentElement('afterend', paintBtn);
} else {
hoverInfo.appendChild(paintBtn);
}
}
// Observe for hoverInfo appearing
const hoverObserver = new MutationObserver(() => injectPaintHereButton());
hoverObserver.observe(document.body, { childList: true, subtree: true });
injectPaintHereButton();
// Identify the two main content divs for reordering
// The first child div is the controls row (buttons, energy, etc.)
// The second is .control-container-colors (color swatches)
const innerWrapper = bottomControls.querySelector(':scope > div');
const controlsRow = innerWrapper
? innerWrapper.querySelector(':scope > .w-full.flex')
: null;
const colorsDiv = innerWrapper
? innerWrapper.querySelector(':scope > .control-container-colors')
: null;
const swapParent = controlsRow?.parentElement;
// --- 4. LOGIC ENGINE ---
const updateState = () => {
const COLLAPSE_OFFSET = 48;
// Vertical docking
if (isTop) {
bottomControls.style.bottom = 'auto';
bottomControls.style.top = '1rem';
// Reorder: colors first, controls second (buttons closer to map edge)
if (swapParent && colorsDiv && controlsRow) {
swapParent.insertBefore(colorsDiv, controlsRow);
}
// Button bar goes BELOW the panel when docked top
topBar.style.top = 'auto';
topBar.style.bottom = '-24px';
[toggleBtn, dragBar, resetBtn, flipBtn].forEach(el => {
el.style.borderRadius = '0 0 8px 8px';
el.style.borderBottom = '';
el.style.borderTop = 'none';
});
} else {
bottomControls.style.top = 'auto';
bottomControls.style.bottom = '1rem';
// Restore original order: controls first, colors second
if (swapParent && colorsDiv && controlsRow) {
swapParent.insertBefore(controlsRow, colorsDiv);
}
// Button bar goes ABOVE the panel when docked bottom
topBar.style.bottom = 'auto';
topBar.style.top = '-24px';
[toggleBtn, dragBar, resetBtn, flipBtn].forEach(el => {
el.style.borderRadius = '8px 8px 0 0';
el.style.borderBottom = 'none';
el.style.borderTop = '';
});
}
const yTransform = isCollapsed
? (isTop ? `translateY(calc(-100% + ${COLLAPSE_OFFSET}px))` : `translateY(calc(100% - ${COLLAPSE_OFFSET}px))`)
: 'translateY(0)';
bottomControls.style.left = '50%';
bottomControls.style.right = 'auto';
bottomControls.style.transform = `translateX(calc(-50% + ${dragOffsetX}px)) ${yTransform}`;
toggleBtn.innerHTML = isCollapsed
? (isTop ? '▼' : '▲')
: (isTop ? '▲' : '▼');
flipBtn.innerHTML = isTop ? '⬇' : '⬆';
};
// --- 5. DRAG LOGIC ---
let isDragging = false;
let dragStartX = 0;
let dragStartOffset = 0;
function onDragStart(e) {
isDragging = true;
dragStartX = (e.touches ? e.touches[0].clientX : e.clientX);
dragStartOffset = dragOffsetX;
dragBar.style.cursor = 'grabbing';
bottomControls.style.transition = 'none'; // disable animation while dragging
e.preventDefault();
}
function onDragMove(e) {
if (!isDragging) return;
const clientX = (e.touches ? e.touches[0].clientX : e.clientX);
dragOffsetX = dragStartOffset + (clientX - dragStartX);
// Clamp so the panel stays at least partially on screen
const halfW = bottomControls.offsetWidth / 2;
const maxOff = window.innerWidth / 2 - 60;
dragOffsetX = Math.max(-maxOff, Math.min(maxOff, dragOffsetX));
updateState();
}
function onDragEnd() {
if (!isDragging) return;
isDragging = false;
dragBar.style.cursor = 'grab';
bottomControls.style.transition = 'all 0.4s cubic-bezier(0.4, 0, 0.2, 1)';
localStorage.setItem(DRAG_STORAGE_KEY, String(dragOffsetX));
}
dragBar.addEventListener('mousedown', onDragStart);
document.addEventListener('mousemove', onDragMove);
document.addEventListener('mouseup', onDragEnd);
dragBar.addEventListener('touchstart', onDragStart, { passive: false });
document.addEventListener('touchmove', onDragMove, { passive: false });
document.addEventListener('touchend', onDragEnd);
// --- 6. EVENT LISTENERS ---
toggleBtn.addEventListener('click', () => {
isCollapsed = !isCollapsed;
updateState();
});
resetBtn.addEventListener('click', () => {
dragOffsetX = 0;
localStorage.removeItem(DRAG_STORAGE_KEY);
updateState();
});
flipBtn.addEventListener('click', () => {
isTop = !isTop;
isCollapsed = false; // expand when flipping
localStorage.setItem(TOP_STORAGE_KEY, String(isTop));
updateState();
});
// Initialize
updateState();
console.log('Bottom controls enhanced: properly centered with left/right positioning.');
};
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
_featureStatus.hidePaintMenu = 'ok';
console.log('[GeoPixelcons++] ✅ Paint Menu Controls loaded');
} catch (err) {
_featureStatus.hidePaintMenu = 'error';
console.error('[GeoPixelcons++] ❌ Paint Menu Controls failed:', err);
}
}
// ============================================================
// FEATURE: Ghost Template Manager [ghostTemplateManager]
// ============================================================
if (_settings.ghostTemplateManager) {
try {
(function _init_ghostTemplateManager() {
// ========== CONFIGURATION ==========
const DEBUG_MODE = false;
const DB_NAME = 'GP_Ghost_History';
const DB_VERSION = 3;
const STORE_NAME = 'images';
// Marker Colors for Encoding
const MARKER_R = 71;
const MARKER_G = 80;
const MARKER_B = 88;
const POSITION_OFFSET = 2147483648;
let isInternalUpdate = false;
let previewActive = false;
let previewOverlay = null;
// ========== UTILITIES ==========
function gpLog(msg, data = null) {
if (!DEBUG_MODE) return;
console.log(`%c[GP Manager] ${msg}`, "color: #00ffff; background: #000; padding: 2px 4px;", data || '');
}
// Debug: Log environment info on load
gpLog("Script loaded. Environment check:", {
hasWindow: typeof window !== 'undefined',
hasUnsafeWindow: typeof unsafeWindow !== 'undefined',
windowMap: typeof window !== 'undefined' ? typeof window.map : 'N/A',
windowTurf: typeof window !== 'undefined' ? typeof window.turf : 'N/A',
unsafeWindowMap: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.map : 'N/A',
unsafeWindowTurf: typeof unsafeWindow !== 'undefined' ? typeof unsafeWindow.turf : 'N/A'
});
/**
* Safely get a page variable, avoiding DOM element conflicts.
* In some browsers, accessing unsafeWindow.map returns the <div id="map"> element
* instead of the JavaScript map variable.
*/
function getPageVariable(varName) {
// Try window first (works in Chrome/Vivaldi)
if (typeof window !== 'undefined' && window[varName] !== undefined) {
const val = window[varName];
// Make sure it's not a DOM element when we expect an object with methods
if (varName === 'map' && val instanceof HTMLElement) {
gpLog(`window.${varName} is a DOM element, trying unsafeWindow`);
} else {
gpLog(`Found ${varName} in window`);
return val;
}
}
// Try unsafeWindow (needed in Firefox/Brave with @grant permissions)
if (typeof unsafeWindow !== 'undefined' && unsafeWindow[varName] !== undefined) {
const val = unsafeWindow[varName];
// Check if it's a DOM element when we expect the map object
if (varName === 'map' && val instanceof HTMLElement) {
gpLog(`unsafeWindow.${varName} is a DOM element, looking for alternatives`);
// Try to get the map from common Mapbox/Leaflet global patterns
// The map might be stored in a different variable or we need wrappedJSObject (Firefox)
if (typeof unsafeWindow.wrappedJSObject !== 'undefined' && unsafeWindow.wrappedJSObject[varName]) {
const wrappedVal = unsafeWindow.wrappedJSObject[varName];
if (!(wrappedVal instanceof HTMLElement)) {
gpLog(`Found ${varName} in wrappedJSObject`);
return wrappedVal;
}
}
// For Brave/Chrome with sandboxing, try accessing via page script injection
gpLog(`Attempting page context injection for ${varName}`);
return getPageVariableViaInjection(varName);
} else {
gpLog(`Found ${varName} in unsafeWindow`);
return val;
}
}
// Try wrappedJSObject directly (Firefox)
if (typeof unsafeWindow !== 'undefined' &&
typeof unsafeWindow.wrappedJSObject !== 'undefined' &&
unsafeWindow.wrappedJSObject[varName] !== undefined) {
gpLog(`Found ${varName} in wrappedJSObject`);
return unsafeWindow.wrappedJSObject[varName];
}
gpLog(`Could not find ${varName} in any scope`);
return null;
}
/**
* Get a page variable by creating a bridge in the page context.
* This is needed in Brave when @grant permissions create a sandbox.
*/
function getPageVariableViaInjection(varName) {
try {
// Create a unique ID for this retrieval
const bridgeId = `__gp_bridge_${varName}_${Date.now()}`;
// Inject a script that copies the variable to a data attribute
const script = document.createElement('script');
script.textContent = `
(function() {
if (typeof ${varName} !== 'undefined' && ${varName}) {
// Store a marker that the variable exists
document.documentElement.setAttribute('${bridgeId}', 'exists');
// For map object, we can't directly transfer it, so we'll access it differently
if ('${varName}' === 'map' && typeof ${varName}.project === 'function') {
document.documentElement.setAttribute('${bridgeId}_hasProject', 'true');
}
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
// Check if the variable exists
const exists = document.documentElement.getAttribute(bridgeId);
document.documentElement.removeAttribute(bridgeId);
document.documentElement.removeAttribute(`${bridgeId}_hasProject`);
if (exists === 'exists') {
gpLog(`${varName} exists in page context, creating proxy`);
// For the map object specifically, we need to create a proxy that executes in page context
if (varName === 'map') {
return createMapProxy();
} else if (varName === 'turf') {
return createTurfProxy();
}
}
gpLog(`${varName} not found via injection`);
return null;
} catch (e) {
gpLog(`Error in page context injection for ${varName}:`, e.message);
return null;
}
}
/**
* Create a proxy object for the map that executes methods in page context
*/
function createMapProxy() {
return {
project: function(lngLat) {
// Execute in page context and return result
const script = document.createElement('script');
const resultId = `__gp_map_result_${Date.now()}`;
script.textContent = `
(function() {
try {
const result = map.project([${lngLat[0]}, ${lngLat[1]}]);
document.documentElement.setAttribute('${resultId}', JSON.stringify({x: result.x, y: result.y}));
} catch(e) {
document.documentElement.setAttribute('${resultId}_error', e.message);
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
const resultStr = document.documentElement.getAttribute(resultId);
const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
document.documentElement.removeAttribute(resultId);
document.documentElement.removeAttribute(`${resultId}_error`);
if (errorStr) {
throw new Error(errorStr);
}
return JSON.parse(resultStr);
},
on: function(event, handler) {
gpLog(`Map event listener for ${event} registered (proxy mode)`);
// Store the handler for later use
if (!this._handlers) this._handlers = {};
if (!this._handlers[event]) this._handlers[event] = [];
this._handlers[event].push(handler);
// Set up event forwarding via page script
const listenerId = `__gp_map_listener_${event}_${Date.now()}`;
const script = document.createElement('script');
script.textContent = `
(function() {
if (typeof map !== 'undefined' && map.on) {
map.on('${event}', function() {
document.documentElement.setAttribute('${listenerId}', Date.now());
});
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
// Set up mutation observer to detect attribute changes
const observer = new MutationObserver(() => {
const val = document.documentElement.getAttribute(listenerId);
if (val) {
document.documentElement.removeAttribute(listenerId);
handler();
}
});
observer.observe(document.documentElement, { attributes: true });
},
off: function(event, handler) {
gpLog(`Map event listener for ${event} removed (proxy mode)`);
// In proxy mode, we can't easily remove specific handlers
// This is a limitation of the bridge approach
},
getContainer: function() {
return document.getElementById('map');
}
};
}
/**
* Create a proxy object for turf that executes methods in page context
*/
function createTurfProxy() {
return {
toWgs84: function(mercCoords) {
const script = document.createElement('script');
const resultId = `__gp_turf_result_${Date.now()}`;
script.textContent = `
(function() {
try {
const result = turf.toWgs84([${mercCoords[0]}, ${mercCoords[1]}]);
document.documentElement.setAttribute('${resultId}', JSON.stringify(result));
} catch(e) {
document.documentElement.setAttribute('${resultId}_error', e.message);
}
})();
`;
document.documentElement.appendChild(script);
script.remove();
const resultStr = document.documentElement.getAttribute(resultId);
const errorStr = document.documentElement.getAttribute(`${resultId}_error`);
document.documentElement.removeAttribute(resultId);
document.documentElement.removeAttribute(`${resultId}_error`);
if (errorStr) {
throw new Error(errorStr);
}
return JSON.parse(resultStr);
}
};
}
function notifyUser(title, message) {
// Use safe helper to get showAlert function
const showAlert = getPageVariable('showAlert');
if (typeof showAlert === 'function') {
showAlert(title, message);
} else {
console.log(`[${title}] ${message}`);
// Fallback alert if site's showAlert is not available
alert(`${title}: ${message}`);
}
}
function goToTemplateLocation() {
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedCoordsStr) {
notifyUser("No Template", "No ghost image template is currently set.");
return;
}
try {
const coords = JSON.parse(savedCoordsStr);
if (typeof coords.gridX !== 'number' || typeof coords.gridY !== 'number') {
notifyUser("Error", "Invalid coordinates in template.");
return;
}
// Get goToGridLocation using safe helper
const goToGridLocation = getPageVariable('goToGridLocation');
if (typeof goToGridLocation === 'function') {
gpLog(`Teleporting to template location: ${coords.gridX}, ${coords.gridY}`);
goToGridLocation(coords.gridX, coords.gridY);
} else {
notifyUser("Error", "Navigation function not available.");
gpLog("ERROR: goToGridLocation function not found in window or unsafeWindow");
}
} catch (e) {
console.error("Failed to parse coordinates:", e);
notifyUser("Error", "Failed to read template coordinates.");
}
}
// Computes a SHA-256 fingerprint of the file content
async function computeFileHash(blob) {
const buffer = await blob.arrayBuffer();
const hashBuffer = await crypto.subtle.digest('SHA-256', buffer);
const hashArray = Array.from(new Uint8Array(hashBuffer));
return hashArray.map(b => b.toString(16).padStart(2, '0')).join('');
}
// Computes a templateId from the clean image content (without position encoding)
// This allows us to identify the same template even if it's been moved to different positions
async function computeTemplateId(blob) {
try {
const img = await loadImageToCanvas(blob);
const decoded = decodeRobustPosition(img);
if (decoded && decoded.cleanCanvas) {
// If position was encoded, use the clean canvas for ID
const cleanBlob = await new Promise(r => decoded.cleanCanvas.toBlob(r, 'image/png'));
return await computeFileHash(cleanBlob);
} else {
// No position encoding found, use original hash
return await computeFileHash(blob);
}
} catch (e) {
// On error, fall back to regular hash
return await computeFileHash(blob);
}
}
// ========== STYLES ==========
const style = document.createElement('style');
style.textContent = `
.gp-to-modal-overlay {
position: fixed; inset: 0; background: rgba(0, 0, 0, 0.75);
display: flex; align-items: center; justify-content: center; z-index: 10000;
}
.gp-to-modal-panel {
background: var(--color-gray-100, white); color: var(--color-gray-900, inherit); border-radius: 1rem; padding: 1.5rem;
width: 95%; max-width: 600px; max-height: 80vh;
display: flex; flex-direction: column; gap: 1rem;
box-shadow: 0 25px 50px -12px rgba(0, 0, 0, 0.25);
}
.gp-to-header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid var(--color-gray-300, #eee); padding-bottom: 10px; }
.gp-to-title { font-size: 1.25rem; font-weight: bold; color: var(--color-gray-900, #1f2937); }
.gp-to-grid {
display: grid; grid-template-columns: repeat(auto-fill, minmax(100px, 1fr));
gap: 10px; overflow-y: auto; padding: 4px;
}
.gp-to-card {
border: 1px solid var(--color-gray-300, #e5e7eb); border-radius: 8px; overflow: hidden;
position: relative; transition: transform 0.1s, box-shadow 0.1s;
cursor: pointer; background: var(--color-gray-200, #f9fafb);
}
.gp-to-card:hover { transform: translateY(-2px); box-shadow: 0 4px 6px -1px rgba(0,0,0,0.1); border-color: #3b82f6; }
.gp-to-card img { width: 100%; height: 100px; object-fit: cover; display: block; }
.gp-to-card-footer {
padding: 4px; font-size: 10px; text-align: center;
background: var(--color-gray-100, #fff); color: var(--color-gray-500, #6b7280); white-space: nowrap; overflow: hidden; text-overflow: ellipsis;
}
.gp-to-delete-btn {
position: absolute; top: 2px; right: 2px;
background: rgba(239, 68, 68, 0.9); color: white;
border: none; border-radius: 4px; width: 20px; height: 20px;
display: flex; align-items: center; justify-content: center;
font-size: 12px; cursor: pointer; z-index: 2;
}
.gp-to-delete-btn:hover { background: #dc2626; }
.gp-to-btn {
padding: 0.5rem 1rem; border-radius: 0.5rem; font-weight: 600; cursor: pointer; border: none;
display: inline-flex; align-items: center; justify-content: center; gap: 0.5rem;
transition: all 0.2s;
}
.gp-to-btn-blue { background-color: #3b82f6; color: white; }
.gp-to-btn-blue:hover { background-color: #2563eb; }
.gp-to-btn-green { background-color: #10b981; color: white; }
.gp-to-btn-green:hover { background-color: #059669; }
.gp-to-btn-purple { background-color: #8b5cf6; color: white; }
.gp-to-btn-purple:hover { background-color: #7c3aed; }
.gp-to-btn-red { background-color: #ef4444; color: white; }
.gp-to-btn-gray { background-color: var(--color-gray-300, #e5e7eb); color: var(--color-gray-800, #374151); }
.gp-to-btn-orange { background-color: #f97316; color: white; }
.gp-to-btn-orange:hover { background-color: #ea580c; }
.gp-to-btn-cyan { background-color: #06b6d4; color: white; border: 2px solid transparent; }
.gp-to-btn-cyan:hover { background-color: #0891b2; }
.gp-to-btn-cyan.active {
background-color: #0e7490;
border: 2px solid #fbbf24;
box-shadow: 0 0 0 3px rgba(251, 191, 36, 0.3);
}
.gp-to-preview-overlay {
position: fixed;
pointer-events: none;
z-index: 9999;
opacity: 0.7;
transition: opacity 0.2s;
}
`;
document.head.appendChild(style);
// ========== INDEXED DB (CACHE) ==========
const dbPromise = new Promise((resolve, reject) => {
const request = indexedDB.open(DB_NAME, DB_VERSION);
request.onupgradeneeded = (e) => {
const db = e.target.result;
const txn = e.target.transaction;
let store;
if (!db.objectStoreNames.contains(STORE_NAME)) {
store = db.createObjectStore(STORE_NAME, { keyPath: 'id', autoIncrement: true });
} else {
store = txn.objectStore(STORE_NAME);
}
if (!store.indexNames.contains('hash')) {
store.createIndex('hash', 'hash', { unique: false });
}
if (!store.indexNames.contains('templateId')) {
store.createIndex('templateId', 'templateId', { unique: false });
}
};
request.onsuccess = (e) => resolve(e.target.result);
request.onerror = (e) => reject('DB Error');
});
const HistoryManager = {
async add(blob, filename) {
const db = await dbPromise;
const hash = await computeFileHash(blob);
const templateId = await computeTemplateId(blob);
return new Promise((resolve, reject) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
const store = tx.objectStore(STORE_NAME);
const templateIndex = store.index('templateId');
const req = templateIndex.get(templateId);
req.onsuccess = () => {
const existing = req.result;
if (existing) {
gpLog("Duplicate template detected (same image, possibly different position). Updating entry.");
store.delete(existing.id);
}
const item = {
blob: blob,
name: filename || `Image_${Date.now()}`,
date: Date.now(),
hash: hash,
templateId: templateId
};
store.add(item);
};
tx.oncomplete = () => resolve();
tx.onerror = () => reject(tx.error);
});
},
async getAll() {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readonly');
const store = tx.objectStore(STORE_NAME);
const req = store.getAll();
req.onsuccess = () => resolve(req.result.reverse());
});
},
async delete(id) {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).delete(id);
tx.oncomplete = () => resolve();
});
},
async clear() {
const db = await dbPromise;
return new Promise((resolve) => {
const tx = db.transaction(STORE_NAME, 'readwrite');
tx.objectStore(STORE_NAME).clear();
tx.oncomplete = () => resolve();
});
}
};
// ========== IMPORT/EXPORT FUNCTIONS ==========
// Helper function to convert blob to base64
function blobToBase64(blob) {
return new Promise((resolve, reject) => {
const reader = new FileReader();
reader.onloadend = () => resolve(reader.result.split(',')[1]);
reader.onerror = reject;
reader.readAsDataURL(blob);
});
}
async function exportToZip() {
gpLog("exportToZip: Starting export...");
const images = await HistoryManager.getAll();
gpLog(`exportToZip: Retrieved ${images.length} images`);
if (images.length === 0) {
notifyUser("Info", "No images to export.");
return;
}
// JSZip doesn't work in Tampermonkey sandbox - use JSON bundle instead
gpLog("exportToZip: Using JSON bundle export (JSZip incompatible with this environment)");
try {
const exportData = {
version: "3.4",
exportDate: new Date().toISOString(),
images: []
};
for (let i = 0; i < images.length; i++) {
const imgData = images[i];
gpLog(`Encoding image ${i+1}/${images.length}: ${imgData.name}`);
const base64 = await blobToBase64(imgData.blob);
exportData.images.push({
id: imgData.id,
name: imgData.name,
date: imgData.date,
hash: imgData.hash,
templateId: imgData.templateId,
imageData: base64,
mimeType: imgData.blob.type || 'image/png'
});
}
gpLog(`exportToZip: Creating download...`);
const jsonStr = JSON.stringify(exportData);
const blob = new Blob([jsonStr], { type: 'application/json' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `GeoPixels_History_${Date.now()}.json`;
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
URL.revokeObjectURL(url);
gpLog("exportToZip: Export complete");
notifyUser("Success", `Exported ${images.length} images to JSON bundle.`);
} catch (error) {
console.error("exportToZip failed:", error);
gpLog(`exportToZip: ERROR - ${error.message}`);
notifyUser("Error", "Failed to export: " + error.message);
}
}
async function importFromZip(file) {
try {
gpLog(`importFromZip: Starting import of ${file.name}`);
// Check if it's a JSON file (new format)
if (file.name.endsWith('.json')) {
gpLog("importFromZip: Detected JSON bundle format");
const text = await file.text();
const data = JSON.parse(text);
if (!data.images || !Array.isArray(data.images)) {
notifyUser("Error", "Invalid JSON: 'images' array not found.");
return;
}
let imported = 0;
for (const imgEntry of data.images) {
// Convert base64 back to blob
const byteCharacters = atob(imgEntry.imageData);
const byteNumbers = new Array(byteCharacters.length);
for (let i = 0; i < byteCharacters.length; i++) {
byteNumbers[i] = byteCharacters.charCodeAt(i);
}
const byteArray = new Uint8Array(byteNumbers);
const blob = new Blob([byteArray], { type: imgEntry.mimeType || 'image/png' });
// Check for duplicate
const existingImages = await HistoryManager.getAll();
const isDuplicate = existingImages.some(img => img.hash === imgEntry.hash);
if (!isDuplicate) {
await HistoryManager.add(blob, imgEntry.name, imgEntry.hash);
imported++;
gpLog(`Imported: ${imgEntry.name}`);
} else {
gpLog(`Skipped duplicate: ${imgEntry.name}`);
}
}
notifyUser("Success", `Imported ${imported} images from JSON bundle.`);
return;
}
// Try ZIP format (legacy) - may not work
gpLog("importFromZip: Attempting ZIP format (may fail)");
const zip = await JSZip.loadAsync(file);
const metadataFile = zip.file('metadata.json');
if (!metadataFile) {
notifyUser("Error", "Invalid ZIP: metadata.json not found.");
return;
}
const metadataText = await metadataFile.async('text');
const metadata = JSON.parse(metadataText);
let imported = 0;
for (const item of metadata) {
const imageFile = zip.file(item.filename);
if (imageFile) {
const blob = await imageFile.async('blob');
await HistoryManager.add(blob, item.name);
imported++;
}
}
notifyUser("Success", `Imported ${imported} images from ZIP.`);
return true;
} catch (e) {
console.error(e);
notifyUser("Error", "Failed to import file.");
return false;
}
}
// ========== ALGORITHM (ENCODE/DECODE) ==========
function encodeRobustPosition(originalCanvas, gridX, gridY) {
const width = originalCanvas.width;
const height = originalCanvas.height;
const newCanvas = document.createElement('canvas');
newCanvas.width = width;
newCanvas.height = height + 1;
const ctx = newCanvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(originalCanvas, 0, 1);
const headerImage = ctx.getImageData(0, 0, width, 1);
const data = headerImage.data;
const valX = (gridX + POSITION_OFFSET) >>> 0;
const valY = (gridY + POSITION_OFFSET) >>> 0;
const packetSize = 5;
const maxPackets = Math.floor(width / packetSize);
for (let i = 0; i < maxPackets; i++) {
const base = (i * packetSize) * 4;
data[base] = MARKER_R; data[base + 1] = MARKER_G; data[base + 2] = MARKER_B; data[base + 3] = 255;
data[base + 4] = (valX >>> 24) & 0xFF; data[base + 5] = (valX >>> 16) & 0xFF; data[base + 6] = 0; data[base + 7] = 255;
data[base + 8] = (valX >>> 8) & 0xFF; data[base + 9] = valX & 0xFF; data[base + 10] = 0; data[base + 11] = 255;
data[base + 12] = (valY >>> 24) & 0xFF; data[base + 13] = (valY >>> 16) & 0xFF; data[base + 14] = 0; data[base + 15] = 255;
data[base + 16] = (valY >>> 8) & 0xFF; data[base + 17] = valY & 0xFF; data[base + 18] = 0; data[base + 19] = 255;
}
ctx.putImageData(headerImage, 0, 0);
return newCanvas;
}
function decodeRobustPosition(img) {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const ctx = canvas.getContext('2d', { willReadFrequently: true });
ctx.drawImage(img, 0, 0);
const headerData = ctx.getImageData(0, 0, img.width, 1).data;
const votesX = new Map();
const votesY = new Map();
let validPackets = 0;
const packetSize = 5;
const maxPackets = Math.floor(img.width / packetSize);
for (let i = 0; i < maxPackets; i++) {
const base = (i * packetSize) * 4;
if (headerData[base] === MARKER_R && headerData[base + 1] === MARKER_G && headerData[base + 2] === MARKER_B && headerData[base + 3] === 255) {
const xVal = ((headerData[base + 4] << 24) | (headerData[base + 5] << 16) | (headerData[base + 8] << 8) | headerData[base + 9]) >>> 0;
const yVal = ((headerData[base + 12] << 24) | (headerData[base + 13] << 16) | (headerData[base + 16] << 8) | headerData[base + 17]) >>> 0;
votesX.set(xVal, (votesX.get(xVal) || 0) + 1);
votesY.set(yVal, (votesY.get(yVal) || 0) + 1);
validPackets++;
}
}
if (validPackets === 0) return null;
const getWinner = (map) => [...map.entries()].reduce((a, b) => b[1] > a[1] ? b : a)[0];
const gridX = getWinner(votesX) - POSITION_OFFSET;
const gridY = getWinner(votesY) - POSITION_OFFSET;
const cleanCanvas = document.createElement('canvas');
cleanCanvas.width = img.width;
cleanCanvas.height = img.height - 1;
const cleanCtx = cleanCanvas.getContext('2d');
cleanCtx.drawImage(canvas, 0, 1, img.width, img.height - 1, 0, 0, img.width, img.height - 1);
return { gridX, gridY, cleanCanvas };
}
// ========== PREVIEW FUNCTIONALITY ==========
let previewImageCache = null;
let previewRenderHandler = null;
function drawPreviewImageOnCanvas() {
gpLog("drawPreviewImageOnCanvas called");
if (!previewOverlay) {
gpLog("No preview overlay, returning");
return;
}
if (!previewActive) {
gpLog("Preview not active, returning");
return;
}
const savedImageData = localStorage.getItem('ghostImageData');
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedCoordsStr || !savedImageData) {
gpLog("Missing ghost image data or coords in localStorage");
return;
}
const coords = JSON.parse(savedCoordsStr);
gpLog("Ghost coords", coords);
// Use cached image to avoid reloading
if (!previewImageCache || previewImageCache.src !== savedImageData) {
previewImageCache = new Image();
previewImageCache.src = savedImageData;
gpLog("Loading new preview image");
}
const img = previewImageCache;
if (!img.complete) {
gpLog("Image not loaded yet, waiting...");
img.onload = () => {
gpLog("Image loaded, redrawing");
drawPreviewImageOnCanvas();
};
return;
}
gpLog("Image loaded, dimensions:", { width: img.width, height: img.height });
// Get required game variables
const pixelCanvas = document.getElementById('pixel-canvas');
if (!pixelCanvas) {
gpLog("ERROR: pixel-canvas not found");
return;
}
// Match canvas size to pixel canvas
if (previewOverlay.width !== pixelCanvas.width || previewOverlay.height !== pixelCanvas.height) {
previewOverlay.width = pixelCanvas.width;
previewOverlay.height = pixelCanvas.height;
gpLog("Resized preview canvas to", { width: pixelCanvas.width, height: pixelCanvas.height });
}
const ctx = previewOverlay.getContext('2d');
const { width, height } = previewOverlay;
ctx.clearRect(0, 0, width, height);
gpLog("Cleared canvas");
// Get map and turf using safe helper to avoid DOM element conflicts
const map = getPageVariable('map');
const turf = getPageVariable('turf');
// gridSize is often 25 (standard grid size for geopixels)
// Try to get from page, fallback to defaults
let gridSize = getPageVariable('gridSize') || 25;
let halfSize = getPageVariable('halfSize') || (gridSize / 2);
let offsetMetersX = getPageVariable('offsetMetersX') || 0;
let offsetMetersY = getPageVariable('offsetMetersY') || 0;
gpLog("Grid values:", { gridSize, halfSize, offsetMetersX, offsetMetersY });
if (!map || !turf) {
gpLog("ERROR: Missing required variables", {
hasMap: !!map,
hasTurf: !!turf,
gridSize: gridSize
});
return;
}
if (typeof map.project !== 'function') {
gpLog("ERROR: map.project is not a function", { mapType: typeof map });
return;
}
// Calculate corners using the SAME method as the game's drawGhostImageOnCanvas
// Top-left pixel center
const tl_pixel_center_x = coords.gridX * gridSize;
const tl_pixel_center_y = coords.gridY * gridSize;
// Top-left mercator edge
const tl_merc_edge = [
tl_pixel_center_x - halfSize + offsetMetersX,
tl_pixel_center_y + halfSize + offsetMetersY
];
// Bottom-right grid coordinates
const br_pixel_gridX = coords.gridX + img.width - 1;
const br_pixel_gridY = coords.gridY - img.height + 1;
const br_pixel_center_x = br_pixel_gridX * gridSize;
const br_pixel_center_y = br_pixel_gridY * gridSize;
// Bottom-right mercator edge
const br_merc_edge = [
br_pixel_center_x + halfSize + offsetMetersX,
br_pixel_center_y - halfSize + offsetMetersY
];
gpLog("Mercator coords (ghost method)", { tl_merc_edge, br_merc_edge });
// Convert to WGS84 and then project to screen
const topLeftScreen = map.project(turf.toWgs84(tl_merc_edge));
const bottomRightScreen = map.project(turf.toWgs84(br_merc_edge));
gpLog("Screen coords", { topLeftScreen, bottomRightScreen });
const drawX = topLeftScreen.x;
const drawY = topLeftScreen.y;
const screenWidth = bottomRightScreen.x - drawX;
const screenHeight = bottomRightScreen.y - drawY;
gpLog("Draw position and dimensions", { drawX, drawY, screenWidth, screenHeight });
// Check if visible
if (drawX + screenWidth < 0 ||
drawX > width ||
drawY + screenHeight < 0 ||
drawY > height) {
gpLog("Image not in viewport, skipping draw");
return;
}
// Draw fully opaque
ctx.imageSmoothingEnabled = false;
ctx.drawImage(img, drawX, drawY, screenWidth, screenHeight);
gpLog("Drew preview image successfully");
}
function togglePreview(button) {
gpLog("togglePreview called, current state:", previewActive);
gpLog("Button click - environment check:", {
windowExists: typeof window !== 'undefined',
unsafeWindowExists: typeof unsafeWindow !== 'undefined',
windowKeys: typeof window !== 'undefined' ? Object.keys(window).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : [],
unsafeWindowKeys: typeof unsafeWindow !== 'undefined' ? Object.keys(unsafeWindow).filter(k => k.includes('map') || k.includes('turf')).slice(0, 10) : []
});
if (previewActive) {
// Deactivate preview
gpLog("Deactivating preview");
if (previewOverlay && previewOverlay.parentNode) {
previewOverlay.parentNode.removeChild(previewOverlay);
gpLog("Removed preview overlay from DOM");
}
// Unhook from map events
if (previewRenderHandler) {
const map = getPageVariable('map');
if (map && typeof map.off === 'function') {
try {
map.off('move', previewRenderHandler);
map.off('zoom', previewRenderHandler);
map.off('rotate', previewRenderHandler);
gpLog("Removed map event listeners");
} catch (e) {
gpLog("Error removing map listeners", e);
}
}
}
previewOverlay = null;
previewImageCache = null;
previewRenderHandler = null;
previewActive = false;
button.innerHTML = '👁️ Preview';
button.classList.remove('active');
gpLog("Preview deactivated");
} else {
// Activate preview
gpLog("Activating preview");
const savedImageData = localStorage.getItem('ghostImageData');
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
if (!savedImageData || !savedCoordsStr) {
gpLog("ERROR: No ghost image data in localStorage");
notifyUser("Error", "No ghost image on map to preview.");
return;
}
gpLog("Found ghost data in localStorage");
// Find the pixel canvas to match its size
const pixelCanvas = document.getElementById('pixel-canvas');
if (!pixelCanvas) {
gpLog("ERROR: pixel-canvas not found");
notifyUser("Error", "Pixel canvas not found. Make sure you're on the map view.");
return;
}
gpLog("Found pixel canvas", { width: pixelCanvas.width, height: pixelCanvas.height });
// Verify map exists
const map = getPageVariable('map');
if (!map) {
gpLog("ERROR: map not found in any scope");
notifyUser("Error", "Map not initialized yet. Please wait a moment and try again.");
return;
}
gpLog("Map object found", {
mapType: typeof map,
hasProject: typeof map.project,
isHTMLElement: map instanceof HTMLElement,
constructor: map.constructor ? map.constructor.name : 'unknown'
});
if (typeof map.project !== 'function') {
gpLog("ERROR: map.project is not a function", {
mapType: typeof map,
projectType: typeof map.project,
mapKeys: Object.keys(map).slice(0, 20),
mapConstructor: map.constructor ? map.constructor.name : 'unknown'
});
notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
return;
}
gpLog("map.project verified as function");
// Verify turf exists
const turf = getPageVariable('turf');
if (!turf) {
gpLog("ERROR: turf not found in any scope");
notifyUser("Error", "Turf.js library not loaded. Page may not be fully loaded.");
return;
}
gpLog("Turf object found", { turfType: typeof turf, hasToWgs84: typeof turf.toWgs84 });
if (typeof turf.toWgs84 !== 'function') {
gpLog("ERROR: turf.toWgs84 is not a function", {
turfType: typeof turf,
toWgs84Type: typeof turf.toWgs84,
turfKeys: Object.keys(turf).slice(0, 20)
});
notifyUser("Error", "Map projection not available. Page may not be fully loaded.");
return;
}
gpLog("turf.toWgs84 verified as function");
gpLog("Map and turf are ready with required functions");
// Create preview canvas
previewOverlay = document.createElement('canvas');
previewOverlay.id = 'gp-preview-canvas';
previewOverlay.className = 'pixel-perfect';
previewOverlay.width = pixelCanvas.width;
previewOverlay.height = pixelCanvas.height;
previewOverlay.style.cssText = 'display: block; image-rendering: pixelated; position: absolute; top: 0; left: 0; pointer-events: none; z-index: 5;';
gpLog("Created preview canvas element");
// Insert into DOM - find the map container
const mapContainer = map.getContainer ? map.getContainer() : document.getElementById('map');
if (mapContainer) {
mapContainer.appendChild(previewOverlay);
gpLog("Appended preview canvas to map container");
} else {
document.body.appendChild(previewOverlay);
gpLog("Appended preview canvas to body (fallback)");
}
previewActive = true;
button.innerHTML = '👁️ Hide Preview';
button.classList.add('active');
// Create render handler
previewRenderHandler = () => {
gpLog("Map event triggered, redrawing preview");
drawPreviewImageOnCanvas();
};
// Hook into map events (same as geopixels++)
try {
map.on('move', previewRenderHandler);
map.on('zoom', previewRenderHandler);
map.on('rotate', previewRenderHandler);
gpLog("Attached to map events");
} catch (e) {
gpLog("ERROR attaching map listeners", e);
}
// Render once immediately
gpLog("Drawing initial preview");
drawPreviewImageOnCanvas();
gpLog("Preview activated successfully");
}
}
/**
* Replicates the logic of the 'Save Pos' button to cache the currently placed ghost image.
* This function is available globally but is no longer called automatically.
*/
async function cacheCurrentGhostPosition() {
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
const savedImageData = localStorage.getItem('ghostImageData');
if (!savedCoordsStr || !savedImageData) {
gpLog("Auto-Cache: No ghost image on map or coordinates found.");
return;
}
gpLog("Auto-Cache: Starting cache process.");
const coords = JSON.parse(savedCoordsStr);
const img = new Image();
img.src = savedImageData;
await new Promise(r => img.onload = r);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.height = img.height;
tempCanvas.getContext('2d').drawImage(img, 0, 0);
const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
encodedCanvas.toBlob(async (blob) => {
if(!blob) return;
// Save to History (Cache)
try {
await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`);
gpLog("Auto-Cache: Cached image with position data.");
notifyUser("Auto-Cache", `Ghost image position ${coords.gridX}, ${coords.gridY} auto-cached.`);
} catch (e) {
console.error("Auto-Cache failed", e);
notifyUser("Auto-Cache Error", "Failed to auto-cache the image position.");
}
}, 'image/png');
}
// Expose for direct use if needed, but primarily used internally now
window.cacheCurrentGhostPosition = cacheCurrentGhostPosition;
// ========== GAME INTEGRATION ==========
function applyCoordinatesToGame(coords) {
gpLog("Applying coordinates...", coords);
let attempts = 0;
const interval = setInterval(() => {
const placeBtn = document.getElementById('initiatePlaceGhostBtn');
if (placeBtn && !placeBtn.disabled) {
clearInterval(interval);
localStorage.setItem('ghostImageCoords', JSON.stringify(coords));
// Get function using safe helper
const initializeGhostFromStorage = getPageVariable('initializeGhostFromStorage');
if (typeof initializeGhostFromStorage === 'function') {
gpLog("Calling initializeGhostFromStorage to place template");
initializeGhostFromStorage();
notifyUser("Auto-Place", `Position detected: ${coords.gridX}, ${coords.gridY}`);
} else {
gpLog("ERROR: initializeGhostFromStorage function not found");
notifyUser("Warning", `Position set to ${coords.gridX}, ${coords.gridY} but auto-place failed. Click 'Place on Map' manually.`);
}
}
if (++attempts > 50) {
clearInterval(interval);
gpLog("Timeout waiting for place button to be ready");
}
}, 100);
}
async function loadImageToCanvas(blob) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve(img);
img.onerror = reject;
img.src = URL.createObjectURL(blob);
});
}
// ========== PROCESSING LOGIC ==========
async function processAndLoadImage(file, saveToHistory = true) {
gpLog("Processing image...");
const placeBtn = document.getElementById('initiatePlaceGhostBtn');
if (placeBtn) { placeBtn.innerText = "Analyzing..."; placeBtn.disabled = true; }
try {
const img = await loadImageToCanvas(file);
const decoded = decodeRobustPosition(img);
let finalFile = file;
let coords = null;
if (decoded) {
gpLog("Found encoded position.", { gridX: decoded.gridX, gridY: decoded.gridY });
coords = { gridX: decoded.gridX, gridY: decoded.gridY };
const cleanBlob = await new Promise(r => decoded.cleanCanvas.toBlob(r, 'image/png'));
finalFile = new File([cleanBlob], file.name || "ghost.png", { type: "image/png" });
} else {
gpLog("No encoded position found in image");
}
if (saveToHistory) {
await HistoryManager.add(file, file.name);
}
const input = document.getElementById('ghostImageInput');
const dt = new DataTransfer();
dt.items.add(finalFile);
input.files = dt.files;
isInternalUpdate = true;
input.dispatchEvent(new Event('change', { bubbles: true }));
isInternalUpdate = false;
// Wait for the game to process the image first
await new Promise(resolve => setTimeout(resolve, 100));
if (coords) {
gpLog("Applying coordinates to game", coords);
applyCoordinatesToGame(coords);
} else {
// Clear old coordinates if this template has no encoded position
localStorage.removeItem('ghostImageCoords');
gpLog("No encoded position found, cleared old coordinates");
}
} catch (e) {
console.error(e);
notifyUser("Error", "Failed to process image.");
} finally {
if (placeBtn) placeBtn.innerText = "Place on Map";
}
}
// ========== INTERCEPTOR ==========
function setupNativeInterceptor() {
const input = document.getElementById('ghostImageInput');
if (!input) return;
// 3. Add .zip to the file input's accepted types
input.setAttribute('accept', 'image/png, image/jpeg, image/webp, image/gif, application/zip, .zip');
input.addEventListener('change', async (e) => {
if (isInternalUpdate) return;
const file = e.target.files[0];
if (!file) return;
e.stopImmediatePropagation();
e.preventDefault();
// Check if it's a ZIP file
if (file.type === 'application/zip' || file.type === 'application/x-zip-compressed' || file.name.toLowerCase().endsWith('.zip')) {
gpLog("Detected ZIP file upload");
const success = await importFromZip(file);
if (success) {
// Clear the input so same file can be uploaded again
input.value = '';
}
return;
}
// Otherwise process as image
processAndLoadImage(file, false);
}, true);
}
// ========== UI HANDLERS ==========
async function handleUrlUpload() {
const url = prompt("Enter Image or ZIP URL:");
if (!url) return;
try {
// Use GM_xmlhttpRequest to bypass CSP restrictions
const blob = await new Promise((resolve, reject) => {
GM_xmlhttpRequest({
method: 'GET',
url: url,
responseType: 'blob',
onload: (response) => {
if (response.status >= 200 && response.status < 300) {
resolve(response.response);
} else {
reject(new Error(`HTTP ${response.status}: ${response.statusText}`));
}
},
onerror: (error) => {
reject(new Error('Failed to fetch URL'));
},
ontimeout: () => {
reject(new Error('Request timed out'));
}
});
});
// Check if it's a ZIP file
if (blob.type === 'application/zip' || blob.type === 'application/x-zip-compressed' || url.toLowerCase().endsWith('.zip')) {
gpLog("Detected ZIP file from URL");
await importFromZip(blob);
notifyUser("Success", "Imported cache from URL!");
return;
}
// Otherwise treat as image
if (!blob.type.startsWith('image/')) throw new Error("Invalid image");
processAndLoadImage(new File([blob], "url_upload.png", { type: blob.type }), false);
} catch (e) {
console.error(e);
notifyUser("Error", "Could not load file from URL: " + e.message);
}
}
async function downloadWithPos() {
const savedImageData = localStorage.getItem('ghostImageData');
if (!savedImageData) {
notifyUser("Error", "No ghost image loaded.");
return;
}
const savedCoordsStr = localStorage.getItem('ghostImageCoords');
const img = new Image();
img.src = savedImageData;
await new Promise(r => img.onload = r);
const tempCanvas = document.createElement('canvas');
tempCanvas.width = img.width; tempCanvas.height = img.height;
tempCanvas.getContext('2d').drawImage(img, 0, 0);
if (savedCoordsStr) {
// If coordinates exist, encode them and save
const coords = JSON.parse(savedCoordsStr);
const encodedCanvas = encodeRobustPosition(tempCanvas, coords.gridX, coords.gridY);
encodedCanvas.toBlob(async (blob) => {
if(!blob) return;
// Save to History (Cache)
try {
await HistoryManager.add(blob, `Backup_${coords.gridX}_${coords.gridY}`);
gpLog("Cached image with position data");
notifyUser("Success", "Template saved to history!");
} catch (e) {
console.error("Cache failed", e);
notifyUser("Error", "Failed to save template");
}
}, 'image/png');
} else {
// No coordinates: just save the image as-is
tempCanvas.toBlob(async (blob) => {
if(!blob) return;
try {
await HistoryManager.add(blob, `Image_${Date.now()}`);
gpLog("Cached image without position data");
notifyUser("Success", "Template saved to history!");
} catch (e) {
console.error("Cache failed", e);
notifyUser("Error", "Failed to save template");
}
}, 'image/png');
}
}
async function openHistoryModal() {
const existing = document.getElementById('gp-history-modal');
if (existing) existing.remove();
const images = await HistoryManager.getAll();
const modal = document.createElement('div');
modal.id = 'gp-history-modal';
modal.className = 'gp-to-modal-overlay';
modal.innerHTML = `
<div class="gp-to-modal-panel">
<div class="gp-to-header">
<span class="gp-to-title">Image History (${images.length})</span>
<div class="flex gap-2">
<button id="gp-export-zip" class="gp-to-btn gp-to-btn-orange text-xs">💾 Export JSON</button>
<button id="gp-import-zip" class="gp-to-btn gp-to-btn-green text-xs">📁 Import JSON</button>
<button id="gp-clear-all" class="gp-to-btn gp-to-btn-red text-xs">Clear All</button>
<button id="gp-close-hist" class="gp-to-btn gp-to-btn-gray">Close</button>
</div>
</div>
<div class="gp-to-grid" id="gp-history-grid">
${images.length === 0 ? '<p class="p-4 text-gray-500 col-span-full text-center">No images found.</p>' : ''}
</div>
</div>
`;
document.body.appendChild(modal);
const grid = modal.querySelector('#gp-history-grid');
images.forEach(imgData => {
const card = document.createElement('div');
card.className = 'gp-to-card';
card.innerHTML = `
<button class="gp-to-delete-btn" title="Delete">✖</button>
<img src="${URL.createObjectURL(imgData.blob)}" />
<div class="gp-to-card-footer">${new Date(imgData.date).toLocaleTimeString()} - ${imgData.name.substring(0,12)}</div>
`;
card.onclick = (e) => {
if (e.target.closest('.gp-to-delete-btn')) return;
processAndLoadImage(imgData.blob, false);
modal.remove();
};
card.querySelector('.gp-to-delete-btn').onclick = async () => {
await HistoryManager.delete(imgData.id);
card.remove();
};
grid.appendChild(card);
});
modal.querySelector('#gp-export-zip').onclick = async () => {
await exportToZip();
};
modal.querySelector('#gp-import-zip').onclick = () => {
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json, .zip, application/json, application/zip'; // Accept JSON (new) and ZIP (legacy)
input.onchange = async (e) => {
const file = e.target.files[0];
if (file) {
await importFromZip(file);
modal.remove();
openHistoryModal(); // Refresh the modal
}
};
input.click();
};
modal.querySelector('#gp-clear-all').onclick = async () => {
if(confirm("Clear all cached images?")) {
await HistoryManager.clear();
modal.remove();
}
};
modal.querySelector('#gp-close-hist').onclick = () => modal.remove();
}
// ========== INJECTION ==========
/**
* Watches the document for the coordinate-setting success message
* and triggers the auto-cache function.
* This addresses issue #2.
*/
function setupAlertBodyObserver() {
const targetNode = document.getElementById('alertBody');
if (!targetNode) {
gpLog("Could not find alertBody for position observer.");
return;
}
const observer = new MutationObserver((mutationsList, observer) => {
for(const mutation of mutationsList) {
if (mutation.type === 'childList' || mutation.type === 'characterData') {
const textContent = targetNode.textContent;
if (textContent && textContent.includes("Ghost image position set")) {
gpLog("Detected 'Ghost image position set'. Triggering auto-cache.");
cacheCurrentGhostPosition();
// Disconnect after first success to avoid spamming the cache,
// as a new observer will be created when the modal is opened next.
observer.disconnect();
break;
}
}
}
});
// Start observing the target node for configured mutations
const config = { childList: true, subtree: true, characterData: true };
observer.observe(targetNode, config);
}
function injectControls() {
const modal = document.getElementById('ghostImageModal');
if (!modal) return;
const container = modal.querySelector('.flex.flex-wrap.items-center.justify-center.gap-3');
if (!container || container.dataset.gpInjected) return;
container.dataset.gpInjected = "true";
// 1. Remove the 'hidden' class from the hexDisplay span
const hexDisplay = document.getElementById('hexDisplay');
if (hexDisplay) {
hexDisplay.classList.remove('hidden');
gpLog("Removed 'hidden' class from hexDisplay.");
}
setupNativeInterceptor();
const btnUrl = document.createElement('button');
btnUrl.innerHTML = '🔗 URL'; btnUrl.className = 'gp-to-btn gp-to-btn-blue shadow';
btnUrl.title = 'Load from URL (Image or ZIP)';
btnUrl.onclick = handleUrlUpload;
const btnLocal = document.createElement('button');
btnLocal.innerHTML = '📂 File'; btnLocal.className = 'gp-to-btn gp-to-btn-green shadow';
btnLocal.title = 'Upload Image or ZIP';
// Note: The click handler for this just triggers the native input, which we intercept.
btnLocal.onclick = () => document.getElementById('ghostImageInput').click();
const btnHist = document.createElement('button');
btnHist.innerHTML = '📜 History'; btnHist.className = 'gp-to-btn gp-to-btn-purple shadow';
btnHist.onclick = openHistoryModal;
const btnDL = document.createElement('button');
btnDL.innerHTML = '💾 Save'; btnDL.className = 'gp-to-btn gp-to-btn-gray shadow';
btnDL.onclick = downloadWithPos;
const btnPreview = document.createElement('button');
btnPreview.innerHTML = '👁️ Preview';
btnPreview.className = 'gp-to-btn gp-to-btn-cyan shadow';
btnPreview.title = 'Toggle image preview overlay';
btnPreview.onclick = () => togglePreview(btnPreview);
const btnGoTo = document.createElement('button');
btnGoTo.innerHTML = '🎯 Go To';
btnGoTo.className = 'gp-to-btn gp-to-btn-orange shadow';
btnGoTo.title = 'Teleport to template location';
btnGoTo.onclick = goToTemplateLocation;
container.prepend(btnGoTo);
container.prepend(btnPreview);
container.prepend(btnDL);
container.prepend(btnHist);
container.prepend(btnLocal);
container.prepend(btnUrl);
// Auto-caching disabled - user must manually press Save Pos button
// setupAlertBodyObserver();
}
const observer = new MutationObserver(() => injectControls());
observer.observe(document.body, { childList: true, subtree: true });
document.querySelector('label[for="ghostImageInput"]')?.classList.add('hidden');
gpLog("GeoPixels Ultimate Ghost Template Manager v3.4 Loaded (with uint8array ZIP fix)");
})();
_featureStatus.ghostTemplateManager = 'ok';
console.log('[GeoPixelcons++] ✅ Ghost Template Manager loaded');
} catch (err) {
_featureStatus.ghostTemplateManager = 'error';
console.error('[GeoPixelcons++] ❌ Ghost Template Manager failed:', err);
}
}
// ============================================================
// FEATURE: Guild Overhaul [guildOverhaul]
// ============================================================
if (_settings.guildOverhaul) {
try {
(function _init_guildOverhaul() {
// --- Configuration & State ---
const CONFIG = {
debugMode: false,
timeOffset: GM_getValue('debug_time_offset', 0),
minSnapshotInterval: GM_getValue('min_snapshot_interval', 60 * 60 * 1000),
maxSnapshots: GM_getValue('max_snapshots', 750)
};
const SNAPSHOT_INTERVALS = {
HOURLY: 60 * 60 * 1000,
TWELVE_HOURS: 12 * 60 * 60 * 1000,
TWENTY_FOUR_HOURS: 24 * 60 * 60 * 1000
};
const sessionState = {
visitedCoords: new Set()
};
// --- Territory Overlay State ---
const TERRITORY_STORAGE_KEY = 'guildOverhaul_territorySettings';
let territoryCanvas = null;
let territoryVisible = false;
let territoryRects = []; // Array of { gridX, gridY, width, height, label }
let territoryActivityMap = {}; // Map of rect.index → boolean (has active players)
let territorySettings = loadTerritorySettings();
// --- Player Markers Overlay State ---
const PLAYER_STORAGE_KEY = 'guildOverhaul_playerSettings';
let playersContainer = null;
let playersVisible = false;
let playerMarkerData = []; // Array of { name, gridX, gridY, element, inTerritory }
let playersShowNames = false;
let playersColorByTerritory = true;
let playersShowInTerritory = true;
let playersShowOutsideTerritory = true;
let playerSettings = loadPlayerSettings();
function loadPlayerSettings() {
try {
const stored = GM_getValue(PLAYER_STORAGE_KEY, null);
if (stored) return stored;
} catch (e) {}
return {
markerSize: 28,
labelFontSize: 11,
defaultColor: '#ef4444',
territoryColor: '#3b82f6'
};
}
function savePlayerSettings() {
GM_setValue(PLAYER_STORAGE_KEY, playerSettings);
}
function loadTerritorySettings() {
try {
const stored = GM_getValue(TERRITORY_STORAGE_KEY, null);
if (stored) return stored;
} catch (e) {}
return {
borderColor: '#3b82f6',
borderThickness: 2,
showLabels: true,
labelFontSize: 12,
showFill: true,
fillColor: '#3b82f6',
fillAlpha: 0.15,
colorByActivity: false,
activeBorderColor: '#22c55e',
activeFillColor: '#22c55e',
abandonedBorderColor: '#6b7280',
abandonedFillColor: '#6b7280'
};
}
function saveTerritorySettings() {
GM_setValue(TERRITORY_STORAGE_KEY, territorySettings);
}
// --- CSS Styles ---
// --- CSS Styles (Tailwind-compatible for geopixels++ dark theme) ---
const style = document.createElement('style');
style.textContent = `
.guild-modal-header {
touch-action: none !important;
-webkit-user-select: none !important;
user-select: none !important;
}
.guild-modal-header span {
touch-action: none !important;
-webkit-user-select: none !important;
user-select: none !important;
display: block;
flex: 1;
padding-right: 10px;
}
.draggable-panel {
touch-action: none !important;
}
/* Use Tailwind CSS variables for dark mode compatibility */
.guild-message-section {
border: 1px solid var(--color-gray-200, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
background-color: var(--color-white, #fff);
}
.guild-message-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: var(--color-gray-50, #f9fafb);
cursor: pointer;
user-select: none;
color: var(--color-gray-900, #111827);
}
.guild-message-header:hover {
background-color: var(--color-gray-100, #f3f4f6);
}
.guild-message-toggle {
display: inline-block;
width: 20px;
height: 20px;
text-align: center;
line-height: 20px;
font-weight: bold;
color: var(--color-gray-500, #6b7280);
transition: transform 0.2s ease;
}
.guild-message-toggle.collapsed {
transform: rotate(-90deg);
}
.guild-message-content {
max-height: 500px;
overflow: hidden;
transition: max-height 0.3s ease, padding 0.3s ease;
padding: 0.75rem;
background-color: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
}
.guild-message-content.collapsed {
max-height: 0;
padding: 0;
}
@media (max-width: 1024px) {
#infoTab .grid.grid-cols-1.lg\\:grid-cols-3 {
grid-template-columns: 1fr !important;
}
#infoTab .lg\\:col-span-2 { grid-column: auto !important; }
#infoTab .lg\\:col-span-1 { grid-column: auto !important; order: 1; }
#infoTab > .grid { display: flex; flex-direction: column; }
#guildMembersContainer { order: 1; margin-top: 2rem; }
}
#infoTab.message-collapsed > .grid { display: block; }
#infoTab.message-collapsed #guildMembersContainer { margin-top: 1rem; }
.guild-find-btn.visited { background-color: var(--color-purple-500, #a855f7) !important; }
.guild-find-btn.visited:hover { background-color: var(--color-purple-600, #9333ea) !important; }
.xp-changes-section {
margin-top: 1.5rem;
border: 1px solid var(--color-gray-200, #e5e7eb);
border-radius: 0.5rem;
overflow: hidden;
width: 100%;
background-color: var(--color-white, #fff);
}
.xp-changes-header {
display: flex;
align-items: center;
justify-content: space-between;
padding: 0.75rem;
background-color: var(--color-gray-100, #f1f5f9);
cursor: pointer;
user-select: none;
font-weight: 600;
color: var(--color-gray-700, #334155);
}
.xp-changes-header:hover { background-color: var(--color-gray-200, #e2e8f0); }
.xp-changes-content {
padding: 1rem;
background-color: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
display: block;
}
.xp-changes-content.hidden { display: none; }
.daily-brief-table {
width: 100%;
border-collapse: collapse;
margin-top: 10px;
color: var(--color-gray-900, #111827);
}
.daily-brief-table th, .daily-brief-table td {
border: 1px solid var(--color-gray-300, #d1d5db);
padding: 8px;
text-align: left;
}
.daily-brief-table th {
background-color: var(--color-gray-100, #f2f2f2);
color: var(--color-gray-900, #111827);
}
.daily-brief-table td {
background-color: var(--color-white, #fff);
}
.xp-gain { color: var(--color-green-500, #22c55e); }
.xp-loss { color: var(--color-red-500, #ef4444); }
.xp-neutral { color: var(--color-gray-500, #94a3b8); }
.user-cell-content { display: flex; flex-direction: column; gap: 2px; }
.user-name { font-weight: 500; color: var(--color-gray-900, #111827); }
.user-coords { font-size: 13px; }
.member-icon-btn {
display: inline-flex; align-items: center; justify-content: center;
width: 24px; height: 24px; border-radius: 4px; cursor: pointer;
transition: background-color 0.2s; margin-left: 4px; border: none;
background: transparent; padding: 0;
}
.member-icon-btn:hover { background-color: var(--color-gray-100, rgba(0,0,0,0.05)); }
.discord-icon { color: #5865F2; }
.map-icon { color: var(--color-sky-500, #0ea5e9); }
.map-icon.out-of-territory { color: var(--color-red-500, #ef4444); }
.map-icon.visited { color: var(--color-purple-500, #a855f7); }
.control-button {
padding: 6px 12px;
border: 1px solid var(--color-gray-300, #d1d5db);
background: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
border-radius: 4px;
cursor: pointer;
font-size: 12px;
transition: background-color 0.2s;
}
.control-button:hover { background-color: var(--color-gray-100, #f0f0f0); }
.control-button.active {
background-color: var(--color-blue-500, #3b82f6);
color: var(--color-white, #fff);
border-color: var(--color-blue-500, #3b82f6);
}
.trash-btn {
background: none; border: none;
color: var(--color-red-500, #ef4444);
cursor: pointer;
padding: 2px 4px; font-size: 12px;
}
.trash-btn:hover { color: var(--color-red-600, #dc2626); }
.tooltip-popup {
position: fixed;
background: var(--color-gray-800, #333);
color: var(--color-gray-100, #fff);
padding: 4px 8px;
border-radius: 4px; font-size: 12px; z-index: 10000; pointer-events: none;
opacity: 0; transition: opacity 0.2s;
}
.tooltip-popup.visible { opacity: 1; }
#snapshotIntervalSelect {
background-color: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
}
#snapshotIntervalSelect:hover {
border-color: var(--color-blue-500, #3b82f6) !important;
box-shadow: 0 0 4px rgba(59, 130, 246, 0.2) !important;
}
#snapshotIntervalSelect:focus {
border-color: var(--color-blue-500, #3b82f6) !important;
box-shadow: 0 0 6px rgba(59, 130, 246, 0.3) !important;
outline: none !important;
}
/* --- Player Markers Overlay Styles --- */
#players-container {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
overflow: hidden;
}
.player-marker {
position: absolute;
pointer-events: auto;
cursor: pointer;
transform: translate(-50%, -100%);
transition: transform 0.15s ease;
filter: drop-shadow(0 2px 4px rgba(0,0,0,0.35));
}
.player-marker:hover {
transform: translate(-50%, -100%) scale(1.25);
z-index: 10;
}
.player-marker-tooltip {
position: absolute;
bottom: 100%;
left: 50%;
transform: translateX(-50%);
margin-bottom: 6px;
background: var(--color-gray-800, #1f2937);
color: var(--color-gray-100, #f3f4f6);
padding: 4px 8px;
border-radius: 4px;
font-size: 11px;
font-weight: 600;
white-space: nowrap;
pointer-events: none;
opacity: 0;
transition: opacity 0.15s ease;
box-shadow: 0 2px 6px rgba(0,0,0,0.25);
}
.player-marker-tooltip::after {
content: '';
position: absolute;
top: 100%;
left: 50%;
transform: translateX(-50%);
border: 5px solid transparent;
border-top-color: var(--color-gray-800, #1f2937);
}
.player-marker:hover .player-marker-tooltip {
opacity: 1;
}
.player-marker.show-label .player-marker-tooltip {
opacity: 1;
}
.player-marker-options {
display: flex;
align-items: center;
gap: 14px;
padding: 4px 0;
}
.player-marker-options label {
display: flex;
align-items: center;
gap: 5px;
font-size: 12px;
font-weight: 500;
color: var(--color-gray-600, #4b5563);
cursor: pointer;
user-select: none;
}
.player-marker-options input[type="checkbox"] {
width: 14px;
height: 14px;
cursor: pointer;
accent-color: var(--color-blue-500, #3b82f6);
}
/* --- Territory Overlay Styles --- */
#territory-canvas {
position: absolute;
inset: 0;
pointer-events: none;
z-index: 5;
}
/* Territory Controls Container */
#territoryControlsContainer {
background-color: var(--color-gray-100, #f0f9ff);
border: 1px solid var(--color-gray-300, #bae6fd);
}
.territory-setting-row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
}
.territory-setting-row label {
font-size: 13px;
font-weight: 500;
color: var(--color-gray-700, #374151);
}
.territory-settings-collapsible {
width: 100%;
border: 1px solid var(--color-gray-300, #d1d5db);
border-radius: 8px;
overflow: hidden;
background: var(--color-white, #fff);
}
.territory-settings-toggle {
display: flex;
align-items: center;
justify-content: space-between;
padding: 8px 12px;
background: var(--color-gray-100, #f3f4f6);
cursor: pointer;
user-select: none;
font-size: 13px;
font-weight: 600;
color: var(--color-blue-500, #3b82f6);
border: none;
width: 100%;
}
.territory-settings-toggle:hover {
background: var(--color-gray-200, #e5e7eb);
}
.territory-settings-toggle .toggle-arrow {
transition: transform 0.2s ease;
font-size: 11px;
}
.territory-settings-toggle .toggle-arrow.collapsed {
transform: rotate(-90deg);
}
.territory-settings-content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
border-top: 1px solid var(--color-gray-200, #e5e7eb);
background: var(--color-white, #fff);
}
.territory-settings-content.collapsed {
display: none;
}
.territory-section-divider {
border-top: 1px solid var(--color-gray-200, #e5e7eb);
margin: 2px 0;
padding-top: 4px;
font-size: 11px;
font-weight: 600;
color: var(--color-gray-500, #6b7280);
text-transform: uppercase;
letter-spacing: 0.5px;
}
.territory-toggle-btn {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 8px 16px;
border-radius: 8px;
font-size: 13px;
font-weight: 600;
cursor: pointer;
border: none;
transition: all 0.2s;
}
.territory-toggle-btn.active {
background: var(--color-blue-500, #3b82f6);
color: var(--color-white, #fff);
box-shadow: 0 2px 8px rgba(59,130,246,0.3);
}
.territory-toggle-btn.inactive {
background: var(--color-gray-200, #e5e7eb);
color: var(--color-gray-700, #374151);
}
.territory-toggle-btn.inactive:hover {
background: var(--color-gray-300, #d1d5db);
}
/* Territory settings inputs */
.territory-settings-content input[type="color"] {
border: 2px solid var(--color-gray-300, #d1d5db);
}
.territory-settings-content select {
background-color: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
border: 1px solid var(--color-gray-300, #d1d5db);
}
.territory-settings-content input[type="range"] {
accent-color: var(--color-blue-500, #3b82f6);
}
/* Info text in territory controls */
.territory-info-text {
font-size: 12px;
color: var(--color-gray-500, #64748b);
}
/* --- Modal Styling for Dark Mode Compatibility --- */
.gmi-modal-overlay {
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background: rgba(0, 0, 0, 0.5);
z-index: 9999;
}
.gmi-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-white, #fff);
border: 2px solid var(--color-blue-500, #3b82f6);
border-radius: 8px;
padding: 20px;
z-index: 10000;
box-shadow: 0 10px 25px rgba(0, 0, 0, 0.2);
color: var(--color-gray-900, #111827);
}
.gmi-modal h3 {
margin: 0 0 15px 0;
font-size: 18px;
font-weight: bold;
color: var(--color-gray-900, #111827);
}
.gmi-modal-btn {
display: block;
width: 100%;
padding: 10px;
margin: 8px 0;
border: 1px solid var(--color-gray-300, #d1d5db);
border-radius: 4px;
background: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
cursor: pointer;
font-size: 14px;
transition: background-color 0.2s;
text-align: left;
}
.gmi-modal-btn:hover {
background-color: var(--color-gray-100, #f3f4f6);
}
.gmi-modal-btn.danger {
color: var(--color-red-500, #ef4444);
}
.gmi-modal-btn.danger:hover {
background-color: rgba(239, 68, 68, 0.1);
}
.gmi-modal-btn.warning {
color: var(--color-yellow-500, #f59e0b);
}
.gmi-modal-btn.warning:hover {
background-color: rgba(245, 158, 11, 0.1);
}
.gmi-modal-btn.primary {
color: var(--color-blue-500, #3b82f6);
}
.gmi-modal-btn.primary:hover {
background-color: rgba(59, 130, 246, 0.1);
}
.gmi-modal-section {
padding: 10px;
background: var(--color-gray-50, #f9fafb);
border-radius: 4px;
border: 1px solid var(--color-gray-200, #e5e7eb);
margin-bottom: 15px;
}
.gmi-modal-select {
padding: 6px 10px;
border: 1px solid var(--color-gray-300, #d1d5db);
border-radius: 4px;
background: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
font-size: 12px;
cursor: pointer;
}
.gmi-modal-label {
font-weight: 600;
font-size: 12px;
color: var(--color-gray-700, #374151);
user-select: none;
}
.gmi-checkbox-option {
display: flex;
align-items: center;
padding: 8px;
border: 2px solid var(--color-gray-300, #d1d5db);
border-radius: 4px;
background: var(--color-white, #fff);
cursor: pointer;
font-size: 12px;
transition: all 0.2s;
}
.gmi-checkbox-option:hover {
border-color: var(--color-gray-400, #9ca3af);
}
.gmi-checkbox-option input[type="checkbox"] {
width: 16px;
height: 16px;
margin-right: 8px;
cursor: pointer;
accent-color: var(--color-blue-500, #3b82f6);
}
.gmi-snapshot-list {
flex: 1;
overflow-y: auto;
border: 1px solid var(--color-gray-200, #e5e7eb);
border-radius: 4px;
padding: 10px;
margin-bottom: 15px;
background: var(--color-gray-50, #f9fafb);
}
.gmi-snapshot-item {
display: flex;
align-items: center;
padding: 8px;
margin: 4px 0;
background: var(--color-white, #fff);
border-radius: 4px;
border: 1px solid var(--color-gray-200, #e5e7eb);
transition: background-color 0.2s, border-color 0.2s;
}
.gmi-snapshot-item.selected {
background: rgba(239, 68, 68, 0.1);
border-color: var(--color-red-300, #fca5a5);
}
.gmi-snapshot-item label {
flex: 1;
cursor: pointer;
font-size: 12px;
color: var(--color-gray-700, #374151);
}
.gmi-snapshot-item.selected label {
color: var(--color-red-700, #b91c1c);
text-decoration: line-through;
}
.gmi-action-btn {
flex: 1;
min-width: 120px;
padding: 12px 16px;
border: none;
border-radius: 6px;
font-weight: 600;
font-size: 13px;
cursor: pointer;
transition: all 0.3s ease;
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
}
.gmi-action-btn.danger {
background: var(--color-red-500, #dc2626);
color: var(--color-white, #fff);
}
.gmi-action-btn.danger:hover {
background: var(--color-red-600, #b91c1c);
}
.gmi-action-btn.primary {
background: var(--color-blue-500, #3b82f6);
color: var(--color-white, #fff);
}
.gmi-action-btn.primary:hover {
background: var(--color-blue-600, #2563eb);
}
.gmi-action-btn.success {
background: var(--color-green-500, #10b981);
color: var(--color-white, #fff);
}
.gmi-action-btn.success:hover {
background: var(--color-green-600, #059669);
}
.gmi-action-btn.neutral {
background: var(--color-gray-100, #f3f4f6);
color: var(--color-gray-600, #6b7280);
}
.gmi-action-btn.neutral:hover {
background: var(--color-gray-200, #e5e7eb);
}
/* Progress popup */
.gmi-progress-popup {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3);
z-index: 10000;
min-width: 300px;
text-align: center;
}
.gmi-progress-popup p {
color: var(--color-gray-700, #374151);
}
.gmi-progress-bar-container {
width: 100%;
height: 20px;
background: var(--color-gray-200, #e5e7eb);
border-radius: 4px;
margin-top: 10px;
overflow: hidden;
}
.gmi-progress-bar {
height: 100%;
background: var(--color-blue-500, #3b82f6);
transition: width 0.3s;
}
/* CSV Modal */
.gmi-csv-modal {
position: fixed;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
background-color: var(--color-white, #fff);
padding: 20px;
border-radius: 8px;
box-shadow: 0 4px 20px rgba(0,0,0,0.2);
z-index: 10002;
width: 500px;
max-width: 90%;
display: flex;
flex-direction: column;
gap: 10px;
}
.gmi-csv-modal h3 {
margin: 0 0 10px 0;
color: var(--color-gray-800, #1e293b);
font-size: 1.25rem;
font-weight: 600;
}
.gmi-csv-modal textarea {
width: 100%;
height: 300px;
font-family: monospace;
font-size: 12px;
border: 1px solid var(--color-gray-300, #ccc);
border-radius: 4px;
resize: vertical;
background: var(--color-white, #fff);
color: var(--color-gray-900, #111827);
}
`;
document.head.appendChild(style);
// --- Helper Functions ---
function getVirtualNow() {
return Date.now() + CONFIG.timeOffset;
}
function waitForElement(selector, timeout = 10000) {
return new Promise((resolve, reject) => {
const startTime = Date.now();
const checkInterval = setInterval(() => {
const element = document.querySelector(selector);
if (element) {
clearInterval(checkInterval);
resolve(element);
} else if (Date.now() - startTime > timeout) {
clearInterval(checkInterval);
reject(new Error(`Element ${selector} not found within ${timeout}ms`));
}
}, 100);
});
}
async function fetchUserProfile(targetUserId) {
try {
if (!targetUserId) { console.error("Missing targetId"); return null; }
const response = await fetch('/GetUserProfile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ "targetId": parseInt(targetUserId) })
});
if (!response.ok) throw new Error(`Server returned ${response.status}`);
return await response.json();
} catch (err) {
console.error("Failed to fetch user profile:", err);
return null;
}
}
function showTooltip(x, y, text) {
let tooltip = document.getElementById('custom-tooltip');
if (!tooltip) {
tooltip = document.createElement('div');
tooltip.id = 'custom-tooltip';
tooltip.className = 'tooltip-popup';
document.body.appendChild(tooltip);
}
tooltip.textContent = text;
tooltip.style.left = x + 10 + 'px';
tooltip.style.top = y + 'px';
tooltip.classList.add('visible');
setTimeout(() => tooltip.classList.remove('visible'), 2000);
}
// --- XP Tracking Logic ---
function parseGuildMembers() {
const container = document.getElementById('guildMembersContainer');
if (!container) return null;
const members = {};
const memberRows = container.querySelectorAll('div.flex.items-center.justify-between.p-2.rounded-md.bg-white.shadow-sm');
memberRows.forEach(row => {
const nameEl = row.querySelector('p.font-semibold');
const xpEl = row.querySelector('p.text-xs.text-gray-500');
if (nameEl && xpEl) {
let fullName = nameEl.textContent.trim();
const badge = nameEl.querySelector('span');
if (badge) fullName = fullName.replace(badge.textContent, '').trim();
const xpText = xpEl.textContent;
const xpMatch = xpText.match(/([\d,.]+)\s*XP$/);
let coords = null;
const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
if (findBtn) {
const match = findBtn.getAttribute('onclick').match(/goToGridLocation\((-?\d+),\s*(-?\d+)\)/);
if (match) coords = [parseInt(match[1]), parseInt(match[2])];
}
if (fullName && xpMatch) {
const xp = parseInt(xpMatch[1].replace(/[.,]/g, ''), 10);
members[fullName] = { xp, coords };
}
}
});
return members;
}
function saveGuildSnapshot(members, forceNew = false) {
const now = getVirtualNow();
let history = GM_getValue('guild_xp_history', []);
const lastEntry = history[history.length - 1];
const lastBucketStart = lastEntry ? (lastEntry.bucketStartTime || lastEntry.timestamp) : 0;
const newEntry = { timestamp: now, bucketStartTime: now, members: members };
if (!forceNew && lastEntry && (now - lastBucketStart < CONFIG.minSnapshotInterval)) {
newEntry.bucketStartTime = lastBucketStart;
history[history.length - 1] = newEntry;
if (CONFIG.debugMode) console.log('[Guild XP] Updated recent snapshot');
} else {
history.push(newEntry);
console.log('[Guild XP] Created new snapshot');
}
if (history.length > CONFIG.maxSnapshots) history = history.slice(history.length - CONFIG.maxSnapshots);
GM_setValue('guild_xp_history', history);
return history;
}
function getXp(val) {
if (typeof val === 'number') return val;
if (val && typeof val === 'object' && val.xp !== undefined) return val.xp;
return 0;
}
function getCoords(val) {
if (val && typeof val === 'object' && val.coords) return val.coords;
return null;
}
async function fetchAllGuildMembersData() {
const currentMembers = parseGuildMembers();
if (!currentMembers || Object.keys(currentMembers).length === 0) {
alert('No guild members found. Please wait for members to load.');
return null;
}
const memberNames = Object.keys(currentMembers);
const allUsersData = [];
let successCount = 0;
let failCount = 0;
const progressDiv = document.createElement('div');
progressDiv.className = 'gmi-progress-popup';
progressDiv.innerHTML = `
<p style="font-weight: bold; margin-bottom: 10px;">Fetching guild member data...</p>
<p id="progressText" style="font-size: 14px;">0/${memberNames.length}</p>
<div class="gmi-progress-bar-container">
<div id="progressBar" class="gmi-progress-bar" style="width: 0%;"></div>
</div>
`;
document.body.appendChild(progressDiv);
for (let i = 0; i < memberNames.length; i++) {
const memberName = memberNames[i];
const match = memberName.match(/#(\d+)$/);
if (match) {
const userId = match[1];
const data = await fetchUserProfile(userId);
if (data) { allUsersData.push(data); successCount++; }
else failCount++;
} else {
failCount++;
}
const progressPercent = ((i + 1) / memberNames.length) * 100;
document.getElementById('progressBar').style.width = progressPercent + '%';
document.getElementById('progressText').textContent = `${i + 1}/${memberNames.length} (${successCount} fetched)`;
}
const jsonString = JSON.stringify(allUsersData, null, 2);
navigator.clipboard.writeText(jsonString).then(() => {
progressDiv.innerHTML = `
<p style="font-weight: bold; color: #10b981; margin-bottom: 5px;">✓ Success!</p>
<p style="font-size: 14px; color: #666;">Fetched: ${successCount} users<br>Failed: ${failCount} users<br><br><strong>JSON copied to clipboard!</strong></p>
`;
setTimeout(() => progressDiv.remove(), 3000);
}).catch((err) => {
progressDiv.innerHTML = `<p style="font-weight: bold; color: #dc2626;">Error copying to clipboard!</p><p style="font-size: 12px; color: #666;">${err.message}</p>`;
setTimeout(() => progressDiv.remove(), 3000);
});
return allUsersData;
}
function calculateXPChanges(oldMembers, newMembers) {
const changes = [];
for (const [id, oldVal] of Object.entries(oldMembers)) {
const oldXp = getXp(oldVal);
if (newMembers.hasOwnProperty(id)) {
const newVal = newMembers[id];
const newXp = getXp(newVal);
const diff = newXp - oldXp;
const coords = getCoords(newVal) || getCoords(oldVal);
changes.push({ type: 'gain', id, diff, oldXp, newXp, coords });
} else {
const coords = getCoords(oldVal);
changes.push({ type: 'left', id, oldXp, coords });
}
}
for (const [id, newVal] of Object.entries(newMembers)) {
if (!oldMembers.hasOwnProperty(id)) {
const newXp = getXp(newVal);
const coords = getCoords(newVal);
changes.push({ type: 'join', id, newXp, coords });
}
}
return changes;
}
function getCoordinateColor(coords) {
if (!coords || coords.length < 2) return { bg: '#f3f4f6', text: '#1f2937' };
const x = coords[0];
const y = coords[1];
const distance = Math.sqrt(x * x + y * y);
const distanceBand = Math.floor(distance / 25000);
let baseColor;
if (x >= 0 && y >= 0) {
const intensity = Math.min(distanceBand * 3, 15);
baseColor = `hsl(120, 50%, ${97 - intensity}%)`;
} else if (x < 0 && y >= 0) {
const intensity = Math.min(distanceBand * 3, 15);
baseColor = `hsl(0, 50%, ${97 - intensity}%)`;
} else if (x < 0 && y < 0) {
const intensity = Math.min(distanceBand * 3, 15);
baseColor = `hsl(240, 50%, ${97 - intensity}%)`;
} else {
const intensity = Math.min(distanceBand * 3, 15);
baseColor = `hsl(30, 50%, ${97 - intensity}%)`;
}
return { bg: baseColor, text: '#1f2937' };
}
// --- XP Changes Section ---
function ensureXPChangesSection() {
const infoBtn = document.getElementById('infoTabBtn');
if (!infoBtn) {
if (document.getElementById('infoTab')) {
console.log('[Guild XP] Could not find tab buttons, appending to infoTab instead');
ensureXPChangesSectionLegacy();
}
return;
}
const tabNav = infoBtn.parentElement;
if (document.getElementById('xpTrackerTabBtn')) return;
const existingPanes = document.querySelectorAll('#xpTrackerPane');
existingPanes.forEach(pane => pane.remove());
const xpTabBtn = document.createElement('button');
xpTabBtn.textContent = 'XP Tracker';
xpTabBtn.id = 'xpTrackerTabBtn';
xpTabBtn.className = infoBtn.className;
xpTabBtn.classList.remove('text-blue-600', 'border-blue-500');
xpTabBtn.classList.add('text-gray-500', 'border-transparent');
xpTabBtn.style.borderBottom = '2px solid transparent';
const xpTabPane = document.createElement('div');
xpTabPane.id = 'xpTrackerPane';
xpTabPane.style.display = 'none';
xpTabPane.className = 'hidden guild-tab-content';
const infoTab = document.getElementById('infoTab');
const contentContainer = infoTab?.parentElement;
if (!contentContainer) {
console.log('[Guild XP] Could not find content container');
ensureXPChangesSectionLegacy();
return;
}
xpTabBtn.onclick = (e) => {
e.preventDefault();
e.stopPropagation();
const allPanes = contentContainer.querySelectorAll('.guild-tab-content, [id$="Tab"], [id$="Pane"]');
allPanes.forEach(pane => { pane.style.display = 'none'; pane.classList.add('hidden'); });
const allBtns = tabNav.querySelectorAll('button');
allBtns.forEach(btn => {
btn.classList.remove('text-blue-600', 'border-blue-500');
btn.classList.add('text-gray-500', 'border-transparent');
btn.style.borderBottom = '2px solid transparent';
btn.style.color = '';
});
xpTabPane.style.display = 'block';
xpTabPane.classList.remove('hidden');
xpTabBtn.classList.remove('text-gray-500', 'border-transparent');
xpTabBtn.classList.add('text-blue-600', 'border-blue-500');
xpTabBtn.style.borderBottom = '2px solid #3b82f6';
xpTabBtn.style.color = '#3b82f6';
renderXPChanges(xpTabPane);
};
const existingTabs = tabNav.querySelectorAll('button');
existingTabs.forEach(btn => {
if (btn.id === 'xpTrackerTabBtn' || btn.dataset.xpTrackerHooked) return;
const originalOnClick = btn.onclick;
btn.onclick = (e) => {
xpTabPane.style.display = 'none';
xpTabPane.classList.add('hidden');
const allPanes = contentContainer.querySelectorAll('.guild-tab-content');
allPanes.forEach(pane => { if (pane.id !== 'xpTrackerPane') pane.style.display = ''; });
xpTabBtn.classList.remove('text-blue-600', 'border-blue-500');
xpTabBtn.classList.add('text-gray-500', 'border-transparent');
xpTabBtn.style.borderBottom = '2px solid transparent';
xpTabBtn.style.color = '';
if (originalOnClick) originalOnClick.call(btn, e);
};
btn.dataset.xpTrackerHooked = 'true';
});
tabNav.appendChild(xpTabBtn);
contentContainer.appendChild(xpTabPane);
const navObserver = new MutationObserver(() => {
if (!document.getElementById('xpTrackerTabBtn')) tabNav.appendChild(xpTabBtn);
});
navObserver.observe(tabNav, { childList: true });
}
function ensureXPChangesSectionLegacy() {
const infoTab = document.getElementById('infoTab');
if (!infoTab || document.getElementById('xpChangesSection')) return;
const section = document.createElement('div');
section.id = 'xpChangesSection';
section.className = 'xp-changes-section';
const header = document.createElement('div');
header.className = 'xp-changes-header';
header.innerHTML = `<span>XP Changes Tracker</span><span class="toggle-icon">▼</span>`;
const content = document.createElement('div');
content.className = 'xp-changes-content hidden';
content.id = 'xpChangesContent';
header.onclick = () => {
content.classList.toggle('hidden');
const icon = header.querySelector('.toggle-icon');
icon.style.transform = content.classList.contains('hidden') ? 'rotate(0deg)' : 'rotate(180deg)';
if (!content.classList.contains('hidden')) renderXPChanges(content);
};
section.appendChild(header);
section.appendChild(content);
infoTab.appendChild(section);
}
function collapseOtherSections() {
const messageSection = document.querySelector('.guild-message-section');
if (messageSection) {
const content = messageSection.querySelector('.guild-message-content');
const toggle = messageSection.querySelector('.guild-message-toggle');
if (content && !content.classList.contains('collapsed')) {
content.classList.add('collapsed');
toggle.classList.add('collapsed');
document.getElementById('infoTab').classList.add('message-collapsed');
}
}
}
function expandOtherSections() {
const messageSection = document.querySelector('.guild-message-section');
if (messageSection) {
const content = messageSection.querySelector('.guild-message-content');
const toggle = messageSection.querySelector('.guild-message-toggle');
if (content && content.classList.contains('collapsed')) {
content.classList.remove('collapsed');
toggle.classList.remove('collapsed');
document.getElementById('infoTab').classList.remove('message-collapsed');
}
}
}
function exportToCSV(snapshots, currentMembers, fromVal, toVal) {
// Determine which snapshots to compare based on current selection
// If called from the button, we might need to pass these values or read them from DOM
// But since this function was originally designed to dump EVERYTHING, let's adapt it
// to dump the CURRENT VIEW if specific snapshots are provided, or EVERYTHING if not.
let csvContent = '';
if (fromVal !== undefined && toVal !== undefined) {
// Export current view (comparison)
const getSnapshot = (val) => val === 'current' ? { members: currentMembers } : snapshots[val];
const fromData = getSnapshot(fromVal);
const toData = getSnapshot(toVal);
if (!fromData || !toData) return;
const changes = calculateXPChanges(fromData.members, toData.members);
// Sort (same as view)
changes.sort((a, b) => {
if (a.type === 'join') return -1;
if (b.type === 'join') return 1;
if (a.type === 'left') return 1;
if (b.type === 'left') return -1;
return b.diff - a.diff;
});
const csvRows = [
["Username", "Change Type", "XP Change", "Old XP", "New XP"],
...changes.map(c => {
const oldVal = c.oldXp || 0;
const newVal = c.newXp || 0;
const diff = c.diff !== undefined ? c.diff : (newVal - oldVal);
return [`"${c.id}"`, c.type, diff, oldVal, newVal];
})
];
csvContent = csvRows.map(e => e.join(",")).join("\n");
} else {
// Export Full History (Legacy behavior)
let csv = 'Snapshot,Timestamp,User,XP\n';
snapshots.forEach((snap, idx) => {
const timestamp = new Date(snap.timestamp).toLocaleString();
for (const [user, data] of Object.entries(snap.members)) {
const xp = data.xp || data;
csv += `${idx + 1},"${timestamp}","${user}",${xp}\n`;
}
});
// Add current
const now = new Date(getVirtualNow()).toLocaleString();
for (const [user, data] of Object.entries(currentMembers)) {
const xp = data.xp || data;
csv += `Current,"${now}","${user}",${xp}\n`;
}
csvContent = csv;
}
// Open CSV Modal
const csvOverlay = document.createElement('div');
csvOverlay.className = 'gmi-modal-overlay';
csvOverlay.style.zIndex = '10001';
csvOverlay.onclick = () => { csvOverlay.remove(); csvModal.remove(); };
const csvModal = document.createElement('div');
csvModal.className = 'gmi-csv-modal';
const title = document.createElement('h3');
title.textContent = 'CSV Export';
const textarea = document.createElement('textarea');
textarea.value = csvContent;
textarea.readOnly = true;
textarea.onclick = () => textarea.select();
const btnRow = document.createElement('div');
btnRow.style.display = 'flex';
btnRow.style.justifyContent = 'flex-end';
btnRow.style.gap = '10px';
const copyBtn = document.createElement('button');
copyBtn.innerHTML = '📋 Copy';
copyBtn.className = 'control-button';
copyBtn.onclick = () => {
textarea.select();
navigator.clipboard.writeText(csvContent).then(() => {
const orig = copyBtn.innerHTML;
copyBtn.innerHTML = '✅ Copied!';
setTimeout(() => copyBtn.innerHTML = orig, 1000);
});
};
const downloadBtn = document.createElement('button');
downloadBtn.innerHTML = '💾 Download';
downloadBtn.className = 'gmi-action-btn success';
downloadBtn.onclick = () => {
const blob = new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
const link = document.createElement("a");
if (link.download !== undefined) {
const url = URL.createObjectURL(blob);
link.setAttribute("href", url);
link.setAttribute("download", `guild_xp_export_${Date.now()}.csv`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
}
};
const closeBtn = document.createElement('button');
closeBtn.innerHTML = 'Close';
closeBtn.className = 'control-button';
closeBtn.onclick = () => { csvOverlay.remove(); csvModal.remove(); };
btnRow.appendChild(copyBtn);
btnRow.appendChild(downloadBtn);
btnRow.appendChild(closeBtn);
csvModal.appendChild(title);
csvModal.appendChild(textarea);
csvModal.appendChild(btnRow);
document.body.appendChild(csvOverlay);
document.body.appendChild(csvModal);
}
// --- History Pruning Functions ---
function deleteAllHistory() {
if (confirm('Delete ALL snapshots? This cannot be undone.')) {
GM_setValue('guild_xp_history', []);
return [];
}
return null;
}
function keepDailyHistory() {
let history = GM_getValue('guild_xp_history', []);
const dailyMap = new Map();
// Group by day (YYYY-MM-DD)
history.forEach(entry => {
const date = new Date(entry.timestamp);
const dayKey = date.toISOString().split('T')[0]; // YYYY-MM-DD
// Keep the latest snapshot from each day
if (!dailyMap.has(dayKey) || entry.timestamp > dailyMap.get(dayKey).timestamp) {
dailyMap.set(dayKey, entry);
}
});
const pruned = Array.from(dailyMap.values()).sort((a, b) => a.timestamp - b.timestamp);
const removed = history.length - pruned.length;
if (confirm(`This will keep only the latest snapshot from each day.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
GM_setValue('guild_xp_history', pruned);
return pruned;
}
return null;
}
function keepWeeklyHistory() {
let history = GM_getValue('guild_xp_history', []);
const weeklyMap = new Map();
// Group by week (ISO week)
history.forEach(entry => {
const date = new Date(entry.timestamp);
const dayOfWeek = date.getUTCDay();
const diff = date.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
const weekStart = new Date(date.setUTCDate(diff));
const weekKey = weekStart.toISOString().split('T')[0]; // Start of week (YYYY-MM-DD)
// Keep the latest snapshot from each week
if (!weeklyMap.has(weekKey) || entry.timestamp > weeklyMap.get(weekKey).timestamp) {
weeklyMap.set(weekKey, entry);
}
});
const pruned = Array.from(weeklyMap.values()).sort((a, b) => a.timestamp - b.timestamp);
const removed = history.length - pruned.length;
if (confirm(`This will keep only the latest snapshot from each week.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
GM_setValue('guild_xp_history', pruned);
return pruned;
}
return null;
}
function deleteHistoryOlderThan7Days() {
let history = GM_getValue('guild_xp_history', []);
const now = getVirtualNow();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
const pruned = history.filter(entry => (now - entry.timestamp) <= sevenDaysMs);
const removed = history.length - pruned.length;
if (confirm(`This will delete all snapshots older than 7 days.\nSnapshots: ${history.length} → ${pruned.length} (removing ${removed}).\nContinue?`)) {
GM_setValue('guild_xp_history', pruned);
return pruned;
}
return null;
}
function renderCleanHistoryMenu(container, onClose) {
const overlay = document.createElement('div');
overlay.className = 'gmi-modal-overlay';
const modal = document.createElement('div');
modal.className = 'gmi-modal';
modal.style.minWidth = '350px';
const title = document.createElement('h3');
title.textContent = 'Clean History Options';
modal.appendChild(title);
const deleteAllBtn = document.createElement('button');
deleteAllBtn.innerHTML = 'Select All Snapshots for Deletion';
deleteAllBtn.className = 'gmi-modal-btn danger';
deleteAllBtn.onclick = () => {
const result = deleteAllHistory();
if (result !== null) {
onClose(result);
}
};
modal.appendChild(deleteAllBtn);
const keepDailyBtn = document.createElement('button');
keepDailyBtn.innerHTML = 'Keep One Snapshot Per Day (Latest)';
keepDailyBtn.className = 'gmi-modal-btn warning';
keepDailyBtn.onclick = () => {
const result = keepDailyHistory();
if (result !== null) {
onClose(result);
}
};
modal.appendChild(keepDailyBtn);
const keepWeeklyBtn = document.createElement('button');
keepWeeklyBtn.innerHTML = 'Keep One Snapshot Per Week (Latest)';
keepWeeklyBtn.className = 'gmi-modal-btn primary';
keepWeeklyBtn.onclick = () => {
const result = keepWeeklyHistory();
if (result !== null) {
onClose(result);
}
};
modal.appendChild(keepWeeklyBtn);
const delete7DaysBtn = document.createElement('button');
delete7DaysBtn.innerHTML = 'Select Snapshots Older Than 7 Days for Deletion';
delete7DaysBtn.className = 'gmi-modal-btn';
delete7DaysBtn.style.color = 'var(--color-purple-500, #8b5cf6)';
delete7DaysBtn.onclick = () => {
const result = deleteHistoryOlderThan7Days();
if (result !== null) {
onClose(result);
}
};
modal.appendChild(delete7DaysBtn);
const cancelBtn = document.createElement('button');
cancelBtn.innerHTML = 'Cancel';
cancelBtn.className = 'gmi-modal-btn';
cancelBtn.style.marginTop = '15px';
cancelBtn.style.borderTop = '1px solid var(--color-gray-300, #ddd)';
cancelBtn.style.paddingTop = '15px';
cancelBtn.onclick = () => {
overlay.remove();
modal.remove();
};
modal.appendChild(cancelBtn);
overlay.onclick = () => {
overlay.remove();
modal.remove();
};
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
// --- Export/Import Functions ---
function exportSnapshots() {
let history = GM_getValue('guild_xp_history', []);
if (history.length === 0) {
alert('No snapshots to export.');
return;
}
const exportData = {
version: 1,
exportDate: new Date().toISOString(),
snapshotCount: history.length,
snapshots: history
};
const jsonString = JSON.stringify(exportData, null, 2);
const blob = new Blob([jsonString], { type: 'application/json;charset=utf-8;' });
const link = document.createElement('a');
const url = URL.createObjectURL(blob);
link.setAttribute('href', url);
link.setAttribute('download', `guild_snapshots_${Date.now()}.json`);
link.style.visibility = 'hidden';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
alert(`Exported ${history.length} snapshots successfully.`);
}
function importSnapshots() {
if (!confirm('WARNING: Importing will ERASE all current snapshots and replace them with the imported data.\n\nAre you sure you want to continue?')) {
return;
}
const input = document.createElement('input');
input.type = 'file';
input.accept = '.json';
input.onchange = (e) => {
const file = e.target.files[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (event) => {
try {
const importData = JSON.parse(event.target.result);
if (!importData.snapshots || !Array.isArray(importData.snapshots)) {
alert('Invalid snapshot file format.');
return;
}
if (importData.snapshots.length === 0) {
alert('No snapshots found in file.');
return;
}
GM_setValue('guild_xp_history', importData.snapshots);
alert(`Successfully imported ${importData.snapshots.length} snapshots.`);
// Refresh the UI if open
const xpTrackerPane = document.getElementById('xpTrackerPane');
if (xpTrackerPane && xpTrackerPane.style.display !== 'none') {
renderXPChanges(xpTrackerPane);
}
} catch (error) {
alert(`Error importing file: ${error.message}`);
}
};
reader.readAsText(file);
};
input.click();
}
function renderCleanHistoryModal(onClose) {
let history = GM_getValue('guild_xp_history', []);
const selectedIndices = new Set();
const overlay = document.createElement('div');
overlay.className = 'gmi-modal-overlay';
const modal = document.createElement('div');
modal.className = 'gmi-modal';
modal.style.cssText = `
width: 90%;
max-width: 600px;
max-height: 80vh;
display: flex;
flex-direction: column;
`;
// Header
const header = document.createElement('div');
header.style.cssText = 'margin-bottom: 15px; border-bottom: 2px solid var(--color-gray-200, #e5e7eb); padding-bottom: 10px;';
const title = document.createElement('h3');
title.textContent = 'Manage Snapshots';
header.appendChild(title);
const info = document.createElement('p');
info.textContent = `Total snapshots: ${history.length}`;
info.style.cssText = 'margin: 0; font-size: 12px; color: var(--color-gray-500, #6b7280);';
header.appendChild(info);
modal.appendChild(header);
// Max snapshots control
const maxSnapshotsDiv = document.createElement('div');
maxSnapshotsDiv.style.cssText = `
display: flex;
align-items: center;
gap: 10px;
margin-bottom: 15px;
padding: 10px;
background: #f9fafb;
border-radius: 4px;
border: 1px solid var(--color-gray-200, #e5e7eb);
`;
const maxLabel = document.createElement('label');
maxLabel.textContent = 'Max Snapshots:';
maxLabel.className = 'gmi-modal-label';
maxSnapshotsDiv.appendChild(maxLabel);
const maxSelect = document.createElement('select');
maxSelect.className = 'gmi-modal-select';
const presets = [50, 100, 250, 500, 750, 1000, 2500, 5000, 10000];
presets.forEach(value => {
const option = document.createElement('option');
option.value = value;
option.textContent = value;
if (value === CONFIG.maxSnapshots) option.selected = true;
maxSelect.appendChild(option);
});
maxSelect.onchange = (e) => {
const newMax = parseInt(e.target.value);
CONFIG.maxSnapshots = newMax;
GM_setValue('max_snapshots', newMax);
};
maxSnapshotsDiv.appendChild(maxSelect);
modal.appendChild(maxSnapshotsDiv);
// Snapshot Interval Control
const intervalDiv = document.createElement('div');
intervalDiv.className = 'gmi-modal-section';
intervalDiv.style.cssText = `
display: grid;
grid-template-columns: 150px 1fr;
align-items: center;
gap: 12px;
`;
const intervalLabel = document.createElement('label');
intervalLabel.textContent = 'Snapshot Interval:';
intervalLabel.className = 'gmi-modal-label';
intervalLabel.style.whiteSpace = 'nowrap';
intervalDiv.appendChild(intervalLabel);
const intervalSelect = document.createElement('select');
intervalSelect.id = 'snapshotIntervalSelect';
intervalSelect.className = 'gmi-modal-select';
intervalSelect.style.cssText = `
padding: 8px 12px;
border: 2px solid #d1d5db;
border-radius: 6px;
background: white;
font-size: 13px;
cursor: pointer;
color: #374151;
transition: all 0.2s ease;
font-weight: 500;
max-width: 280px;
`;
// Add hover and focus styles through a style tag
intervalSelect.onmouseover = () => {
intervalSelect.style.borderColor = '#3b82f6';
intervalSelect.style.boxShadow = '0 0 4px rgba(59, 130, 246, 0.2)';
};
intervalSelect.onmouseout = () => {
if (document.activeElement !== intervalSelect) {
intervalSelect.style.borderColor = '#d1d5db';
intervalSelect.style.boxShadow = 'none';
}
};
intervalSelect.onfocus = () => {
intervalSelect.style.borderColor = '#3b82f6';
intervalSelect.style.boxShadow = '0 0 6px rgba(59, 130, 246, 0.3)';
};
intervalSelect.onblur = () => {
intervalSelect.style.borderColor = '#d1d5db';
intervalSelect.style.boxShadow = 'none';
};
const hourlyOpt = document.createElement('option');
hourlyOpt.value = 'hourly';
hourlyOpt.textContent = 'Hourly (1h)';
intervalSelect.appendChild(hourlyOpt);
const twelveHourOpt = document.createElement('option');
twelveHourOpt.value = '12h';
twelveHourOpt.textContent = '12 Hours';
intervalSelect.appendChild(twelveHourOpt);
const twentyFourHourOpt = document.createElement('option');
twentyFourHourOpt.value = '24h';
twentyFourHourOpt.textContent = '24 Hours';
intervalSelect.appendChild(twentyFourHourOpt);
const customOpt = document.createElement('option');
customOpt.value = 'custom';
customOpt.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
intervalSelect.appendChild(customOpt);
// Set current value
updateSnapshotIntervalDropdown(intervalSelect);
intervalSelect.onchange = (e) => {
const selectedValue = e.target.value;
if (selectedValue === 'hourly') {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.HOURLY;
} else if (selectedValue === '12h') {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWELVE_HOURS;
} else if (selectedValue === '24h') {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS;
} else if (selectedValue === 'custom') {
const userInput = prompt("Enter custom snapshot interval in minutes:", (CONFIG.minSnapshotInterval / (60 * 1000)).toString());
if (userInput !== null && userInput.trim() !== '') {
const minutes = parseFloat(userInput);
if (!isNaN(minutes) && minutes > 0) {
CONFIG.minSnapshotInterval = minutes * 60 * 1000;
const customOption = intervalSelect.querySelector('option[value="custom"]');
if (customOption) {
customOption.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
}
} else {
alert("Invalid input. Please enter a positive number.");
updateSnapshotIntervalDropdown(intervalSelect);
return;
}
} else {
updateSnapshotIntervalDropdown(intervalSelect);
return;
}
}
// Persist the change
GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
};
intervalDiv.appendChild(intervalSelect);
modal.appendChild(intervalDiv);
// Track which preset option is selected (null = none, or the option name)
let selectedPreset = null;
// Shortcut options - mutually exclusive checkboxes + Select All toggle
const shortcutsDiv = document.createElement('div');
shortcutsDiv.style.cssText = `
display: grid;
grid-template-columns: 1fr 1fr;
gap: 8px;
margin-bottom: 15px;
`;
const checkboxInputStyle = `
width: 16px;
height: 16px;
margin-right: 8px;
cursor: pointer;
accent-color: var(--color-blue-500, #3b82f6);
`;
// Helper function to update preset selection
function updatePresetSelection(newPreset) {
selectedPreset = selectedPreset === newPreset ? null : newPreset;
// Clear the selection if switching presets
selectedIndices.clear();
if (selectedPreset === 'all') {
// Select all snapshots
if (history.length === 0) {
alert('No snapshots to select.');
selectedPreset = null;
} else {
for (let i = 0; i < history.length; i++) {
selectedIndices.add(i);
}
}
} else if (selectedPreset === 'daily') {
// Keep daily
const dailyMap = new Map();
history.forEach((entry, idx) => {
const date = new Date(entry.timestamp);
const dayKey = date.toISOString().split('T')[0];
if (!dailyMap.has(dayKey)) {
dailyMap.set(dayKey, []);
}
dailyMap.get(dayKey).push(idx);
});
dailyMap.forEach(indices => {
for (let i = 0; i < indices.length - 1; i++) {
selectedIndices.add(indices[i]);
}
});
} else if (selectedPreset === 'weekly') {
// Keep weekly
const weeklyMap = new Map();
history.forEach((entry, idx) => {
const date = new Date(entry.timestamp);
const dayOfWeek = date.getUTCDay();
const diff = date.getUTCDate() - dayOfWeek + (dayOfWeek === 0 ? -6 : 1);
const weekStart = new Date(date.setUTCDate(diff));
const weekKey = weekStart.toISOString().split('T')[0];
if (!weeklyMap.has(weekKey)) {
weeklyMap.set(weekKey, []);
}
weeklyMap.get(weekKey).push(idx);
});
weeklyMap.forEach(indices => {
for (let i = 0; i < indices.length - 1; i++) {
selectedIndices.add(indices[i]);
}
});
} else if (selectedPreset === '7days') {
// 7+ days old
const now = getVirtualNow();
const sevenDaysMs = 7 * 24 * 60 * 60 * 1000;
history.forEach((entry, idx) => {
if ((now - entry.timestamp) > sevenDaysMs) {
selectedIndices.add(idx);
}
});
}
renderCheckboxList();
updateCheckboxStates();
}
function updateCheckboxStates() {
allCheckbox.checked = selectedPreset === 'all';
dailyCheckbox.checked = selectedPreset === 'daily';
weeklyCheckbox.checked = selectedPreset === 'weekly';
deleteOldCheckbox.checked = selectedPreset === '7days';
}
// All snapshots checkbox
const allOption = document.createElement('label');
allOption.className = 'gmi-checkbox-option';
allOption.style.color = 'var(--color-red-500, #ef4444)';
const allCheckbox = document.createElement('input');
allCheckbox.type = 'checkbox';
allCheckbox.style.cssText = checkboxInputStyle;
const allLabel = document.createElement('span');
allLabel.textContent = 'Select All';
allLabel.style.cssText = 'user-select: none;';
allOption.appendChild(allCheckbox);
allOption.appendChild(allLabel);
allOption.onclick = (e) => {
if (e.target === allCheckbox) updatePresetSelection('all');
};
allOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-red-500, #ef4444)';
allOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'all' ? 'var(--color-red-500, #ef4444)' : 'var(--color-gray-300, #ddd)';
shortcutsDiv.appendChild(allOption);
// Keep daily checkbox
const dailyOption = document.createElement('label');
dailyOption.className = 'gmi-checkbox-option';
dailyOption.style.color = 'var(--color-yellow-500, #f59e0b)';
const dailyCheckbox = document.createElement('input');
dailyCheckbox.type = 'checkbox';
dailyCheckbox.style.cssText = checkboxInputStyle;
dailyCheckbox.style.accentColor = 'var(--color-yellow-500, #f59e0b)';
const dailyLabel = document.createElement('span');
dailyLabel.textContent = 'Keep One Per Day';
dailyLabel.style.cssText = 'user-select: none;';
dailyOption.appendChild(dailyCheckbox);
dailyOption.appendChild(dailyLabel);
dailyOption.onclick = (e) => {
if (e.target === dailyCheckbox) updatePresetSelection('daily');
};
dailyOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-yellow-500, #f59e0b)';
dailyOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'daily' ? 'var(--color-yellow-500, #f59e0b)' : 'var(--color-gray-300, #ddd)';
shortcutsDiv.appendChild(dailyOption);
// Keep weekly checkbox
const weeklyOption = document.createElement('label');
weeklyOption.className = 'gmi-checkbox-option';
weeklyOption.style.color = 'var(--color-blue-500, #3b82f6)';
const weeklyCheckbox = document.createElement('input');
weeklyCheckbox.type = 'checkbox';
weeklyCheckbox.style.cssText = checkboxInputStyle;
const weeklyLabel = document.createElement('span');
weeklyLabel.textContent = 'Keep One Per Week';
weeklyLabel.style.cssText = 'user-select: none;';
weeklyOption.appendChild(weeklyCheckbox);
weeklyOption.appendChild(weeklyLabel);
weeklyOption.onclick = (e) => {
if (e.target === weeklyCheckbox) updatePresetSelection('weekly');
};
weeklyOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-blue-500, #3b82f6)';
weeklyOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === 'weekly' ? 'var(--color-blue-500, #3b82f6)' : 'var(--color-gray-300, #ddd)';
shortcutsDiv.appendChild(weeklyOption);
// 7+ days old checkbox
const deleteOldOption = document.createElement('label');
deleteOldOption.className = 'gmi-checkbox-option';
deleteOldOption.style.color = 'var(--color-purple-500, #8b5cf6)';
const deleteOldCheckbox = document.createElement('input');
deleteOldCheckbox.type = 'checkbox';
deleteOldCheckbox.style.cssText = checkboxInputStyle;
deleteOldCheckbox.style.accentColor = 'var(--color-purple-500, #8b5cf6)';
const deleteOldLabel = document.createElement('span');
deleteOldLabel.textContent = 'Delete 7+ Days Old';
deleteOldLabel.style.cssText = 'user-select: none;';
deleteOldOption.appendChild(deleteOldCheckbox);
deleteOldOption.appendChild(deleteOldLabel);
deleteOldOption.onclick = (e) => {
if (e.target === deleteOldCheckbox) updatePresetSelection('7days');
};
deleteOldOption.onmouseover = (e) => e.currentTarget.style.borderColor = 'var(--color-purple-500, #8b5cf6)';
deleteOldOption.onmouseout = (e) => e.currentTarget.style.borderColor = selectedPreset === '7days' ? 'var(--color-purple-500, #8b5cf6)' : 'var(--color-gray-300, #ddd)';
shortcutsDiv.appendChild(deleteOldOption);
modal.appendChild(shortcutsDiv);
// Snapshot list container
const listContainer = document.createElement('div');
listContainer.className = 'gmi-snapshot-list';
listContainer.style.cssText = `
flex: 1;
overflow-y: auto;
`;
modal.appendChild(listContainer);
function renderCheckboxList() {
listContainer.innerHTML = '';
if (history.length === 0) {
listContainer.innerHTML = '<p style="color: #6b7280; text-align: center; padding: 20px;">No snapshots available.</p>';
return;
}
let currentDayKey = null;
let useAltColor = false;
history.forEach((entry, idx) => {
const item = document.createElement('div');
const isSelected = selectedIndices.has(idx);
// Check if date changed
const entryDate = new Date(entry.timestamp);
const entryDayKey = entryDate.toISOString().split('T')[0]; // YYYY-MM-DD
if (entryDayKey !== currentDayKey) {
currentDayKey = entryDayKey;
useAltColor = !useAltColor; // Toggle color when day changes
}
item.className = isSelected ? 'gmi-snapshot-item selected' : 'gmi-snapshot-item';
if (!isSelected && useAltColor) {
item.style.background = 'var(--color-gray-100, #f3f4f6)';
}
const checkbox = document.createElement('input');
checkbox.type = 'checkbox';
checkbox.checked = selectedIndices.has(idx);
checkbox.style.cssText = 'margin-right: 10px; cursor: pointer; accent-color: var(--color-blue-500, #3b82f6);';
checkbox.onchange = (e) => {
if (e.target.checked) {
selectedIndices.add(idx);
} else {
selectedIndices.delete(idx);
}
renderCheckboxList();
};
item.appendChild(checkbox);
const label = document.createElement('label');
label.style.cssText = `flex: 1; cursor: pointer; font-size: 12px; color: ${isSelected ? 'var(--color-red-700, #991b1b)' : 'var(--color-gray-700, #374151)'}; ${isSelected ? 'text-decoration: line-through;' : ''}`;
label.onclick = () => {
checkbox.checked = !checkbox.checked;
if (checkbox.checked) {
selectedIndices.add(idx);
} else {
selectedIndices.delete(idx);
}
renderCheckboxList();
};
const timestamp = new Date(entry.timestamp);
const memberCount = Object.keys(entry.members).length;
label.innerHTML = `
<span style="font-weight: bold;">${idx + 1})</span>
${timestamp.toLocaleString()}
<span style="color: ${isSelected ? 'var(--color-red-600, #b91c1c)' : 'var(--color-gray-500, #6b7280)'};">(${memberCount} members)</span>
`;
item.appendChild(label);
listContainer.appendChild(item);
});
}
renderCheckboxList();
// Bottom buttons
const buttonDiv = document.createElement('div');
buttonDiv.style.cssText = `
display: flex;
gap: 10px;
flex-wrap: wrap;
border-top: 1px solid var(--color-gray-200, #e5e7eb);
padding-top: 15px;
`;
const deleteSelectedBtn = document.createElement('button');
deleteSelectedBtn.innerHTML = '🗑️ Delete Selected';
deleteSelectedBtn.className = 'gmi-action-btn danger';
deleteSelectedBtn.onclick = () => {
if (selectedIndices.size === 0) {
alert('No snapshots selected.');
return;
}
const newHistory = history.filter((_, idx) => !selectedIndices.has(idx));
const deleted = history.length - newHistory.length;
if (confirm(`Delete ${deleted} snapshot(s)?`)) {
GM_setValue('guild_xp_history', newHistory);
overlay.remove();
modal.remove();
onClose(newHistory);
}
};
buttonDiv.appendChild(deleteSelectedBtn);
const exportBtn = document.createElement('button');
exportBtn.innerHTML = '💾 Export Snapshots';
exportBtn.className = 'gmi-action-btn primary';
exportBtn.onclick = () => {
exportSnapshots();
};
buttonDiv.appendChild(exportBtn);
const importBtn = document.createElement('button');
importBtn.innerHTML = '📥 Import Snapshots';
importBtn.className = 'gmi-action-btn success';
importBtn.onclick = () => {
importSnapshots();
};
buttonDiv.appendChild(importBtn);
const cancelBtn = document.createElement('button');
cancelBtn.innerHTML = '✕ Close';
cancelBtn.className = 'gmi-action-btn neutral';
cancelBtn.onclick = () => {
overlay.remove();
modal.remove();
};
buttonDiv.appendChild(cancelBtn);
modal.appendChild(buttonDiv);
overlay.onclick = () => {
overlay.remove();
modal.remove();
};
document.body.appendChild(overlay);
document.body.appendChild(modal);
}
function renderXPChanges(container) {
container.innerHTML = '';
container.style.display = 'flex';
container.style.flexDirection = 'column';
container.style.height = '100%';
const currentMembers = parseGuildMembers();
let history = GM_getValue('guild_xp_history', []);
if (!currentMembers || Object.keys(currentMembers).length === 0) {
container.innerHTML = '<p class="text-gray-500">Please wait for members to load...</p>';
return;
}
// --- Controls ---
const controls = document.createElement('div');
controls.style.marginBottom = '15px';
controls.style.display = 'flex';
controls.style.flexDirection = 'column';
controls.style.gap = '10px';
// Snapshot Button + Action Buttons
const snapRow = document.createElement('div');
snapRow.style.display = 'flex';
snapRow.style.justifyContent = 'flex-end';
snapRow.style.gap = '8px';
snapRow.style.flexWrap = 'wrap';
const snapBtn = document.createElement('button');
snapBtn.innerHTML = '📷 Take a Snapshot';
snapBtn.className = 'control-button';
snapBtn.onclick = () => {
history = saveGuildSnapshot(currentMembers, true);
renderXPChanges(container);
};
snapRow.appendChild(snapBtn);
const csvBtn = document.createElement('button');
csvBtn.innerHTML = '📥 Export CSV';
csvBtn.className = 'control-button';
csvBtn.onclick = () => {
// Pass current selection to export function
exportToCSV(history, currentMembers, fromSelect.value, toSelect.value);
};
snapRow.appendChild(csvBtn);
const exportAllDataBtn = document.createElement('button');
exportAllDataBtn.innerHTML = '🎨 Export All User Data';
exportAllDataBtn.className = 'control-button';
exportAllDataBtn.style.color = '#a855f7';
exportAllDataBtn.title = 'Fetch and export all guild members\' data (including colors) as JSON';
exportAllDataBtn.onclick = async () => {
exportAllDataBtn.disabled = true;
exportAllDataBtn.style.opacity = '0.5';
await fetchAllGuildMembersData();
exportAllDataBtn.disabled = false;
exportAllDataBtn.style.opacity = '1';
};
snapRow.appendChild(exportAllDataBtn);
const cleanBtn = document.createElement('button');
cleanBtn.innerHTML = '🧹 Manage History';
cleanBtn.className = 'control-button';
cleanBtn.style.color = '#ef4444';
cleanBtn.onclick = () => {
renderCleanHistoryModal((newHistory) => {
history = newHistory;
renderXPChanges(container);
});
};
snapRow.appendChild(cleanBtn);
controls.appendChild(snapRow);
// Selectors
const getOptions = () => {
const snaps = history.map((entry, index) => ({
label: `${index + 1}) ${new Date(entry.timestamp).toLocaleString()}`,
value: index,
members: entry.members
}));
const curr = {
label: `Now (${new Date(getVirtualNow()).toLocaleString()})`,
value: 'current',
members: currentMembers
};
return { snaps, curr, all: [...snaps, curr] };
};
let { snaps: snapshots, curr: currentSnapshot, all: allOptions } = getOptions();
// Filter buttons
const filterRow = document.createElement('div');
filterRow.style.display = 'flex';
filterRow.style.gap = '8px';
filterRow.style.flexWrap = 'wrap';
let filterMode = 'all'; // 'all', 'active', 'inactive', 'in-territory', 'out-of-territory'
const clearAllFilterActive = () => {
allBtn.classList.remove('active');
activeBtn.classList.remove('active');
inactiveBtn.classList.remove('active');
inTerritoryBtn.classList.remove('active');
outOfTerritoryBtn.classList.remove('active');
};
const allBtn = document.createElement('button');
allBtn.innerHTML = 'Show All';
allBtn.className = 'control-button active';
allBtn.onclick = () => {
filterMode = 'all';
clearAllFilterActive();
allBtn.classList.add('active');
updateTable();
};
filterRow.appendChild(allBtn);
const activeBtn = document.createElement('button');
activeBtn.innerHTML = 'Active';
activeBtn.className = 'control-button';
activeBtn.onclick = () => {
filterMode = 'active';
clearAllFilterActive();
activeBtn.classList.add('active');
updateTable();
};
filterRow.appendChild(activeBtn);
const inactiveBtn = document.createElement('button');
inactiveBtn.innerHTML = 'Inactive';
inactiveBtn.className = 'control-button';
inactiveBtn.onclick = () => {
filterMode = 'inactive';
clearAllFilterActive();
inactiveBtn.classList.add('active');
updateTable();
};
filterRow.appendChild(inactiveBtn);
const inTerritoryBtn = document.createElement('button');
inTerritoryBtn.innerHTML = '🟦 In Territory';
inTerritoryBtn.className = 'control-button xp-territory-filter-btn';
inTerritoryBtn.style.display = playersVisible ? '' : 'none';
inTerritoryBtn.onclick = () => {
filterMode = 'in-territory';
clearAllFilterActive();
inTerritoryBtn.classList.add('active');
updateTable();
};
filterRow.appendChild(inTerritoryBtn);
const outOfTerritoryBtn = document.createElement('button');
outOfTerritoryBtn.innerHTML = '🟥 Out of Territory';
outOfTerritoryBtn.className = 'control-button xp-territory-filter-btn';
outOfTerritoryBtn.style.display = playersVisible ? '' : 'none';
outOfTerritoryBtn.onclick = () => {
filterMode = 'out-of-territory';
clearAllFilterActive();
outOfTerritoryBtn.classList.add('active');
updateTable();
};
filterRow.appendChild(outOfTerritoryBtn);
controls.appendChild(filterRow);
const row1 = document.createElement('div');
row1.style.display = 'flex';
row1.style.gap = '10px';
row1.style.alignItems = 'center';
row1.style.flexWrap = 'wrap';
const fromSelect = document.createElement('select');
fromSelect.style.flex = '1';
fromSelect.style.padding = '4px';
fromSelect.style.border = '2px solid #3b82f6';
fromSelect.style.borderRadius = '4px';
const toSelect = document.createElement('select');
toSelect.style.flex = '1';
toSelect.style.padding = '4px';
toSelect.style.border = '2px solid #3b82f6';
toSelect.style.borderRadius = '4px';
// Populate
allOptions.forEach(opt => {
fromSelect.add(new Option(opt.label, opt.value));
toSelect.add(new Option(opt.label, opt.value));
});
// Defaults
if (snapshots.length >= 1) {
fromSelect.value = snapshots[snapshots.length - 1].value;
} else {
fromSelect.value = 'current';
}
toSelect.value = 'current';
row1.appendChild(document.createTextNode('From:'));
row1.appendChild(fromSelect);
// Delete "From" button
const deleteFromBtn = document.createElement('button');
deleteFromBtn.className = 'trash-btn';
deleteFromBtn.innerHTML = '🗑️';
deleteFromBtn.title = 'Delete this snapshot';
deleteFromBtn.onclick = () => {
const snapIndex = parseInt(fromSelect.value);
if (snapIndex >= 0 && snapIndex < history.length) {
if (confirm('Delete this snapshot?')) {
history.splice(snapIndex, 1);
GM_setValue('guild_xp_history', history);
renderXPChanges(container);
}
}
};
row1.appendChild(deleteFromBtn);
row1.appendChild(document.createTextNode('To:'));
row1.appendChild(toSelect);
// Delete "To" button
const deleteToBtn = document.createElement('button');
deleteToBtn.className = 'trash-btn';
deleteToBtn.innerHTML = '🗑️';
deleteToBtn.title = 'Delete this snapshot';
deleteToBtn.onclick = () => {
const snapIndex = parseInt(toSelect.value);
if (snapIndex >= 0 && snapIndex < history.length) {
if (confirm('Delete this snapshot?')) {
history.splice(snapIndex, 1);
GM_setValue('guild_xp_history', history);
renderXPChanges(container);
}
}
};
row1.appendChild(deleteToBtn);
controls.appendChild(row1);
// Results Area
const resultsDiv = document.createElement('div');
resultsDiv.style.flex = '1';
resultsDiv.style.overflowY = 'auto';
resultsDiv.style.minHeight = '0'; // Crucial for flexbox scrolling
resultsDiv.style.border = '1px solid #e5e7eb';
resultsDiv.style.borderRadius = '0.5rem';
const updateTable = () => {
resultsDiv.innerHTML = '';
const fromVal = fromSelect.value;
const toVal = toSelect.value;
const fromData = fromVal === 'current' ? currentSnapshot : snapshots[fromVal];
const toData = toVal === 'current' ? currentSnapshot : snapshots[toVal];
if (!fromData || !toData) return;
let changes = calculateXPChanges(fromData.members, toData.members);
// Apply filter
if (filterMode === 'active') {
changes = changes.filter(c => {
// Active = Joined OR Positive XP Gain
return c.type === 'join' || c.diff > 0;
});
} else if (filterMode === 'inactive') {
changes = changes.filter(c => {
// Inactive = Left OR Zero/Negative XP Gain
return c.type === 'left' || c.diff <= 0;
});
} else if (filterMode === 'in-territory') {
changes = changes.filter(c => {
const markerInfo = playerMarkerData.find(m => m.name === c.id);
return markerInfo && markerInfo.inTerritory;
});
} else if (filterMode === 'out-of-territory') {
changes = changes.filter(c => {
const markerInfo = playerMarkerData.find(m => m.name === c.id);
return markerInfo && !markerInfo.inTerritory;
});
}
// Sort
changes.sort((a, b) => {
if (a.type === 'join') return -1;
if (b.type === 'join') return 1;
if (a.type === 'left') return 1;
if (b.type === 'left') return -1;
return b.diff - a.diff;
});
const table = document.createElement('table');
table.className = 'daily-brief-table';
table.innerHTML = `<thead><tr><th>User</th><th>Change</th><th>Details</th></tr></thead>`;
const tbody = document.createElement('tbody');
if (changes.length === 0) {
tbody.innerHTML = `<tr><td colspan="3" style="text-align:center">No changes.</td></tr>`;
} else {
changes.forEach(change => {
const tr = document.createElement('tr');
// User Cell with Buttons and Coordinates
const userTd = document.createElement('td');
userTd.style.display = 'flex';
userTd.style.alignItems = 'center';
userTd.style.gap = '4px';
// Create user info (name only)
const nameSpan = document.createElement('span');
nameSpan.className = 'user-name';
nameSpan.textContent = change.id;
userTd.appendChild(nameSpan);
// Extract ID
const match = change.id.match(/#(\d+)$/);
if (match) {
const userId = match[1];
// Discord Button
const discordBtn = document.createElement('button');
discordBtn.className = 'member-icon-btn discord-icon';
discordBtn.title = 'Check Discord';
discordBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 127.14 96.36" width="16" height="16" fill="currentColor">
<path d="M107.7,8.07A105.15,105.15,0,0,0,81.47,0a72.06,72.06,0,0,0-3.36,6.83A97.68,97.68,0,0,0,49,6.83,72.37,72.37,0,0,0,45.64,0,105.89,105.89,0,0,0,19.39,8.09C2.79,32.65-1.71,56.6.54,80.21h0A105.73,105.73,0,0,0,32.71,96.36,77.11,77.11,0,0,0,39.6,85.25a68.42,68.42,0,0,1-10.85-5.18c.91-.66,1.8-1.34,2.66-2a75.57,75.57,0,0,0,64.32,0c.87.71,1.76,1.39,2.66,2a68.68,68.68,0,0,1-10.87,5.19,77,77,0,0,0,6.89,11.1A105.25,105.25,0,0,0,126.6,80.22c1.24-23.25-13.28-47.54-18.9-72.15ZM42.45,65.69C36.18,65.69,31,60,31,53s5-12.74,11.43-12.74S54,46,53.89,53,48.84,65.69,42.45,65.69Zm42.24,0C78.41,65.69,73.25,60,73.25,53s5-12.74,11.44-12.74S96.23,46,96.12,53,91.08,65.69,84.69,65.69Z"/>
</svg>
`;
discordBtn.onclick = async (e) => {
e.stopPropagation();
const data = await fetchUserProfile(userId);
if (data && data.discordUser) {
navigator.clipboard.writeText(data.discordUser).then(() => {
showTooltip(e.clientX, e.clientY, `Discord ID: ${data.discordUser} copied!`);
});
} else {
showTooltip(e.clientX, e.clientY, 'No Discord ID found.');
}
};
userTd.appendChild(discordBtn);
}
// Map Button
if (change.coords) {
const mapBtn = document.createElement('button');
mapBtn.className = 'member-icon-btn map-icon';
mapBtn.setAttribute('data-player-name', change.id);
// If player markers are active, mark out-of-territory players red
if (playersVisible && playerMarkerData.length > 0) {
const markerInfo = playerMarkerData.find(m => m.name === change.id);
if (markerInfo && !markerInfo.inTerritory) {
mapBtn.classList.add('out-of-territory');
}
}
mapBtn.title = 'Find on Map';
mapBtn.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round">
<circle cx="12" cy="10" r="3"/>
<path d="M12 21.7C17.3 17 20 13 20 10a8 8 0 1 0-16 0c0 3 2.7 7 8 11.7z"/>
</svg>
`;
const coordKey = `${change.coords[0]},${change.coords[1]}`;
if (sessionState.visitedCoords.has(coordKey)) {
mapBtn.classList.add('visited');
}
mapBtn.onclick = () => {
// Find the original Find button in the member row and click it
const memberName = change.id;
const memberRows = document.querySelectorAll('#guildMembersContainer div.flex.items-center.justify-between');
let found = false;
for (const row of memberRows) {
const nameEl = row.querySelector('p.font-semibold');
if (nameEl) {
// Remove badge the same way parseGuildMembers does
let displayName = nameEl.textContent.trim();
const badge = nameEl.querySelector('span');
if (badge) {
displayName = displayName.replace(badge.textContent, '').trim();
}
// Match by exact name to handle both users with and without usernames
if (displayName === memberName) {
const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
if (findBtn) {
findBtn.click();
found = true;
break;
}
}
}
}
if (!found && window.goToGridLocation) {
window.goToGridLocation(change.coords[0], change.coords[1]);
}
// Mark as visited
sessionState.visitedCoords.add(coordKey);
mapBtn.classList.add('visited');
};
userTd.appendChild(mapBtn);
}
// Display coordinates if available (right-aligned)
if (change.coords) {
const spacer = document.createElement('div');
spacer.style.flex = '1';
userTd.appendChild(spacer);
const coordsSpan = document.createElement('span');
coordsSpan.className = 'user-coords';
// Get colors based on quadrant and distance
const colors = getCoordinateColor(change.coords);
coordsSpan.style.backgroundColor = colors.bg;
coordsSpan.style.padding = '2px 6px';
coordsSpan.style.borderRadius = '3px';
// Create styled parts
const openParen = document.createElement('span');
openParen.style.color = colors.text;
openParen.textContent = '(';
const xVal = document.createElement('span');
xVal.style.color = colors.text;
xVal.style.fontWeight = '500';
xVal.textContent = change.coords[0];
const comma = document.createElement('span');
comma.style.color = colors.text;
comma.textContent = ', ';
const yVal = document.createElement('span');
yVal.style.color = colors.text;
yVal.style.fontWeight = '500';
yVal.textContent = change.coords[1];
const closeParen = document.createElement('span');
closeParen.style.color = colors.text;
closeParen.textContent = ')';
coordsSpan.appendChild(openParen);
coordsSpan.appendChild(xVal);
coordsSpan.appendChild(comma);
coordsSpan.appendChild(yVal);
coordsSpan.appendChild(closeParen);
userTd.appendChild(coordsSpan);
}
let changeCell = '';
if (change.type === 'gain') {
changeCell = change.diff > 0 ? `<td class="xp-gain">+${change.diff.toLocaleString()}</td>` :
(change.diff < 0 ? `<td class="xp-loss">${change.diff.toLocaleString()}</td>` : `<td class="xp-neutral">0</td>`);
} else if (change.type === 'join') {
changeCell = `<td class="xp-gain">JOINED</td>`;
} else if (change.type === 'left') {
changeCell = `<td class="xp-loss">LEFT</td>`;
}
tr.appendChild(userTd);
// Change Cell
const changeTd = document.createElement('td');
changeTd.innerHTML = changeCell.replace(/^<td.*?>|<\/td>$/g, ''); // Strip outer td tags since we are creating td
changeTd.className = changeCell.match(/class="([^"]+)"/)?.[1] || '';
tr.appendChild(changeTd);
// Details Cell
const detailsTd = document.createElement('td');
detailsTd.textContent = `${change.oldXp?.toLocaleString() || 0} → ${change.newXp?.toLocaleString() || 0}`;
tr.appendChild(detailsTd);
tbody.appendChild(tr);
});
}
table.appendChild(tbody);
resultsDiv.appendChild(table);
};
fromSelect.onchange = updateTable;
toSelect.onchange = updateTable;
updateTable();
container.appendChild(controls);
container.appendChild(resultsDiv);
}
function formatSnapshotInterval(ms) {
const seconds = ms / 1000;
if (seconds < 60) return `${seconds}s`;
const minutes = seconds / 60;
if (minutes < 60) return `${minutes.toFixed(1)}m`;
const hours = minutes / 60;
if (hours < 24) return `${hours.toFixed(1)}h`;
const days = hours / 24;
return `${days.toFixed(1)}d`;
}
function getSnapshotIntervalLabel(ms) {
if (ms === SNAPSHOT_INTERVALS.HOURLY) return 'Hourly (1h)';
if (ms === SNAPSHOT_INTERVALS.TWELVE_HOURS) return '12 Hours';
if (ms === SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS) return '24 Hours';
return `Custom (${formatSnapshotInterval(ms)})`;
}
function updateSnapshotIntervalDropdown(dropdown) {
// Update dropdown to show current value
if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.HOURLY) {
dropdown.value = 'hourly';
} else if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.TWELVE_HOURS) {
dropdown.value = '12h';
} else if (CONFIG.minSnapshotInterval === SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS) {
dropdown.value = '24h';
} else {
dropdown.value = 'custom';
const customOption = dropdown.querySelector('option[value="custom"]');
if (customOption) {
customOption.textContent = `Custom (${formatSnapshotInterval(CONFIG.minSnapshotInterval)})`;
}
}
}
// =====================================================
// === TERRITORY MAP OVERLAY (New in 3.0.0) ===
// =====================================================
/**
* Create the territory overlay canvas that sits on top of the map.
* Similar approach to geopixels++ censor canvas but draws stroke-only rectangles.
*/
function createTerritoryCanvas() {
if (territoryCanvas) return;
territoryCanvas = document.createElement('canvas');
territoryCanvas.id = 'territory-canvas';
document.body.appendChild(territoryCanvas);
console.log('[Guild Territories] Territory canvas created');
}
/**
* Load an image from a data URL and return its natural dimensions.
*/
function getImageDimensionsFromSrc(src) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => resolve({ width: img.naturalWidth, height: img.naturalHeight });
img.onerror = () => reject(new Error('Failed to load image'));
img.src = src;
});
}
/**
* Process all guild projects and build territory rectangles.
* Each project has imageGridX, imageGridY (top-left) and an image (base64 PNG).
* Width/height are the pixel dimensions of the PNG (1 pixel = 1 grid unit).
*/
async function buildTerritoryRects() {
if (typeof userGuildData === 'undefined' || !userGuildData || !userGuildData.projects) {
console.warn('[Guild Territories] No guild data or projects available');
return [];
}
const projects = userGuildData.projects;
if (projects.length === 0) return [];
const rects = [];
for (let i = 0; i < projects.length; i++) {
const project = projects[i];
try {
const dims = await getImageDimensionsFromSrc(project.image);
rects.push({
gridX: project.imageGridX,
gridY: project.imageGridY,
width: dims.width,
height: dims.height,
index: i + 1 // 1-based logical order matching guild modal display
});
} catch (err) {
console.warn(`[Guild Territories] Failed to get dimensions for project #${i + 1} (id ${project.id}):`, err);
}
}
return rects;
}
/**
* Export guild territories as JSON compatible with the GeoPixels Json "Import JSON" feature.
* Copies to clipboard in the format: [{ name, x, y, width, height }]
* where x,y is top-left corner (matching Tauri/Json region format).
*/
async function exportTerritoriesForJson() {
const exportBtn = document.getElementById('exportTerritoriesBtn');
if (exportBtn) {
exportBtn.disabled = true;
exportBtn.innerHTML = '⏳ Loading...';
}
try {
// Ensure guild projects are fetched
if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
await fetchGuildProjects();
}
const rects = await buildTerritoryRects();
if (rects.length === 0) {
alert('No guild projects found to export.');
return;
}
// Convert to json-compatible format
const regions = rects.map(rect => ({
name: `Template #${rect.index}`,
x: rect.gridX,
y: rect.gridY,
width: rect.width,
height: rect.height
}));
const json = JSON.stringify(regions, null, 2);
try {
await navigator.clipboard.writeText(json);
if (exportBtn) {
exportBtn.innerHTML = '✅ Copied!';
setTimeout(() => { exportBtn.innerHTML = '📋 Export to Clipboard'; }, 2000);
}
console.log(`[Guild Territories] Exported ${regions.length} territories to clipboard for Json import`);
} catch (clipErr) {
// Fallback: show in prompt for manual copy
prompt('Copy this JSON and paste into Json\'s "Import JSON":', json);
}
} catch (err) {
console.error('[Guild Territories] Export failed:', err);
alert('Failed to export territories: ' + err.message);
} finally {
if (exportBtn) exportBtn.disabled = false;
// Restore button text if not in "Copied!" state
if (exportBtn && !exportBtn.innerHTML.includes('✅')) {
exportBtn.innerHTML = '📋 Export to Clipboard';
}
}
}
/**
* Draw a single territory border rectangle on the canvas.
* Converts grid coordinates → Mercator → WGS84 → screen pixels.
*
* The coordinate system:
* - gridX, gridY = top-left of the image in grid space
* - In GeoPixels, Y axis in grid space is inverted relative to image space
* (gridY is top, gridY - height is bottom)
*/
function drawTerritoryRect(ctx, rect, gSize, color, thickness, fillColor) {
if (typeof turf === 'undefined' || typeof map === 'undefined') return;
// Top-left in mercator: gridX is left edge, gridY is top edge
// The image extends rightward (+X) and downward (-Y in grid terms)
const topLeftMerc = [
(rect.gridX - 0.5) * gSize,
(rect.gridY + 0.5) * gSize
];
const bottomRightMerc = [
(rect.gridX - 0.5 + rect.width) * gSize,
(rect.gridY + 0.5 - rect.height) * gSize
];
const topLeftScreen = map.project(turf.toWgs84(topLeftMerc));
const bottomRightScreen = map.project(turf.toWgs84(bottomRightMerc));
const screenX = topLeftScreen.x;
const screenY = topLeftScreen.y;
const screenW = bottomRightScreen.x - topLeftScreen.x;
const screenH = bottomRightScreen.y - topLeftScreen.y;
// Frustum culling - skip if entirely off-screen
if (
screenX + screenW < 0 ||
screenX > ctx.canvas.width ||
screenY + screenH < 0 ||
screenY > ctx.canvas.height
) return;
// Optional fill
if (territorySettings.showFill) {
ctx.fillStyle = fillColor || territorySettings.fillColor;
ctx.globalAlpha = territorySettings.fillAlpha;
ctx.fillRect(screenX, screenY, screenW, screenH);
}
// Border stroke
ctx.strokeStyle = color;
ctx.lineWidth = thickness;
ctx.globalAlpha = 1;
ctx.strokeRect(screenX, screenY, screenW, screenH);
// Draw project label if enabled (uses logical order #1, #2, etc.)
if (territorySettings.showLabels && screenW > 40 && screenH > 20) {
const label = `#${rect.index}`;
ctx.font = `bold ${territorySettings.labelFontSize}px sans-serif`;
ctx.fillStyle = color;
ctx.globalAlpha = 0.85;
ctx.textAlign = 'left';
ctx.textBaseline = 'top';
ctx.fillText(label, screenX + 4, screenY + 4);
}
}
/**
* Build a map of territory index → boolean indicating whether any guild
* member is currently positioned inside each territory.
* Used to distinguish active (in-use) vs abandoned/finished territories.
*/
function buildTerritoryActivityMap() {
const activity = {};
if (territoryRects.length === 0) return activity;
const members = parseGuildMembers();
if (!members || Object.keys(members).length === 0) return activity;
for (const rect of territoryRects) {
let hasPlayers = false;
for (const [, data] of Object.entries(members)) {
const coords = getCoords(data);
if (coords) {
const [gx, gy] = coords;
if (
gx >= rect.gridX &&
gx < rect.gridX + rect.width &&
gy <= rect.gridY &&
gy > rect.gridY - rect.height
) {
hasPlayers = true;
break;
}
}
}
activity[rect.index] = hasPlayers;
}
return activity;
}
/**
* Redraw all territory rectangles on the overlay canvas.
*/
function drawTerritories() {
if (!territoryCanvas || !territoryVisible) return;
const pixelCanvas = document.getElementById('pixel-canvas');
if (!pixelCanvas) return;
territoryCanvas.width = pixelCanvas.width;
territoryCanvas.height = pixelCanvas.height;
const ctx = territoryCanvas.getContext('2d');
ctx.clearRect(0, 0, territoryCanvas.width, territoryCanvas.height);
if (territoryRects.length === 0) return;
const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
const thickness = territorySettings.borderThickness;
// Build activity map for two-tone coloring if enabled
if (territorySettings.colorByActivity) {
territoryActivityMap = buildTerritoryActivityMap();
}
// When both territories and players are visible, compute occupancy
// to highlight unoccupied territories in red
const occupancyMap = (playersVisible && playerMarkerData.length > 0)
? buildTerritoryActivityMap()
: null;
territoryRects.forEach(rect => {
let color = territorySettings.borderColor;
let fillColor = territorySettings.fillColor;
if (territorySettings.colorByActivity) {
const isActive = territoryActivityMap[rect.index] ?? false;
color = isActive ? territorySettings.activeBorderColor : territorySettings.abandonedBorderColor;
fillColor = isActive ? territorySettings.activeFillColor : territorySettings.abandonedFillColor;
}
// Override: if players are visible and territory is unoccupied, color red
if (occupancyMap && !(occupancyMap[rect.index])) {
color = '#ef4444';
fillColor = '#ef4444';
}
drawTerritoryRect(ctx, rect, gSize, color, thickness, fillColor);
});
}
/**
* Hook into map events so territories redraw on pan/zoom/resize.
*/
function hookTerritoryToMap() {
function waitForMapReady(callback) {
let tries = 0;
function check() {
if (typeof map !== 'undefined' && map && map.on && map.getContainer) callback();
else if (tries++ < 100) setTimeout(check, 100);
}
check();
}
waitForMapReady(() => {
['move', 'rotate', 'zoom'].forEach(ev => map.on(ev, drawTerritories));
new ResizeObserver(drawTerritories).observe(map.getContainer());
map.once('load', drawTerritories);
console.log('[Guild Territories] Hooked to map events');
});
}
/**
* Toggle territory overlay on/off. If turning on, process projects first.
*/
async function toggleTerritories() {
if (territoryVisible) {
// Turn off
territoryVisible = false;
if (territoryCanvas) {
const ctx = territoryCanvas.getContext('2d');
ctx.clearRect(0, 0, territoryCanvas.width, territoryCanvas.height);
}
updateTerritoryToggleButton();
console.log('[Guild Territories] Territories hidden');
return;
}
// Turn on - process projects
const toggleBtn = document.getElementById('territoryToggleBtn');
if (toggleBtn) {
toggleBtn.disabled = true;
toggleBtn.innerHTML = '⏳ Processing...';
}
try {
// Ensure guild projects are fetched
if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
await fetchGuildProjects();
}
territoryRects = await buildTerritoryRects();
if (territoryRects.length === 0) {
if (toggleBtn) {
toggleBtn.disabled = false;
toggleBtn.innerHTML = '🗺️ Show Territories';
toggleBtn.className = 'territory-toggle-btn inactive';
}
alert('No guild projects found to display territories for.');
return;
}
createTerritoryCanvas();
territoryVisible = true;
drawTerritories();
updateTerritoryToggleButton();
console.log(`[Guild Territories] Showing ${territoryRects.length} territories`);
} catch (err) {
console.error('[Guild Territories] Error building territories:', err);
alert('Failed to process territories: ' + err.message);
}
if (toggleBtn) toggleBtn.disabled = false;
}
/**
* Update the toggle button appearance based on state.
*/
function updateTerritoryToggleButton() {
const toggleBtn = document.getElementById('territoryToggleBtn');
if (!toggleBtn) return;
if (territoryVisible) {
toggleBtn.innerHTML = '🗺️ Hide Territories';
toggleBtn.className = 'territory-toggle-btn active';
} else {
toggleBtn.innerHTML = '🗺️ Show Territories';
toggleBtn.className = 'territory-toggle-btn inactive';
}
}
/**
* Build the inline collapsible settings panel HTML.
* Returns the container element to be appended inside the territory controls.
*/
function buildTerritorySettingsPanel() {
const wrapper = document.createElement('div');
wrapper.className = 'territory-settings-collapsible';
wrapper.id = 'territorySettingsCollapsible';
// Toggle header
const toggle = document.createElement('button');
toggle.className = 'territory-settings-toggle';
toggle.innerHTML = '<span>⚙️ Settings</span><span class="toggle-arrow collapsed">▼</span>';
// Content
const content = document.createElement('div');
content.className = 'territory-settings-content collapsed';
const thicknessOptions = [
{ value: 1, label: 'Thin (1px)' },
{ value: 2, label: 'Normal (2px)' },
{ value: 3, label: 'Medium (3px)' },
{ value: 4, label: 'Thick (4px)' },
{ value: 6, label: 'Heavy (6px)' },
{ value: 8, label: 'Extra Heavy (8px)' }
];
const fillOpacityPct = Math.round(territorySettings.fillAlpha * 100);
content.innerHTML = `
<div class="territory-setting-row">
<label>Border Color</label>
<input type="color" id="territoryColorInput" value="${territorySettings.borderColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Border Thickness</label>
<select id="territoryThicknessSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
${thicknessOptions.map(opt =>
`<option value="${opt.value}" ${territorySettings.borderThickness == opt.value ? 'selected' : ''}>${opt.label}</option>`
).join('')}
</select>
</div>
<div class="territory-setting-row">
<label>Show Labels</label>
<input type="checkbox" id="territoryLabelsCheck" ${territorySettings.showLabels ? 'checked' : ''}
class="w-4 h-4 cursor-pointer accent-blue-500">
</div>
<div class="territory-setting-row">
<label>Label Size</label>
<select id="territoryFontSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
${[10, 12, 14, 16, 18, 20].map(s =>
`<option value="${s}" ${territorySettings.labelFontSize == s ? 'selected' : ''}>${s}px</option>`
).join('')}
</select>
</div>
<div class="territory-section-divider">Fill</div>
<div class="territory-setting-row">
<label>Enable Fill</label>
<input type="checkbox" id="territoryFillCheck" ${territorySettings.showFill ? 'checked' : ''}
class="w-4 h-4 cursor-pointer accent-blue-500">
</div>
<div class="territory-setting-row">
<label>Fill Color</label>
<input type="color" id="territoryFillColorInput" value="${territorySettings.fillColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Fill Opacity</label>
<div class="flex items-center gap-1.5">
<input type="range" id="territoryFillAlphaRange" min="0.01" max="1" step="0.01" value="${territorySettings.fillAlpha}"
class="w-20 cursor-pointer">
<span id="territoryFillAlphaValue" class="text-xs min-w-[30px]" style="color: var(--color-gray-500, #6b7280);">${fillOpacityPct}%</span>
</div>
</div>
<div class="territory-section-divider">Activity Coloring</div>
<div class="territory-setting-row">
<label>Color by activity</label>
<input type="checkbox" id="territoryActivityCheck" ${territorySettings.colorByActivity ? 'checked' : ''}
class="w-4 h-4 cursor-pointer accent-blue-500"
title="Use different colors for territories with active players vs abandoned/finished">
</div>
<div id="activityColorRows" style="display:${territorySettings.colorByActivity ? 'flex' : 'none'};flex-direction:column;gap:10px;">
<div class="territory-setting-row">
<label>Active Border</label>
<input type="color" id="territoryActiveBorderInput" value="${territorySettings.activeBorderColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Active Fill</label>
<input type="color" id="territoryActiveFillInput" value="${territorySettings.activeFillColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Abandoned Border</label>
<input type="color" id="territoryAbandonedBorderInput" value="${territorySettings.abandonedBorderColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Abandoned Fill</label>
<input type="color" id="territoryAbandonedFillInput" value="${territorySettings.abandonedFillColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<p style="font-size:11px;color:var(--color-gray-500,#6b7280);margin:0;">
Active = guild members drawing inside. Abandoned = no members inside.
</p>
</div>
<div class="territory-section-divider">Preview</div>
<div id="territoryPreviewContainer" class="flex items-center justify-center gap-2.5 py-1">
<div id="territoryPreviewBox" style="width: 70px; height: 44px; border: ${territorySettings.borderThickness}px solid ${territorySettings.colorByActivity ? territorySettings.activeBorderColor : territorySettings.borderColor}; border-radius: 2px; position: relative; display: flex; align-items: flex-start; justify-content: flex-start; padding: 2px; background: var(--color-white, #fff);">
<div id="territoryPreviewFill" style="position: absolute; inset: 0; background: ${territorySettings.colorByActivity ? territorySettings.activeFillColor : territorySettings.fillColor}; opacity: ${territorySettings.showFill ? territorySettings.fillAlpha : 0}; border-radius: 1px;"></div>
<span style="font-size: ${territorySettings.labelFontSize}px; font-weight: bold; color: ${territorySettings.colorByActivity ? territorySettings.activeBorderColor : territorySettings.borderColor}; position: relative; z-index: 1;">${territorySettings.colorByActivity ? 'Active' : '#1'}</span>
</div>
<div id="territoryPreviewBoxAbandoned" style="width: 70px; height: 44px; border: ${territorySettings.borderThickness}px solid ${territorySettings.abandonedBorderColor}; border-radius: 2px; position: relative; display: ${territorySettings.colorByActivity ? 'flex' : 'none'}; align-items: flex-start; justify-content: flex-start; padding: 2px; background: var(--color-white, #fff);">
<div id="territoryPreviewFillAbandoned" style="position: absolute; inset: 0; background: ${territorySettings.abandonedFillColor}; opacity: ${territorySettings.showFill ? territorySettings.fillAlpha : 0}; border-radius: 1px;"></div>
<span style="font-size: ${territorySettings.labelFontSize}px; font-weight: bold; color: ${territorySettings.abandonedBorderColor}; position: relative; z-index: 1;">Done</span>
</div>
</div>
`;
// Toggle collapse
toggle.addEventListener('click', () => {
content.classList.toggle('collapsed');
toggle.querySelector('.toggle-arrow').classList.toggle('collapsed');
});
wrapper.append(toggle, content);
// Wire up live preview + auto-save after a brief delay
const wireEvents = () => {
const updatePreviewAndSave = () => {
const color = document.getElementById('territoryColorInput')?.value;
const thickness = parseInt(document.getElementById('territoryThicknessSelect')?.value);
const fontSize = parseInt(document.getElementById('territoryFontSelect')?.value);
const showLabels = document.getElementById('territoryLabelsCheck')?.checked;
const showFill = document.getElementById('territoryFillCheck')?.checked;
const fillColor = document.getElementById('territoryFillColorInput')?.value;
const fillAlpha = parseFloat(document.getElementById('territoryFillAlphaRange')?.value);
const colorByActivity = document.getElementById('territoryActivityCheck')?.checked;
const activeBorderColor = document.getElementById('territoryActiveBorderInput')?.value;
const activeFillColor = document.getElementById('territoryActiveFillInput')?.value;
const abandonedBorderColor = document.getElementById('territoryAbandonedBorderInput')?.value;
const abandonedFillColor = document.getElementById('territoryAbandonedFillInput')?.value;
// Show/hide activity color rows
const activityRows = document.getElementById('activityColorRows');
if (activityRows) activityRows.style.display = colorByActivity ? 'flex' : 'none';
// Update main preview box
const box = document.getElementById('territoryPreviewBox');
const fillDiv = document.getElementById('territoryPreviewFill');
const previewBorderColor = colorByActivity ? activeBorderColor : color;
const previewFillCol = colorByActivity ? activeFillColor : fillColor;
if (box) {
box.style.borderColor = previewBorderColor;
box.style.borderWidth = thickness + 'px';
const label = box.querySelector('span');
if (label) {
label.style.color = previewBorderColor;
label.style.fontSize = fontSize + 'px';
label.textContent = colorByActivity ? 'Active' : '#1';
}
}
if (fillDiv) {
fillDiv.style.background = previewFillCol;
fillDiv.style.opacity = showFill ? fillAlpha : 0;
}
// Update abandoned preview box
const boxAbandoned = document.getElementById('territoryPreviewBoxAbandoned');
const fillDivAbandoned = document.getElementById('territoryPreviewFillAbandoned');
if (boxAbandoned) {
boxAbandoned.style.display = colorByActivity ? 'flex' : 'none';
boxAbandoned.style.borderColor = abandonedBorderColor;
boxAbandoned.style.borderWidth = thickness + 'px';
const label = boxAbandoned.querySelector('span');
if (label) {
label.style.color = abandonedBorderColor;
label.style.fontSize = fontSize + 'px';
}
}
if (fillDivAbandoned) {
fillDivAbandoned.style.background = abandonedFillColor;
fillDivAbandoned.style.opacity = showFill ? fillAlpha : 0;
}
const alphaLabel = document.getElementById('territoryFillAlphaValue');
if (alphaLabel) alphaLabel.textContent = Math.round(fillAlpha * 100) + '%';
// Save and redraw
territorySettings.borderColor = color;
territorySettings.borderThickness = thickness;
territorySettings.showLabels = showLabels;
territorySettings.labelFontSize = fontSize;
territorySettings.showFill = showFill;
territorySettings.fillColor = fillColor;
territorySettings.fillAlpha = fillAlpha;
territorySettings.colorByActivity = colorByActivity;
territorySettings.activeBorderColor = activeBorderColor;
territorySettings.activeFillColor = activeFillColor;
territorySettings.abandonedBorderColor = abandonedBorderColor;
territorySettings.abandonedFillColor = abandonedFillColor;
saveTerritorySettings();
drawTerritories();
};
['territoryColorInput', 'territoryFillColorInput', 'territoryActiveBorderInput', 'territoryActiveFillInput', 'territoryAbandonedBorderInput', 'territoryAbandonedFillInput'].forEach(id => {
document.getElementById(id)?.addEventListener('input', updatePreviewAndSave);
});
['territoryThicknessSelect', 'territoryFontSelect'].forEach(id => {
document.getElementById(id)?.addEventListener('change', updatePreviewAndSave);
});
['territoryLabelsCheck', 'territoryFillCheck', 'territoryActivityCheck'].forEach(id => {
document.getElementById(id)?.addEventListener('change', updatePreviewAndSave);
});
document.getElementById('territoryFillAlphaRange')?.addEventListener('input', updatePreviewAndSave);
};
// Defer event wiring until after DOM insertion
setTimeout(wireEvents, 0);
return wrapper;
}
/**
* Add numbered badges (#1, #2, ...) to each project card in the guild modal.
* Numbers match the logical display order used by the territory overlay.
*/
function numberProjectCards() {
const container = document.getElementById('guildProjectsContainer');
if (!container) return;
const cards = container.querySelectorAll(':scope > div');
cards.forEach((card, i) => {
// Skip if already numbered
if (card.querySelector('.project-number-badge')) return;
// Position the card for the badge
card.style.position = 'relative';
const badge = document.createElement('div');
badge.className = 'project-number-badge';
badge.textContent = `#${i + 1}`;
badge.style.cssText = `
position: absolute;
top: 6px;
left: 6px;
background: var(--color-blue-500, #3b82f6);
color: var(--color-white, #fff);
font-size: 11px;
font-weight: 700;
padding: 2px 7px;
border-radius: 6px;
z-index: 5;
box-shadow: 0 1px 4px rgba(0,0,0,0.2);
pointer-events: none;
line-height: 1.4;
`;
card.insertBefore(badge, card.firstChild);
});
}
/**
* Inject the territory controls into the Projects tab of the guild modal.
*/
function injectTerritoryControls() {
const projectsTab = document.getElementById('projectsTab');
if (!projectsTab) return;
// Don't inject twice
if (document.getElementById('territoryControlsContainer')) return;
const container = document.createElement('div');
container.id = 'territoryControlsContainer';
container.className = 'flex flex-col gap-3 mb-3 p-3 rounded-lg border';
// Top row: toggle button + info (right-aligned)
const topRow = document.createElement('div');
topRow.className = 'flex items-center justify-between gap-2 flex-wrap';
const toggleBtn = document.createElement('button');
toggleBtn.id = 'territoryToggleBtn';
toggleBtn.className = territoryVisible ? 'territory-toggle-btn active' : 'territory-toggle-btn inactive';
toggleBtn.innerHTML = territoryVisible ? '🗺️ Hide Territories' : '🗺️ Show Territories';
toggleBtn.addEventListener('click', toggleTerritories);
const playersBtn = document.createElement('button');
playersBtn.id = 'playersToggleBtn';
playersBtn.className = playersVisible ? 'territory-toggle-btn active' : 'territory-toggle-btn inactive';
playersBtn.innerHTML = playersVisible ? '👥 Hide Players' : '👥 Show Players';
playersBtn.addEventListener('click', togglePlayers);
const exportBtn = document.createElement('button');
exportBtn.id = 'exportTerritoriesBtn';
exportBtn.className = 'territory-toggle-btn inactive';
exportBtn.innerHTML = '📋 Export to Clipboard';
exportBtn.title = 'Copy guild territories as JSON for the GeoPixels Json import';
exportBtn.addEventListener('click', exportTerritoriesForJson);
const info = document.createElement('span');
info.className = 'territory-info-text';
info.textContent = 'Overlay territories or player locations on the map';
topRow.append(toggleBtn, playersBtn, exportBtn, info);
container.appendChild(topRow);
// Player marker options row (checkboxes)
const optionsRow = document.createElement('div');
optionsRow.id = 'playersOptionsRow';
optionsRow.className = 'player-marker-options';
optionsRow.style.display = playersVisible ? 'flex' : 'none';
const showNamesLabel = document.createElement('label');
const showNamesCheck = document.createElement('input');
showNamesCheck.type = 'checkbox';
showNamesCheck.id = 'playersShowNamesCheck';
showNamesCheck.checked = playersShowNames;
showNamesCheck.addEventListener('change', (e) => {
playersShowNames = e.target.checked;
refreshMarkerLabels();
});
showNamesLabel.appendChild(showNamesCheck);
showNamesLabel.appendChild(document.createTextNode('Show all names'));
const colorTerritoryLabel = document.createElement('label');
const colorTerritoryCheck = document.createElement('input');
colorTerritoryCheck.type = 'checkbox';
colorTerritoryCheck.id = 'playersColorTerritoryCheck';
colorTerritoryCheck.checked = playersColorByTerritory;
colorTerritoryCheck.addEventListener('change', (e) => {
playersColorByTerritory = e.target.checked;
refreshMarkerColors();
});
colorTerritoryLabel.appendChild(colorTerritoryCheck);
colorTerritoryLabel.appendChild(document.createTextNode('Blue if in territory'));
const showInTerritoryLabel = document.createElement('label');
const showInTerritoryCheck = document.createElement('input');
showInTerritoryCheck.type = 'checkbox';
showInTerritoryCheck.id = 'playersShowInTerritoryCheck';
showInTerritoryCheck.checked = playersShowInTerritory;
showInTerritoryCheck.addEventListener('change', (e) => {
playersShowInTerritory = e.target.checked;
updatePlayerPositions();
});
showInTerritoryLabel.appendChild(showInTerritoryCheck);
showInTerritoryLabel.appendChild(document.createTextNode('Show in-territory'));
const showOutsideTerritoryLabel = document.createElement('label');
const showOutsideTerritoryCheck = document.createElement('input');
showOutsideTerritoryCheck.type = 'checkbox';
showOutsideTerritoryCheck.id = 'playersShowOutsideTerritoryCheck';
showOutsideTerritoryCheck.checked = playersShowOutsideTerritory;
showOutsideTerritoryCheck.addEventListener('change', (e) => {
playersShowOutsideTerritory = e.target.checked;
updatePlayerPositions();
});
showOutsideTerritoryLabel.appendChild(showOutsideTerritoryCheck);
showOutsideTerritoryLabel.appendChild(document.createTextNode('Show outside territory'));
optionsRow.append(showNamesLabel, colorTerritoryLabel, showInTerritoryLabel, showOutsideTerritoryLabel);
container.appendChild(optionsRow);
// Settings collapsible panels (full width)
container.appendChild(buildTerritorySettingsPanel());
container.appendChild(buildPlayerSettingsPanel());
// Insert at the top of the projects tab, before the first child
projectsTab.insertBefore(container, projectsTab.firstChild);
}
// =====================================================
// === PLAYER MARKERS OVERLAY (New in 3.1.0) ===
// =====================================================
/**
* Collect guild member positions from the currently rendered member list.
* Returns array of { name, gridX, gridY } for members with coordinates.
*/
function buildPlayerMarkerData() {
const members = parseGuildMembers();
if (!members) return [];
const markers = [];
for (const [name, data] of Object.entries(members)) {
const coords = getCoords(data);
if (coords) {
markers.push({ name, gridX: coords[0], gridY: coords[1] });
}
}
return markers;
}
/**
* Create the players overlay container (a div for DOM marker elements).
*/
function createPlayersContainer() {
if (playersContainer) return;
playersContainer = document.createElement('div');
playersContainer.id = 'players-container';
document.body.appendChild(playersContainer);
console.log('[Guild Players] Players container created');
}
/**
* Create a single Google-Maps-style pin marker DOM element for a player.
* The pin tip anchors at the exact grid coordinate.
*/
/**
* Check if a grid coordinate falls inside any territory rectangle.
*/
function isInsideTerritory(gridX, gridY) {
for (const rect of territoryRects) {
if (
gridX >= rect.gridX &&
gridX < rect.gridX + rect.width &&
gridY <= rect.gridY &&
gridY > rect.gridY - rect.height
) {
return true;
}
}
return false;
}
/**
* Get the pin fill color for a marker based on territory status.
*/
function getMarkerColor(inTerritory) {
return (playersColorByTerritory && inTerritory) ? playerSettings.territoryColor : playerSettings.defaultColor;
}
function createMarkerElement(marker) {
const wrapper = document.createElement('div');
wrapper.className = 'player-marker' + (playersShowNames ? ' show-label' : '');
wrapper.setAttribute('data-player', marker.name);
const pinColor = getMarkerColor(marker.inTerritory);
const w = playerSettings.markerSize;
const h = Math.round(w * 40 / 28); // maintain aspect ratio 28:40
// Google Maps teardrop SVG pin
wrapper.innerHTML = `
<svg xmlns="http://www.w3.org/2000/svg" width="${w}" height="${h}" viewBox="0 0 24 36">
<path class="pin-body" d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${pinColor}"/>
<circle cx="12" cy="11" r="4.5" fill="white"/>
</svg>
<div class="player-marker-tooltip" style="font-size:${playerSettings.labelFontSize}px">${marker.name.replace(/</g, '<')}</div>
`;
// Click to teleport — find the member's actual Find button in the DOM
// and .click() it (runs in page context), with fallback via script injection
wrapper.addEventListener('click', (e) => {
e.stopPropagation();
let found = false;
const memberRows = document.querySelectorAll('#guildMembersContainer div.flex.items-center.justify-between');
for (const row of memberRows) {
const nameEl = row.querySelector('p.font-semibold');
if (nameEl) {
let displayName = nameEl.textContent.trim();
const badge = nameEl.querySelector('span');
if (badge) displayName = displayName.replace(badge.textContent, '').trim();
if (displayName === marker.name) {
const findBtn = row.querySelector('button[onclick^="goToGridLocation"]');
if (findBtn) {
findBtn.click();
found = true;
break;
}
}
}
}
// Fallback: inject a script tag to call goToGridLocation in page context
if (!found) {
const s = document.createElement('script');
s.textContent = `if(typeof goToGridLocation==='function')goToGridLocation(${parseInt(marker.gridX)},${parseInt(marker.gridY)});`;
document.documentElement.appendChild(s);
s.remove();
}
// Mark as visited
const coordKey = `${marker.gridX},${marker.gridY}`;
sessionState.visitedCoords.add(coordKey);
});
return wrapper;
}
/**
* Convert a grid coordinate to screen pixel position using the same
* pipeline as territory overlay: grid → Mercator → WGS84 → screen.
*/
function gridToScreen(gridX, gridY, gSize) {
if (typeof turf === 'undefined' || typeof map === 'undefined') return null;
const mercCoord = [gridX * gSize, gridY * gSize];
const screenPos = map.project(turf.toWgs84(mercCoord));
return screenPos; // { x, y }
}
/**
* Reposition all player marker DOM elements to match current map view.
* Called on every map move/zoom/resize.
*/
function updatePlayerPositions() {
if (!playersContainer || !playersVisible) return;
const gSize = (typeof gridSize !== 'undefined') ? gridSize : 25;
const viewW = window.innerWidth;
const viewH = window.innerHeight;
const margin = 60; // off-screen buffer before hiding
for (const marker of playerMarkerData) {
// Hide based on territory visibility checkboxes
if (marker.inTerritory && !playersShowInTerritory) {
marker.element.style.display = 'none';
continue;
}
if (!marker.inTerritory && !playersShowOutsideTerritory) {
marker.element.style.display = 'none';
continue;
}
const pos = gridToScreen(marker.gridX, marker.gridY, gSize);
if (!pos) continue;
// Frustum cull with margin
if (pos.x < -margin || pos.x > viewW + margin || pos.y < -margin || pos.y > viewH + margin) {
marker.element.style.display = 'none';
} else {
marker.element.style.display = '';
marker.element.style.left = pos.x + 'px';
marker.element.style.top = pos.y + 'px';
}
}
}
/**
* Hook into map events so player markers reposition on pan/zoom/resize.
*/
function hookPlayersToMap() {
function waitForMapReady(callback) {
let tries = 0;
function check() {
if (typeof map !== 'undefined' && map && map.on && map.getContainer) callback();
else if (tries++ < 100) setTimeout(check, 100);
}
check();
}
waitForMapReady(() => {
['move', 'rotate', 'zoom'].forEach(ev => map.on(ev, updatePlayerPositions));
new ResizeObserver(updatePlayerPositions).observe(map.getContainer());
map.once('load', updatePlayerPositions);
console.log('[Guild Players] Hooked to map events');
});
}
/**
* Toggle player markers overlay on/off.
*/
/**
* Update the "Find on Map" buttons in the XP Tracker tab to reflect
* territory status (red for out-of-territory players) from playerMarkerData.
*/
function updateXPTrackerMapButtons() {
const xpPane = document.getElementById('xpTrackerPane');
if (!xpPane) return;
const mapBtns = xpPane.querySelectorAll('.map-icon[data-player-name]');
mapBtns.forEach(btn => {
const playerName = btn.getAttribute('data-player-name');
if (!playerName) return;
// Don't override visited state
if (btn.classList.contains('visited')) return;
if (playersVisible && playerMarkerData.length > 0) {
const markerInfo = playerMarkerData.find(m => m.name === playerName);
if (markerInfo && !markerInfo.inTerritory) {
btn.classList.add('out-of-territory');
} else {
btn.classList.remove('out-of-territory');
}
} else {
btn.classList.remove('out-of-territory');
}
});
// Show/hide territory filter buttons in XP Tracker
const xpTerritoryBtns = xpPane.querySelectorAll('.xp-territory-filter-btn');
xpTerritoryBtns.forEach(btn => {
btn.style.display = playersVisible ? '' : 'none';
});
}
/**
* Refresh all marker pin colors (e.g. after territory data changes or checkbox toggle).
*/
function refreshMarkerColors() {
for (const marker of playerMarkerData) {
const pinBody = marker.element.querySelector('.pin-body');
if (pinBody) {
pinBody.setAttribute('fill', getMarkerColor(marker.inTerritory));
}
}
}
/**
* Toggle show-label class on all markers.
*/
function refreshMarkerLabels() {
for (const marker of playerMarkerData) {
marker.element.classList.toggle('show-label', playersShowNames);
}
}
/**
* Refresh all marker sizes and label font sizes from playerSettings.
*/
function refreshMarkerSizes() {
const w = playerSettings.markerSize;
const h = Math.round(w * 40 / 28);
for (const marker of playerMarkerData) {
const svg = marker.element.querySelector('svg');
if (svg) {
svg.setAttribute('width', w);
svg.setAttribute('height', h);
}
const tooltip = marker.element.querySelector('.player-marker-tooltip');
if (tooltip) {
tooltip.style.fontSize = playerSettings.labelFontSize + 'px';
}
}
}
/**
* Build a collapsible settings panel for player marker appearance.
*/
function buildPlayerSettingsPanel() {
const wrapper = document.createElement('div');
wrapper.className = 'territory-settings-collapsible';
wrapper.id = 'playerSettingsCollapsible';
const toggle = document.createElement('button');
toggle.className = 'territory-settings-toggle';
toggle.innerHTML = '<span>👥 Player Settings</span><span class="toggle-arrow collapsed">▼</span>';
const content = document.createElement('div');
content.className = 'territory-settings-content collapsed';
const sizeOptions = [
{ value: 16, label: 'Tiny (16px)' },
{ value: 20, label: 'Small (20px)' },
{ value: 24, label: 'Medium (24px)' },
{ value: 28, label: 'Default (28px)' },
{ value: 34, label: 'Large (34px)' },
{ value: 42, label: 'Extra Large (42px)' }
];
content.innerHTML = `
<div class="territory-setting-row">
<label>Marker Size</label>
<select id="playerSizeSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
${sizeOptions.map(opt =>
`<option value="${opt.value}" ${playerSettings.markerSize == opt.value ? 'selected' : ''}>${opt.label}</option>`
).join('')}
</select>
</div>
<div class="territory-setting-row">
<label>Label Size</label>
<select id="playerLabelSizeSelect" class="px-2 py-1 rounded-md text-xs min-w-[120px]">
${[9, 10, 11, 12, 13, 14, 16].map(s =>
`<option value="${s}" ${playerSettings.labelFontSize == s ? 'selected' : ''}>${s}px</option>`
).join('')}
</select>
</div>
<div class="territory-setting-row">
<label>Default Color</label>
<input type="color" id="playerDefaultColorInput" value="${playerSettings.defaultColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-setting-row">
<label>Territory Color</label>
<input type="color" id="playerTerritoryColorInput" value="${playerSettings.territoryColor}"
class="w-10 h-7 rounded-md cursor-pointer p-0.5">
</div>
<div class="territory-section-divider">Preview</div>
<div class="flex items-center justify-center gap-4 py-1">
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;">
<svg id="playerPreviewDefault" xmlns="http://www.w3.org/2000/svg" width="${playerSettings.markerSize}" height="${Math.round(playerSettings.markerSize*40/28)}" viewBox="0 0 24 36">
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${playerSettings.defaultColor}"/>
<circle cx="12" cy="11" r="4.5" fill="white"/>
</svg>
<span style="font-size:10px;color:var(--color-gray-500,#6b7280);">Outside</span>
</div>
<div style="display:flex;flex-direction:column;align-items:center;gap:2px;">
<svg id="playerPreviewTerritory" xmlns="http://www.w3.org/2000/svg" width="${playerSettings.markerSize}" height="${Math.round(playerSettings.markerSize*40/28)}" viewBox="0 0 24 36">
<path d="M12 0C5.4 0 0 5.4 0 12c0 9 12 24 12 24s12-15 12-24C24 5.4 18.6 0 12 0z" fill="${playerSettings.territoryColor}"/>
<circle cx="12" cy="11" r="4.5" fill="white"/>
</svg>
<span style="font-size:10px;color:var(--color-gray-500,#6b7280);">In Territory</span>
</div>
</div>
`;
toggle.addEventListener('click', () => {
content.classList.toggle('collapsed');
toggle.querySelector('.toggle-arrow').classList.toggle('collapsed');
});
wrapper.append(toggle, content);
const wireEvents = () => {
const update = () => {
const size = parseInt(document.getElementById('playerSizeSelect')?.value);
const labelSize = parseInt(document.getElementById('playerLabelSizeSelect')?.value);
const defaultColor = document.getElementById('playerDefaultColorInput')?.value;
const territoryColor = document.getElementById('playerTerritoryColorInput')?.value;
playerSettings.markerSize = size;
playerSettings.labelFontSize = labelSize;
playerSettings.defaultColor = defaultColor;
playerSettings.territoryColor = territoryColor;
// Update preview
const h = Math.round(size * 40 / 28);
const prevDef = document.getElementById('playerPreviewDefault');
const prevTer = document.getElementById('playerPreviewTerritory');
if (prevDef) {
prevDef.setAttribute('width', size);
prevDef.setAttribute('height', h);
prevDef.querySelector('path').setAttribute('fill', defaultColor);
}
if (prevTer) {
prevTer.setAttribute('width', size);
prevTer.setAttribute('height', h);
prevTer.querySelector('path').setAttribute('fill', territoryColor);
}
savePlayerSettings();
refreshMarkerSizes();
refreshMarkerColors();
};
['playerSizeSelect', 'playerLabelSizeSelect'].forEach(id => {
document.getElementById(id)?.addEventListener('change', update);
});
['playerDefaultColorInput', 'playerTerritoryColorInput'].forEach(id => {
document.getElementById(id)?.addEventListener('input', update);
});
};
setTimeout(wireEvents, 0);
return wrapper;
}
function togglePlayers() {
if (playersVisible) {
// Turn off — remove all marker elements
playersVisible = false;
playerMarkerData.forEach(m => m.element.remove());
playerMarkerData = [];
updatePlayersToggleButton();
updatePlayersOptionsVisibility();
updateXPTrackerMapButtons();
drawTerritories(); // refresh territory colors (remove red highlights)
console.log('[Guild Players] Player markers hidden');
return;
}
// Turn on — build markers from current guild members
const toggleBtn = document.getElementById('playersToggleBtn');
if (toggleBtn) {
toggleBtn.disabled = true;
toggleBtn.innerHTML = '⏳ Loading...';
}
const data = buildPlayerMarkerData();
if (data.length === 0) {
if (toggleBtn) {
toggleBtn.disabled = false;
toggleBtn.innerHTML = '👥 Show Players';
toggleBtn.className = 'territory-toggle-btn inactive';
}
alert('No guild members with coordinates found. Make sure the guild Info tab has loaded.');
return;
}
// If territory data is available, compute in-territory flag for each marker
// If territoryRects hasn't been built yet, try building it now
const enrichData = async () => {
if (territoryRects.length === 0) {
// Try to build territory rects (non-blocking, best-effort)
try {
if (typeof userGuildData !== 'undefined' && userGuildData && typeof fetchGuildProjects === 'function') {
await fetchGuildProjects();
}
territoryRects = await buildTerritoryRects();
} catch (e) {
console.warn('[Guild Players] Could not build territory rects for coloring:', e);
}
}
// Mark each marker with territory membership
for (const m of data) {
m.inTerritory = isInsideTerritory(m.gridX, m.gridY);
}
createPlayersContainer();
// Create DOM marker elements
playerMarkerData = data.map(m => {
const el = createMarkerElement(m);
playersContainer.appendChild(el);
return { ...m, element: el };
});
playersVisible = true;
updatePlayerPositions();
updatePlayersToggleButton();
updatePlayersOptionsVisibility();
updateXPTrackerMapButtons();
drawTerritories(); // refresh territory colors (show red for unoccupied)
const inTerritoryCount = data.filter(m => m.inTerritory).length;
console.log(`[Guild Players] Showing ${playerMarkerData.length} player markers (${inTerritoryCount} in territory)`);
if (toggleBtn) toggleBtn.disabled = false;
};
enrichData();
}
/**
* Update the player toggle button appearance based on state.
*/
function updatePlayersToggleButton() {
const toggleBtn = document.getElementById('playersToggleBtn');
if (!toggleBtn) return;
if (playersVisible) {
toggleBtn.innerHTML = '👥 Hide Players';
toggleBtn.className = 'territory-toggle-btn active';
} else {
toggleBtn.innerHTML = '👥 Show Players';
toggleBtn.className = 'territory-toggle-btn inactive';
}
}
/**
* Show/hide the player marker options row based on visibility.
*/
function updatePlayersOptionsVisibility() {
const optionsRow = document.getElementById('playersOptionsRow');
if (optionsRow) {
optionsRow.style.display = playersVisible ? 'flex' : 'none';
}
}
// =====================================================
// === MODAL TRANSFORMATION (inherited from v2.0) ===
// =====================================================
function setupContentTracking() {
const infoTab = document.getElementById('infoTab');
if (!infoTab) return;
const membersContainer = document.getElementById('guildMembersContainer');
if (membersContainer) {
const observer = new MutationObserver(() => {
ensureXPChangesSection();
const members = parseGuildMembers();
if (members && Object.keys(members).length > 0) {
saveGuildSnapshot(members);
}
});
observer.observe(membersContainer, { childList: true, subtree: true });
}
ensureXPChangesSection();
// Watch for the projects tab being shown so we can inject territory controls + number badges
const projectsTab = document.getElementById('projectsTab');
if (projectsTab) {
const projectsObserver = new MutationObserver(() => {
injectTerritoryControls();
numberProjectCards();
});
projectsObserver.observe(projectsTab, { childList: true, subtree: true, attributes: true });
}
// Also watch the projects container specifically for re-renders
const projectsContainer = document.getElementById('guildProjectsContainer');
if (projectsContainer) {
const containerObserver = new MutationObserver(() => {
numberProjectCards();
});
containerObserver.observe(projectsContainer, { childList: true, subtree: true });
}
// Also hook into the projects tab button click
const projectsTabBtn = document.getElementById('projectsTabBtn');
if (projectsTabBtn) {
const originalOnClick = projectsTabBtn.onclick;
projectsTabBtn.addEventListener('click', () => {
// Small delay to ensure tab content is visible
setTimeout(() => {
injectTerritoryControls();
numberProjectCards();
}, 50);
});
}
}
function setupMessageCollapsible() {
const msgElement = document.getElementById('guildInfoMessage');
if (!msgElement) return;
const parent = msgElement.closest('div');
if (!parent || parent.classList.contains('guild-message-section')) return;
const section = document.createElement('div');
section.className = 'guild-message-section';
const header = document.createElement('div');
header.className = 'guild-message-header';
header.innerHTML = `<span>Guild Message</span><span class="guild-message-toggle">▼</span>`;
const content = document.createElement('div');
content.className = 'guild-message-content';
parent.parentNode.insertBefore(section, parent);
content.appendChild(parent);
section.appendChild(header);
section.appendChild(content);
header.onclick = () => {
content.classList.toggle('collapsed');
header.querySelector('.guild-message-toggle').classList.toggle('collapsed');
const infoTab = document.getElementById('infoTab');
if (infoTab) infoTab.classList.toggle('message-collapsed', content.classList.contains('collapsed'));
};
}
/**
* Adds a slim loading progress bar below the header bar that tracks
* guild data readiness: members loaded, XP section ready, projects available.
* Auto-hides with a fade once all milestones are met.
*/
function setupGuildLoadingBar(panel, headerBar) {
if (document.getElementById('guild-loading-bar-container')) return;
const container = document.createElement('div');
container.id = 'guild-loading-bar-container';
container.style.cssText = `
position: absolute; top: 40px; left: 0; right: 0; height: 3px;
background: rgba(0,0,0,0.1); z-index: 52; overflow: hidden;
transition: opacity 0.5s ease; cursor: pointer;
`;
const bar = document.createElement('div');
bar.id = 'guild-loading-bar';
bar.style.cssText = `
height: 100%; width: 0%; background: linear-gradient(90deg, #60a5fa, #3b82f6);
transition: width 0.4s ease; border-radius: 0 2px 2px 0;
pointer-events: none;
`;
container.appendChild(bar);
// Hover tooltip
const tooltip = document.createElement('div');
tooltip.id = 'guild-loading-tooltip';
tooltip.style.cssText = `
position: fixed; display: none; padding: 6px 10px;
background: ${isDarkMode() ? '#1e1e2e' : '#1f2937'}; color: #f3f4f6;
font-size: 11px; line-height: 1.5; border-radius: 6px;
box-shadow: 0 4px 12px rgba(0,0,0,0.3); pointer-events: none;
z-index: 100000; white-space: nowrap;
`;
document.body.appendChild(tooltip);
container.addEventListener('mouseenter', () => { tooltip.style.display = 'block'; updateTooltip(); });
container.addEventListener('mouseleave', () => { tooltip.style.display = 'none'; });
container.addEventListener('mousemove', (e) => {
tooltip.style.left = (e.clientX + 12) + 'px';
tooltip.style.top = (e.clientY + 12) + 'px';
});
// Insert right after the header bar
headerBar.insertAdjacentElement('afterend', container);
// Milestones: each worth a portion of the bar
const milestones = {
modal: { done: true, weight: 10, label: 'Modal ready' },
stats: { done: false, weight: 20, label: 'Guild stats' },
members: { done: false, weight: 40, label: 'Members list' },
xpTracker:{ done: false, weight: 30, label: 'XP Tracker' },
};
function updateTooltip() {
const lines = Object.values(milestones).map(m =>
(m.done ? '✅' : '⏳') + ' ' + m.label
);
tooltip.innerHTML = lines.join('<br>');
}
function updateProgress() {
let progress = 0;
let total = 0;
const pending = [];
for (const [key, m] of Object.entries(milestones)) {
total += m.weight;
if (m.done) progress += m.weight;
else pending.push(key);
}
const pct = Math.round((progress / total) * 100);
bar.style.width = pct + '%';
if (pending.length === 0) {
bar.style.width = '100%';
updateTooltip();
setTimeout(() => {
container.style.opacity = '0';
setTimeout(() => {
container.remove();
tooltip.remove();
}, 600);
}, 800);
}
updateTooltip();
}
function markDone(key) {
if (milestones[key] && !milestones[key].done) {
milestones[key].done = true;
updateProgress();
}
}
// Check milestones periodically
function poll() {
// Stats: guild XP / pixels text is populated
const xpEl = document.getElementById('guildInfoExperience');
if (xpEl && xpEl.textContent.trim().length > 0) markDone('stats');
// Members: guildMembersContainer has member rows
const membersEl = document.getElementById('guildMembersContainer');
if (membersEl && membersEl.querySelectorAll('div.flex.items-center.justify-between').length > 0) {
markDone('members');
}
// XP Tracker: our injected tab button or legacy section exists
if (document.getElementById('xpTrackerTabBtn') || document.getElementById('xpChangesSection')) markDone('xpTracker');
// Keep polling until all done
const allDone = Object.values(milestones).every(m => m.done);
if (!allDone) setTimeout(poll, 300);
}
updateProgress();
setTimeout(poll, 200);
}
async function transformGuildModal() {
try {
await waitForElement('#myGuildModal', 10000);
const modal = document.getElementById('myGuildModal');
const panel = document.getElementById('myGuildPanel');
if (!modal || !panel) {
console.error('[Guild Modal] myGuildModal or myGuildPanel not found');
return;
}
if (panel.classList.contains('draggable-panel')) return;
modal.style.position = 'fixed';
modal.style.inset = 'auto';
modal.style.backgroundColor = 'transparent';
modal.style.justifyContent = 'flex-start';
modal.style.alignItems = 'flex-start';
modal.style.padding = '0';
modal.style.pointerEvents = 'none';
panel.style.position = 'fixed';
panel.style.top = '100px';
panel.style.left = 'calc(50% - 25rem)';
panel.style.width = '50rem';
panel.style.maxWidth = '90vw';
panel.style.maxHeight = '85vh';
panel.style.cursor = 'default';
panel.style.transform = 'none';
panel.style.opacity = '1';
panel.style.scale = '1';
panel.style.pointerEvents = 'auto';
panel.classList.add('draggable-panel');
const existingHeader = panel.querySelector('.guild-modal-header');
if (existingHeader) existingHeader.remove();
const headerBar = document.createElement('div');
headerBar.className = 'guild-modal-header';
headerBar.style.cssText = `
position: absolute; top: 0; left: 0; right: 0; height: 40px;
background: linear-gradient(135deg, #3b82f6 0%, #2563eb 100%);
cursor: move; border-radius: 0.75rem 0.75rem 0 0;
display: flex; align-items: center; justify-content: space-between;
padding: 0 16px; color: white; font-weight: 600;
user-select: none; z-index: 50; pointer-events: auto;
`;
const titleSpan = document.createElement('span');
titleSpan.textContent = 'Guild Panel';
titleSpan.style.cursor = 'move';
const closeBtn = document.createElement('button');
closeBtn.textContent = '✕';
closeBtn.style.cssText = `
background: none; border: none; color: white; font-size: 24px;
cursor: pointer; padding: 0; margin: 0;
display: flex; align-items: center; justify-content: center;
width: 30px; height: 30px; border-radius: 4px; transition: background-color 0.2s;
`;
closeBtn.onmouseover = () => closeBtn.style.backgroundColor = 'rgba(255,255,255,0.2)';
closeBtn.onmouseout = () => closeBtn.style.backgroundColor = 'transparent';
closeBtn.onclick = (e) => {
e.stopPropagation();
if (typeof window.toggleMyGuildModal === 'function') {
window.toggleMyGuildModal();
} else {
const originalClose = document.querySelector('#myGuildModal .close-modal, #myGuildModal [onclick*="toggleMyGuildModal"]');
if (originalClose) originalClose.click();
else modal.style.display = 'none';
}
};
headerBar.appendChild(titleSpan);
headerBar.appendChild(closeBtn);
const resizeHandle = document.createElement('div');
resizeHandle.className = 'guild-modal-resize';
resizeHandle.style.cssText = `
position: absolute; bottom: 0; right: 0; width: 20px; height: 20px;
cursor: nwse-resize;
background: linear-gradient(135deg, transparent 0%, #3b82f6 100%);
border-radius: 0 0 0.75rem 0; z-index: 51; pointer-events: auto;
`;
panel.style.paddingTop = '50px';
if (panel.firstChild) panel.insertBefore(headerBar, panel.firstChild);
else panel.appendChild(headerBar);
panel.appendChild(resizeHandle);
setupDragHandling(panel, titleSpan);
setupResizeHandling(panel, resizeHandle);
setupMessageCollapsible();
setupContentTracking();
// --- Loading progress bar ---
setupGuildLoadingBar(panel, headerBar);
// Inject territory controls and number badges when projects tab is available
setTimeout(() => {
injectTerritoryControls();
numberProjectCards();
}, 200);
console.log('[Guild Modal] v3.0 - Transformed to draggable floating panel with territories');
} catch (error) {
console.error('[Guild Modal] Error transforming modal:', error);
}
}
function setupDragHandling(panel, header) {
let isDragging = false;
let startX = 0, startY = 0, offsetX = 0, offsetY = 0;
const onMouseDown = (e) => {
if (e.target.closest('.guild-modal-resize') || e.target.closest('button')) return;
isDragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
offsetX = rect.left;
offsetY = rect.top;
panel.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove, true);
document.addEventListener('mouseup', onMouseUp, true);
e.preventDefault();
e.stopPropagation();
};
const onMouseMove = (e) => {
if (!isDragging) return;
const deltaX = e.clientX - startX;
const deltaY = e.clientY - startY;
panel.style.left = (offsetX + deltaX) + 'px';
panel.style.top = (offsetY + deltaY) + 'px';
};
const onMouseUp = () => {
isDragging = false;
panel.style.userSelect = 'auto';
document.removeEventListener('mousemove', onMouseMove, true);
document.removeEventListener('mouseup', onMouseUp, true);
};
header.addEventListener('mousedown', onMouseDown, true);
// Also make the header bar itself draggable
const headerBar = panel.querySelector('.guild-modal-header');
if (headerBar && headerBar !== header) {
headerBar.addEventListener('mousedown', onMouseDown, true);
}
}
function setupResizeHandling(panel, handle) {
let isResizing = false;
let startX = 0, startY = 0, startW = 0, startH = 0;
handle.addEventListener('mousedown', (e) => {
isResizing = true;
startX = e.clientX;
startY = e.clientY;
const rect = panel.getBoundingClientRect();
startW = rect.width;
startH = rect.height;
panel.style.userSelect = 'none';
document.addEventListener('mousemove', onMouseMove, true);
document.addEventListener('mouseup', onMouseUp, true);
e.preventDefault();
e.stopPropagation();
});
const onMouseMove = (e) => {
if (!isResizing) return;
const newW = Math.max(300, startW + (e.clientX - startX));
const newH = Math.max(200, startH + (e.clientY - startY));
panel.style.width = newW + 'px';
panel.style.maxHeight = newH + 'px';
};
const onMouseUp = () => {
isResizing = false;
panel.style.userSelect = 'auto';
document.removeEventListener('mousemove', onMouseMove, true);
document.removeEventListener('mouseup', onMouseUp, true);
};
}
function updateSnapshotIntervalUI() {
const dropdown = document.getElementById('snapshotIntervalSelect');
if (dropdown) {
updateSnapshotIntervalDropdown(dropdown);
}
}
// --- Menu Commands ---
// Commented out to keep the Tampermonkey menu clean.
// Uncomment any block below to re-expose it in the menu.
// All underlying functionality remains intact and accessible via the in-page UI.
/*
GM_registerMenuCommand("Snapshot Interval: Hourly", () => {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.HOURLY;
GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
updateSnapshotIntervalUI();
alert(`Snapshot Interval set to: Hourly (1 hour)`);
});
GM_registerMenuCommand("Snapshot Interval: 12 Hours", () => {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWELVE_HOURS;
GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
updateSnapshotIntervalUI();
alert(`Snapshot Interval set to: 12 Hours`);
});
GM_registerMenuCommand("Snapshot Interval: 24 Hours", () => {
CONFIG.minSnapshotInterval = SNAPSHOT_INTERVALS.TWENTY_FOUR_HOURS;
GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
updateSnapshotIntervalUI();
alert(`Snapshot Interval set to: 24 Hours`);
});
GM_registerMenuCommand("Snapshot Interval: Custom", () => {
const userInput = prompt("Enter custom snapshot interval in minutes:", (CONFIG.minSnapshotInterval / (60 * 1000)).toString());
if (userInput !== null && userInput.trim() !== '') {
const minutes = parseFloat(userInput);
if (!isNaN(minutes) && minutes > 0) {
CONFIG.minSnapshotInterval = minutes * 60 * 1000;
GM_setValue('min_snapshot_interval', CONFIG.minSnapshotInterval);
updateSnapshotIntervalUI();
alert(`Snapshot Interval set to: ${minutes} minute(s)`);
} else {
alert("Invalid input. Please enter a positive number.");
}
}
});
GM_registerMenuCommand("Toggle Debug Mode", () => {
CONFIG.debugMode = !CONFIG.debugMode;
alert(`Debug Mode: ${CONFIG.debugMode ? 'ON' : 'OFF'}`);
});
GM_registerMenuCommand("Time Travel: Advance 1 Day", () => {
CONFIG.timeOffset += 24 * 60 * 60 * 1000;
GM_setValue('debug_time_offset', CONFIG.timeOffset);
const virtualDate = new Date(getVirtualNow());
alert(`Time Travel Active! Virtual Date: ${virtualDate.toDateString()}\nReload the page to apply.`);
});
GM_registerMenuCommand("Time Travel: Reset", () => {
CONFIG.timeOffset = 0;
GM_setValue('debug_time_offset', 0);
alert(`Time Travel Reset. Back to reality.`);
});
GM_registerMenuCommand("Reset Guild XP History", () => {
if (confirm("Are you sure you want to clear all stored Guild XP history? This cannot be undone.")) {
GM_setValue('guild_xp_history', []);
alert("Guild XP history has been reset.");
}
});
GM_registerMenuCommand("Toggle Territory Overlay", () => {
toggleTerritories();
});
GM_registerMenuCommand("Toggle Player Markers", () => {
togglePlayers();
});
GM_registerMenuCommand("Territory Settings", () => {
// Open the guild modal projects tab where settings live
if (typeof window.toggleMyGuildModal === 'function') {
const modal = document.getElementById('myGuildModal');
if (modal && modal.classList.contains('hidden')) window.toggleMyGuildModal();
if (typeof window.switchGuildTab === 'function') window.switchGuildTab('projects');
setTimeout(() => {
const collapsible = document.getElementById('territorySettingsCollapsible');
if (collapsible) {
const content = collapsible.querySelector('.territory-settings-content');
const arrow = collapsible.querySelector('.toggle-arrow');
if (content && content.classList.contains('collapsed')) {
content.classList.remove('collapsed');
if (arrow) arrow.classList.remove('collapsed');
}
}
}, 200);
}
});
*/
// --- Initialization ---
function init() {
transformGuildModal();
hookTerritoryToMap();
hookPlayersToMap();
const bodyObserver = new MutationObserver((mutations) => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) {
if (node.nodeType === Node.ELEMENT_NODE) {
if (node.id === 'myGuildModal' || node.querySelector('#myGuildModal')) {
console.log('[Guild Modal] Modal detected, re-initializing...');
transformGuildModal();
}
}
}
}
});
bodyObserver.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
console.log('[Guild Modal] v3.4.0 - Loaded with territory map overlay, player markers, territory-aware XP tracker, and activity-aware territory coloring');
})();
_featureStatus.guildOverhaul = 'ok';
console.log('[GeoPixelcons++] ✅ Guild Overhaul loaded');
} catch (err) {
_featureStatus.guildOverhaul = 'error';
console.error('[GeoPixelcons++] ❌ Guild Overhaul failed:', err);
}
}
// ============================================================
// FEATURE: Paint Brush Swap [paintBrushSwap]
// ============================================================
if (_settings.paintBrushSwap) {
try {
(function _init_paintBrushSwap() {
// Page window reference — needed because GPC++ uses @grant which sandboxes `window`
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// Helper to set page-scope `let` variables (not accessible via window/unsafeWindow)
// Injects a <script> tag so the assignment runs in the page's own global scope
function _setPageVar(name, value) {
try {
const s = document.createElement('script');
s.textContent = `${name} = ${JSON.stringify(value)};`;
(document.head || document.documentElement).appendChild(s);
s.remove();
} catch {}
}
function _runInPage(code) {
try {
const s = document.createElement('script');
s.textContent = code;
(document.head || document.documentElement).appendChild(s);
s.remove();
} catch {}
}
// ============================================
// DEBUG MODE
// ============================================
const DEBUG = false; // Set to true for console logging
// ============================================
// STATE MANAGEMENT
// ============================================
const STORAGE_KEY = 'brushPresets';
const RESIZE_STORAGE_KEY = 'brushSwapDropdownSize';
const MAX_BRUSHES = 100;
const scriptState = {
brushes: [],
nextId: 1,
dropdownOpen: false,
isRenaming: null, // Track which brush ID is being renamed
scrollIndex: -1, // Track current scroll-swap index (-1 = no selection)
activeBrushId: null, // Track which brush is currently loaded
dragState: null // Track drag-to-reorder state
};
// ============================================
// UTILITY FUNCTIONS
// ============================================
function loadBrushes() {
const saved = localStorage.getItem(STORAGE_KEY);
if (saved) {
try {
scriptState.brushes = JSON.parse(saved);
scriptState.nextId = Math.max(...scriptState.brushes.map(b => b.id), 0) + 1;
} catch (e) {
console.error('Failed to parse brush presets:', e);
scriptState.brushes = [];
scriptState.nextId = 1;
}
}
}
function saveBrushes() {
localStorage.setItem(STORAGE_KEY, JSON.stringify(scriptState.brushes));
}
function addBrush(pattern, brushSize) {
if (scriptState.brushes.length >= MAX_BRUSHES) {
// Delete oldest brush (first in array)
scriptState.brushes.shift();
}
const newBrush = {
id: scriptState.nextId++,
name: `Brush ${scriptState.nextId}`,
pattern: pattern,
brushSize: brushSize
};
scriptState.brushes.push(newBrush);
saveBrushes();
return newBrush;
}
function deleteBrush(id) {
scriptState.brushes = scriptState.brushes.filter(b => b.id !== id);
saveBrushes();
renderDropdown();
}
function renameBrush(id, newName) {
const brush = scriptState.brushes.find(b => b.id === id);
if (brush) {
brush.name = newName.trim() || `Brush ${id}`;
saveBrushes();
renderDropdown();
}
}
// ============================================
// BRUSH CAPTURE FROM DOM
// ============================================
function captureBrushFromDOM() {
const brushGrid = document.getElementById('brushGrid');
if (!brushGrid) {
console.warn('Brush Swap: brushGrid not found');
return null;
}
const cells = brushGrid.querySelectorAll('div[data-x][data-y]');
const pattern = [];
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
let centerX = -1, centerY = -1;
// Collect all active cells and find bounds, also locate center marker
cells.forEach(cell => {
if (cell.dataset.active === 'true') {
const x = parseInt(cell.dataset.x);
const y = parseInt(cell.dataset.y);
pattern.push({ gridX: x, gridY: y });
minX = Math.min(minX, x);
maxX = Math.max(maxX, x);
minY = Math.min(minY, y);
maxY = Math.max(maxY, y);
// Find center marker
if (cell.dataset.isCenter === 'true' || cell.dataset.isCenter === 'true') {
centerX = x;
centerY = y;
}
}
});
if (pattern.length === 0) {
console.warn('Brush Swap: No active cells in brush');
return null;
}
// Calculate brush size from grid bounds
const brushSize = Math.max(maxX - minX + 1, maxY - minY + 1);
// If center wasn't found (shouldn't happen), use grid center
if (centerX === -1 || centerY === -1) {
centerX = Math.floor(brushSize / 2);
centerY = Math.floor(brushSize / 2);
}
// Convert grid coordinates to relative coordinates, centered on the actual center pixel
const relativePattern = pattern.map(p => ({
x: p.gridX - centerX,
y: (p.gridY - centerY) * -1 // Invert Y for consistency
}));
if (DEBUG) console.log('Brush Swap: Captured brush from DOM', {
brushSize,
centerX,
centerY,
pattern: relativePattern,
cellCount: pattern.length
});
return {
pattern: relativePattern,
brushSize: brushSize
};
}
function loadBrush(id) {
const brush = scriptState.brushes.find(b => b.id === id);
if (!brush) return;
applyBrushToEditor(brush);
scriptState.activeBrushId = id;
toggleDropdown();
}
function applyBrushToEditor(brush) {
// Track which brush is active
scriptState.activeBrushId = brush.id;
// Set page globals — BrushSize is `let`-declared so _pw.BrushSize won't reach it
_setPageVar('BrushSize', brush.brushSize);
_setPageVar('currentBrushPattern', [...brush.pattern]);
// Also mirror on _pw for any code that reads from window
_pw.BrushSize = brush.brushSize;
_pw.currentBrushPattern = [...brush.pattern];
if (DEBUG) console.log('Brush Swap: Set globals', {
BrushSize: _pw.BrushSize,
currentBrushPattern: _pw.currentBrushPattern
});
// Update userConfig
if (_pw.userConfig) {
_pw.userConfig = {
..._pw.userConfig,
currentBrushPattern: _pw.currentBrushPattern,
brushSize: _pw.BrushSize
};
localStorage.setItem('userConfig', JSON.stringify(_pw.userConfig));
if (DEBUG) console.log('Brush Swap: Updated userConfig');
}
// Call server save if available
_pw.saveConfigServer?.();
// Regenerate the brush grid to reflect the new pattern/size
_runInPage('generateBrushGrid(currentBrushPattern)');
if (DEBUG) console.log('Brush Swap: Applied brush to editor', brush);
}
// ============================================
// BRUSH DIMENSION CONTROL
// ============================================
function addBrushDimensionDropdown() {
const brushEditorPanel = document.getElementById('brushEditorPanel');
if (!brushEditorPanel) return;
// Check if dropdown already exists
if (document.getElementById('brush-swap-dimension-select')) return;
// Find the header area to insert dropdown
const header = brushEditorPanel.querySelector('h2');
if (!header) return;
// Create dropdown container with Tailwind classes
const dropdownContainer = document.createElement('div');
dropdownContainer.className = 'flex gap-2 items-center mb-3 px-1.5 dark:text-gray-300';
// Create label
const label = document.createElement('label');
label.textContent = 'Grid Size:';
label.className = 'text-xs font-semibold text-gray-700 dark:text-gray-300';
// Create select
const select = document.createElement('select');
select.id = 'brush-swap-dimension-select';
select.className = 'px-2 py-1 text-xs border rounded bg-white dark:bg-gray-800 border-gray-300 dark:border-gray-600 text-gray-900 dark:text-gray-100 cursor-pointer';
const options = [
{ value: 1, label: '1×1' },
{ value: 3, label: '3×3' },
{ value: 5, label: '5×5' },
{ value: 7, label: '7×7' },
{ value: 9, label: '9×9' },
{ value: 11, label: '11×11' },
{ value: 13, label: '13×13' },
{ value: 15, label: '15×15' },
{ value: 17, label: '17×17' },
{ value: 19, label: '19×19' },
{ value: 21, label: '21×21' }
];
// Ensure current BrushSize is in the list
const curSize = _pw.BrushSize || 5;
if (!options.some(o => o.value === curSize)) {
options.push({ value: curSize, label: curSize + '×' + curSize });
options.sort((a, b) => a.value - b.value);
}
options.forEach(opt => {
const option = document.createElement('option');
option.value = opt.value;
option.textContent = opt.label;
select.appendChild(option);
});
// Set current BrushSize as selected
select.value = _pw.BrushSize || 5;
// Handle change
select.addEventListener('change', (e) => {
const newSize = parseInt(e.target.value);
_setPageVar('BrushSize', newSize);
_pw.BrushSize = newSize;
if (DEBUG) console.log(`Brush Swap: Changed grid size to ${newSize}x${newSize}`);
// Regenerate grid with new size
_runInPage('generateBrushGrid(currentBrushPattern)');
});
dropdownContainer.appendChild(label);
dropdownContainer.appendChild(select);
// Insert after the header
header.parentNode.insertBefore(dropdownContainer, header.nextSibling);
}
// ============================================
function createBrushPreview(brush) {
const grid = document.createElement('div');
grid.className = 'brush-swap-preview-grid';
// Create a map of active cells based on pattern
const activeCells = new Map();
const centerOffset = Math.floor(brush.brushSize / 2);
let minX = Infinity, maxX = -Infinity;
let minY = Infinity, maxY = -Infinity;
brush.pattern.forEach(offset => {
// Convert from relative coordinates to grid coordinates
const gridX = offset.x + centerOffset;
const gridY = (offset.y * -1) + centerOffset; // Denormalize Y-axis
activeCells.set(`${gridX},${gridY}`, true);
minX = Math.min(minX, gridX);
maxX = Math.max(maxX, gridX);
minY = Math.min(minY, gridY);
maxY = Math.max(maxY, gridY);
});
// Calculate preview dimensions
const width = maxX - minX + 1;
const height = maxY - minY + 1;
const maxDim = Math.max(width, height);
// Scale cells to fit compact preview (8px max per cell)
const cellSize = Math.max(4, Math.floor(32 / maxDim));
// Calculate center of the pattern bounds (not the grid size)
const patternCenterX = minX + Math.floor((maxX - minX) / 2);
const patternCenterY = minY + Math.floor((maxY - minY) / 2);
// Build preview with full pattern bounds
for (let y = minY; y <= maxY; y++) {
for (let x = minX; x <= maxX; x++) {
const cell = document.createElement('div');
cell.className = 'brush-swap-preview-cell';
cell.style.width = cellSize + 'px';
cell.style.height = cellSize + 'px';
const isActive = activeCells.has(`${x},${y}`);
const isCenter = x === patternCenterX && y === patternCenterY;
if (isActive) {
cell.classList.add('active');
if (isCenter) {
cell.classList.add('center');
}
}
grid.appendChild(cell);
}
}
// Set grid columns dynamically
grid.style.gridTemplateColumns = `repeat(${width}, ${cellSize}px)`;
return grid;
}
// ============================================
// DRAG REORDER
// ============================================
function setupDragReorder(handle, itemEl, fromIdx, container) {
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const items = Array.from(container.querySelectorAll('[data-brush-idx]'));
const rects = items.map(el => el.getBoundingClientRect());
let currentDropIdx = fromIdx;
itemEl.classList.add('brush-swap-item-dragging');
// Remove any existing indicator
let indicator = container.querySelector('.brush-swap-drop-indicator');
function onMove(ev) {
const y = ev.clientY;
// Find which slot we're hovering
let dropIdx = items.length; // default to end
for (let i = 0; i < rects.length; i++) {
const mid = rects[i].top + rects[i].height / 2;
if (y < mid) {
dropIdx = i;
break;
}
}
if (dropIdx === currentDropIdx) return;
currentDropIdx = dropIdx;
// Remove old indicator
if (indicator) indicator.remove();
// Insert indicator at the drop position
indicator = document.createElement('div');
indicator.className = 'brush-swap-drop-indicator';
if (dropIdx < items.length) {
container.insertBefore(indicator, items[dropIdx]);
} else {
container.appendChild(indicator);
}
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
itemEl.classList.remove('brush-swap-item-dragging');
if (indicator) indicator.remove();
// Perform the reorder
if (currentDropIdx !== fromIdx && currentDropIdx !== fromIdx + 1) {
const [moved] = scriptState.brushes.splice(fromIdx, 1);
const insertAt = currentDropIdx > fromIdx ? currentDropIdx - 1 : currentDropIdx;
scriptState.brushes.splice(insertAt, 0, moved);
saveBrushes();
// Update scrollIndex to follow the moved brush
const newIdx = scriptState.brushes.findIndex(b => b.id === moved.id);
if (scriptState.scrollIndex === fromIdx) scriptState.scrollIndex = newIdx;
}
renderDropdown();
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
// ============================================
// UI RENDERING
// ============================================
function renderDropdown() {
let dropdown = document.getElementById('brush-swap-dropdown');
if (!dropdown) return;
// Clear existing items
const itemsContainer = dropdown.querySelector('.brush-swap-items');
itemsContainer.innerHTML = '';
if (scriptState.brushes.length === 0) {
const emptyMsg = document.createElement('div');
emptyMsg.className = 'text-center text-gray-500 dark:text-gray-400 text-xs py-3 px-2';
emptyMsg.textContent = 'No saved brushes';
itemsContainer.appendChild(emptyMsg);
return;
}
scriptState.brushes.forEach((brush, idx) => {
const item = document.createElement('div');
const isActive = brush.id === scriptState.activeBrushId;
item.className = 'flex items-center gap-2 p-1.5 border border-gray-200 dark:border-gray-600 rounded bg-gray-50 dark:bg-gray-800 hover:bg-gray-100 dark:hover:bg-gray-700'
+ (isActive ? ' brush-swap-item-active' : '');
item.dataset.brushId = brush.id;
item.dataset.brushIdx = idx;
// Preview grid (wrapped with click-to-expand)
const previewWrap = document.createElement('div');
previewWrap.className = 'brush-swap-preview-wrap';
const preview = createBrushPreview(brush);
previewWrap.appendChild(preview);
previewWrap.addEventListener('click', (e) => {
e.stopPropagation();
previewWrap.classList.toggle('expanded');
});
// Fast tooltip that follows mouse
let prevTip = null;
previewWrap.addEventListener('mouseenter', (e) => {
prevTip = document.createElement('div');
prevTip.className = 'brush-swap-quick-tip';
prevTip.textContent = 'click to expand';
prevTip.style.left = (e.clientX + 12) + 'px';
prevTip.style.top = (e.clientY - 8) + 'px';
document.body.appendChild(prevTip);
});
previewWrap.addEventListener('mousemove', (e) => {
if (prevTip) {
prevTip.style.left = (e.clientX + 12) + 'px';
prevTip.style.top = (e.clientY - 8) + 'px';
}
});
previewWrap.addEventListener('mouseleave', () => {
if (prevTip) { prevTip.remove(); prevTip = null; }
});
item.appendChild(previewWrap);
// Name and controls
const infoContainer = document.createElement('div');
infoContainer.className = 'flex-1 flex flex-col gap-1';
// Name display / edit
const nameContainer = document.createElement('div');
nameContainer.className = 'flex items-center gap-1 flex-1';
if (scriptState.isRenaming === brush.id) {
// Rename input mode
const input = document.createElement('input');
input.type = 'text';
input.className = 'flex-1 px-1 py-0.5 text-xs border border-gray-500 dark:border-gray-400 rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100';
input.value = brush.name;
input.maxLength = 30;
input.addEventListener('blur', () => {
renameBrush(brush.id, input.value);
scriptState.isRenaming = null;
});
input.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
renameBrush(brush.id, input.value);
scriptState.isRenaming = null;
}
});
nameContainer.appendChild(input);
setTimeout(() => input.focus(), 0);
} else {
// Normal name display with pencil icon
const nameSpan = document.createElement('span');
nameSpan.className = 'flex-1 text-xs font-medium text-gray-900 dark:text-gray-100 whitespace-nowrap overflow-hidden text-ellipsis';
nameSpan.textContent = brush.name;
nameContainer.appendChild(nameSpan);
const pencilBtn = document.createElement('button');
pencilBtn.className = 'bg-none border-none cursor-pointer p-0 text-xs opacity-60 hover:opacity-100 transition-opacity flex-shrink-0';
pencilBtn.title = 'Rename brush';
pencilBtn.innerHTML = '✏️';
pencilBtn.addEventListener('click', (e) => {
e.stopPropagation();
scriptState.isRenaming = brush.id;
renderDropdown();
});
nameContainer.appendChild(pencilBtn);
}
infoContainer.appendChild(nameContainer);
// Load and Delete buttons
const buttonsContainer = document.createElement('div');
buttonsContainer.className = 'flex gap-1 flex-shrink-0';
const loadBtn = document.createElement('button');
loadBtn.className = 'px-1.5 py-0.5 text-xs border border-gray-300 dark:border-gray-500 bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 cursor-pointer rounded transition-colors hover:bg-blue-50 dark:hover:bg-blue-900 hover:border-blue-400 dark:hover:border-blue-400';
loadBtn.textContent = 'Load';
loadBtn.addEventListener('click', (e) => {
e.stopPropagation();
loadBrush(brush.id);
});
const deleteBtn = document.createElement('button');
deleteBtn.className = 'px-1 py-0.5 text-xs bg-none border border-gray-300 dark:border-gray-500 opacity-60 hover:opacity-100 transition-opacity cursor-pointer rounded bg-white dark:bg-gray-700 text-gray-900 dark:text-gray-100 hover:bg-red-50 dark:hover:bg-red-900 hover:border-red-400 dark:hover:border-red-400';
deleteBtn.title = 'Delete brush';
deleteBtn.innerHTML = '✕';
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
deleteBrush(brush.id);
});
buttonsContainer.appendChild(loadBtn);
buttonsContainer.appendChild(deleteBtn);
infoContainer.appendChild(buttonsContainer);
item.appendChild(infoContainer);
// Drag handle (right side)
const dragHandle = document.createElement('div');
dragHandle.className = 'brush-swap-drag-handle';
dragHandle.title = 'Drag to reorder';
for (let d = 0; d < 4; d++) {
const dot = document.createElement('div');
dot.className = 'brush-swap-drag-handle-dot';
dragHandle.appendChild(dot);
}
setupDragReorder(dragHandle, item, idx, itemsContainer);
item.appendChild(dragHandle);
itemsContainer.appendChild(item);
});
}
function toggleDropdown() {
const dropdown = document.getElementById('brush-swap-dropdown');
if (!dropdown) return;
scriptState.dropdownOpen = !scriptState.dropdownOpen;
if (scriptState.dropdownOpen) {
// Detect if the paint menu is docked to top
const paintIsTop = localStorage.getItem('gpc-paint-is-top') === 'true';
if (paintIsTop) {
dropdown.style.bottom = 'auto';
dropdown.style.top = '100%';
dropdown.style.marginBottom = '0';
dropdown.style.marginTop = '8px';
} else {
dropdown.style.top = 'auto';
dropdown.style.bottom = '100%';
dropdown.style.marginTop = '0';
dropdown.style.marginBottom = '8px';
}
// Apply stored resize dimensions if any
try {
const stored = JSON.parse(localStorage.getItem(RESIZE_STORAGE_KEY));
if (stored) {
dropdown.style.maxWidth = stored.w + 'px';
dropdown.style.maxHeight = stored.h + 'px';
}
} catch {}
dropdown.classList.add('open');
renderDropdown();
} else {
dropdown.classList.remove('open');
scriptState.isRenaming = null;
}
}
// ============================================
// DOM INITIALIZATION
// ============================================
function injectCSS() {
const style = document.createElement('style');
style.textContent = `
/* Paintbrush icon button */
#brush-swap-toggle {
opacity: 0.85;
transition: all 0.2s ease;
}
#brush-swap-toggle:hover {
opacity: 1;
}
#brush-swap-toggle:active {
opacity: 0.7;
}
/* Dropdown container */
#brush-swap-dropdown {
position: absolute;
bottom: 100%;
right: 0;
border-radius: 4px;
margin-bottom: 8px;
max-width: 300px;
max-height: 0;
overflow: hidden;
opacity: 0;
transition: max-height 0.3s ease, opacity 0.3s ease;
z-index: 1000;
}
#brush-swap-dropdown.open {
max-height: 600px;
opacity: 1;
overflow-y: auto;
}
/* Items container */
.brush-swap-items {
display: flex;
flex-direction: column;
gap: 4px;
padding: 8px;
min-width: 250px;
}
/* Preview grid */
.brush-swap-preview-grid {
display: grid;
gap: 1px;
flex-shrink: 0;
background: var(--color-gray-100, white);
padding: 2px;
border: 1px solid var(--color-gray-400, #ddd);
border-radius: 2px;
}
.brush-swap-quick-tip {
position: fixed;
pointer-events: none;
z-index: 100001;
background: rgba(0,0,0,0.8);
color: #fff;
padding: 3px 7px;
border-radius: 4px;
font-size: 10px;
font-weight: 500;
white-space: nowrap;
font-family: system-ui, sans-serif;
}
.brush-swap-preview-wrap {
width: 36px;
height: 36px;
overflow: hidden;
flex-shrink: 0;
border-radius: 3px;
cursor: pointer;
position: relative;
display: flex;
align-items: center;
justify-content: center;
}
.brush-swap-preview-wrap.expanded {
width: auto;
height: auto;
max-width: 120px;
max-height: 120px;
}
.brush-swap-preview-wrap.expanded .brush-swap-preview-grid {
max-width: 100%;
max-height: 100%;
}
.brush-swap-preview-cell {
background: var(--color-gray-100, white);
border: 0.5px solid var(--color-gray-300, #eee);
}
.brush-swap-preview-cell.active {
background: var(--color-gray-800, #333);
}
.brush-swap-preview-cell.center {
background: #ff6b6b;
}
/* Scrollbar styling for dropdown */
#brush-swap-dropdown::-webkit-scrollbar {
width: 6px;
}
#brush-swap-dropdown::-webkit-scrollbar-track {
background: transparent;
}
#brush-swap-dropdown::-webkit-scrollbar-thumb {
background: #888;
border-radius: 3px;
}
#brush-swap-dropdown::-webkit-scrollbar-thumb:hover {
background: #555;
}
/* Dark mode scrollbar */
@media (prefers-color-scheme: dark) {
#brush-swap-dropdown::-webkit-scrollbar-thumb {
background: #555;
}
#brush-swap-dropdown::-webkit-scrollbar-thumb:hover {
background: #777;
}
}
/* Scroll-swap toast */
#brush-swap-toast {
position: fixed;
pointer-events: none;
z-index: 10000;
background: rgba(0, 0, 0, 0.82);
color: #fff;
padding: 6px 10px;
border-radius: 6px;
font-size: 11px;
font-weight: 600;
font-family: system-ui, sans-serif;
white-space: nowrap;
opacity: 0;
transition: opacity 0.15s ease;
transform: translate(12px, -50%);
display: flex;
align-items: center;
gap: 8px;
}
#brush-swap-toast.visible {
opacity: 1;
}
#brush-swap-toast .toast-preview {
flex-shrink: 0;
}
#brush-swap-toast .toast-preview .brush-swap-preview-cell {
background: rgba(255,255,255,0.2);
border-color: rgba(255,255,255,0.1);
}
#brush-swap-toast .toast-preview .brush-swap-preview-cell.active {
background: #fff;
}
#brush-swap-toast .toast-preview .brush-swap-preview-cell.center {
background: #ff6b6b;
}
#brush-swap-toast .toast-preview .brush-swap-preview-grid {
background: transparent;
border-color: rgba(255,255,255,0.15);
}
/* Active brush highlight */
.brush-swap-item-active {
outline: 2px solid var(--color-blue-500, #3b82f6) !important;
outline-offset: -1px;
background: var(--color-blue-50, rgba(59,130,246,0.08)) !important;
}
/* Drag handle */
.brush-swap-drag-handle {
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
width: 14px;
cursor: grab;
flex-shrink: 0;
opacity: 0.35;
transition: opacity 0.15s;
user-select: none;
padding: 2px 0;
}
.brush-swap-drag-handle:hover {
opacity: 0.8;
}
.brush-swap-drag-handle:active {
cursor: grabbing;
opacity: 1;
}
.brush-swap-drag-handle-dot {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--color-gray-500, #9ca3af);
margin: 1px 0;
}
/* Dragging visual */
.brush-swap-item-dragging {
opacity: 0.4;
}
.brush-swap-drop-indicator {
height: 2px;
background: var(--color-blue-500, #3b82f6);
border-radius: 1px;
margin: -2px 0;
pointer-events: none;
}
/* Resize handles */
.brush-swap-resize-handle {
position: absolute;
width: 12px;
height: 12px;
z-index: 1001;
}
.brush-swap-resize-handle::after {
content: '';
position: absolute;
width: 6px;
height: 6px;
border-style: solid;
border-color: var(--color-gray-400, #9ca3af);
opacity: 0;
transition: opacity 0.15s;
}
#brush-swap-dropdown:hover .brush-swap-resize-handle::after {
opacity: 0.6;
}
.brush-swap-resize-handle:hover::after {
opacity: 1 !important;
}
.brush-swap-resize-tl {
top: 0; left: 0;
cursor: nw-resize;
}
.brush-swap-resize-tl::after {
top: 2px; left: 2px;
border-width: 2px 0 0 2px;
}
.brush-swap-resize-tr {
top: 0; right: 0;
cursor: ne-resize;
}
.brush-swap-resize-tr::after {
top: 2px; right: 2px;
border-width: 2px 2px 0 0;
}
`;
document.head.appendChild(style);
}
function createUI(bottomControlsElement) {
// Find commitBtn to position next to it
const commitBtn = bottomControlsElement.querySelector('#commitBtn') ||
bottomControlsElement.querySelector('button');
if (!commitBtn) {
console.warn('Brush Swap: Could not find commitBtn');
return;
}
// Create wrapper for button and dropdown
const wrapper = document.createElement('div');
wrapper.className = 'relative inline-block';
// Create toggle button (paintbrush icon)
const toggleBtn = document.createElement('button');
toggleBtn.id = 'brush-swap-toggle';
toggleBtn.className = 'bg-gray-100 dark:bg-gray-700 border border-gray-300 dark:border-gray-600 cursor-pointer px-2.5 py-1.5 text-xs leading-none font-semibold text-gray-800 dark:text-gray-200 ml-2 inline-flex items-center justify-center rounded hover:bg-gray-200 dark:hover:bg-gray-600 hover:border-gray-600 dark:hover:border-gray-500 active:bg-gray-300 dark:active:bg-gray-800';
toggleBtn.title = 'Toggle saved brushes';
toggleBtn.innerHTML = '<span style="font-size: 10px; font-weight: 600; display: flex; align-items: center; gap: 4px;">▲ brushes</span>';
toggleBtn.addEventListener('click', (e) => {
e.stopPropagation();
toggleDropdown();
});
// ── Scroll-to-swap: mouse wheel over toggle button cycles brushes ──
const toast = document.createElement('div');
toast.id = 'brush-swap-toast';
document.body.appendChild(toast);
let toastTimer = null;
function showSwapToast(brush, x, y) {
toast.innerHTML = '';
// Add preview
const previewWrap = document.createElement('span');
previewWrap.className = 'toast-preview';
previewWrap.appendChild(createBrushPreview(brush));
toast.appendChild(previewWrap);
// Add name
const nameSpan = document.createElement('span');
nameSpan.textContent = brush.name;
toast.appendChild(nameSpan);
toast.style.left = x + 'px';
toast.style.top = y + 'px';
toast.classList.add('visible');
clearTimeout(toastTimer);
toastTimer = setTimeout(() => toast.classList.remove('visible'), 900);
}
toggleBtn.addEventListener('wheel', (e) => {
if (scriptState.brushes.length === 0) return;
e.preventDefault();
e.stopPropagation();
const dir = e.deltaY > 0 ? 1 : -1;
const len = scriptState.brushes.length;
// Initialize index to current brush if not set
if (scriptState.scrollIndex < 0 || scriptState.scrollIndex >= len) {
scriptState.scrollIndex = dir > 0 ? 0 : len - 1;
} else {
scriptState.scrollIndex = ((scriptState.scrollIndex + dir) % len + len) % len;
}
const brush = scriptState.brushes[scriptState.scrollIndex];
applyBrushToEditor(brush);
showSwapToast(brush, e.clientX, e.clientY);
if (DEBUG) console.log(`Brush Swap: Scrolled to "${brush.name}" (index ${scriptState.scrollIndex})`);
}, { passive: false });
// Create dropdown container
const dropdown = document.createElement('div');
dropdown.id = 'brush-swap-dropdown';
dropdown.className = 'bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 shadow-lg';
dropdown.style.position = 'absolute'; // ensure positioned for resize handles
// Resize handles (top-left and top-right corners)
const resizeTL = document.createElement('div');
resizeTL.className = 'brush-swap-resize-handle brush-swap-resize-tl';
dropdown.appendChild(resizeTL);
const resizeTR = document.createElement('div');
resizeTR.className = 'brush-swap-resize-handle brush-swap-resize-tr';
dropdown.appendChild(resizeTR);
// Stored dimensions (persisted so they survive open/close)
let storedSize = null;
try { storedSize = JSON.parse(localStorage.getItem(RESIZE_STORAGE_KEY)); } catch {}
function applyStoredSize() {
if (storedSize) {
dropdown.style.maxWidth = storedSize.w + 'px';
dropdown.style.maxHeight = storedSize.h + 'px';
}
}
function setupResize(handle, isLeft) {
handle.addEventListener('mousedown', (e) => {
e.preventDefault();
e.stopPropagation();
const startX = e.clientX;
const startY = e.clientY;
const rect = dropdown.getBoundingClientRect();
const startW = rect.width;
const startH = rect.height;
function onMove(ev) {
const dx = ev.clientX - startX;
const dy = ev.clientY - startY;
// Top edge: dragging up increases height
const newH = Math.max(150, startH - dy);
// Left handle: dragging left increases width; right: dragging right increases
const newW = Math.max(200, isLeft ? startW - dx : startW + dx);
dropdown.style.maxWidth = newW + 'px';
dropdown.style.maxHeight = newH + 'px';
}
function onUp() {
document.removeEventListener('mousemove', onMove);
document.removeEventListener('mouseup', onUp);
// Persist
const r = dropdown.getBoundingClientRect();
storedSize = { w: Math.round(r.width), h: Math.round(r.height) };
localStorage.setItem(RESIZE_STORAGE_KEY, JSON.stringify(storedSize));
}
document.addEventListener('mousemove', onMove);
document.addEventListener('mouseup', onUp);
});
}
setupResize(resizeTL, true);
setupResize(resizeTR, false);
const itemsContainer = document.createElement('div');
itemsContainer.className = 'brush-swap-items';
dropdown.appendChild(itemsContainer);
// Assemble and insert
wrapper.appendChild(toggleBtn);
wrapper.appendChild(dropdown);
// Insert after commitBtn
commitBtn.parentNode.insertBefore(wrapper, commitBtn.nextSibling);
// Close dropdown on click outside
document.addEventListener('click', (e) => {
if (!wrapper.contains(e.target) && scriptState.dropdownOpen) {
toggleDropdown();
}
});
}
function hookToggleBrushEditor() {
const originalToggle = _pw.toggleBrushEditor;
if (typeof originalToggle === 'function') {
_pw.toggleBrushEditor = function() {
// Call original toggle
originalToggle.call(this);
// Add dimension dropdown after modal opens
setTimeout(() => {
addBrushDimensionDropdown();
}, 50);
};
if (DEBUG) console.log('Brush Swap: Hooked toggleBrushEditor');
}
}
// ============================================
function hookSaveBrushToPreset() {
if (typeof _pw.saveBrushToPreset !== 'function') {
console.warn('Brush Swap: saveBrushToPreset not yet available, retrying...');
return false;
}
const originalSave = _pw.saveBrushToPreset;
_pw.saveBrushToPreset = function(slotIndex) {
// Call original function
originalSave.call(this, slotIndex);
// After save, capture brush from DOM grid
const brushData = captureBrushFromDOM();
if (brushData) {
const newBrush = addBrush(brushData.pattern, brushData.brushSize);
if (DEBUG) console.log('Brush Swap: Saved brush', newBrush);
renderDropdown();
} else {
console.warn('Brush Swap: Failed to capture brush from DOM');
}
};
if (DEBUG) console.log('Brush Swap: Successfully hooked saveBrushToPreset');
return true;
}
// ============================================
// INITIALIZATION
// ============================================
function init() {
// Load saved brushes from localStorage
loadBrushes();
// Inject CSS
injectCSS();
// Wait for bottomControls and saveBrushToPreset to be ready
let attempts = 0;
const maxAttempts = 120; // 60 seconds at 500ms intervals
const initInterval = setInterval(() => {
attempts++;
const bottomControls = document.getElementById('bottomControls');
const hasSaveBrushToPreset = typeof _pw.saveBrushToPreset === 'function';
if (bottomControls && hasSaveBrushToPreset && attempts <= maxAttempts) {
clearInterval(initInterval);
// Set default brush size to 9x9 — must wait for async config fetch
// which overwrites BrushSize from userConfig.brushSize
const DEFAULT_BRUSH_SIZE = 9;
function applyDefaultBrushSize() {
_setPageVar('BrushSize', DEFAULT_BRUSH_SIZE);
_pw.BrushSize = DEFAULT_BRUSH_SIZE;
_runInPage('if(typeof generateBrushGrid==="function")generateBrushGrid(currentBrushPattern)');
// Update the dimension dropdown if it exists
const sel = document.getElementById('brush-swap-dimension-select');
if (sel) sel.value = DEFAULT_BRUSH_SIZE;
}
// Apply immediately, then re-apply after config fetch likely completes
applyDefaultBrushSize();
let configChecks = 0;
const configWait = setInterval(() => {
configChecks++;
// userConfig gets written to localStorage when server fetch completes
const saved = localStorage.getItem('userConfig');
if (saved || configChecks > 20) {
clearInterval(configWait);
applyDefaultBrushSize();
}
}, 250);
// Create UI
createUI(bottomControls);
// Hook into saveBrushToPreset (now guaranteed to exist)
hookSaveBrushToPreset();
// Hook into toggleBrushEditor for dimension dropdown
hookToggleBrushEditor();
if (DEBUG) console.log('Brush Swap initialized successfully');
} else if (attempts > maxAttempts) {
clearInterval(initInterval);
console.warn('Brush Swap: Could not initialize - bottomControls or saveBrushToPreset not found', {
hasBottomControls: !!bottomControls,
hasSaveBrushToPreset: hasSaveBrushToPreset
});
}
}, 500);
}
// Start when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
_featureStatus.paintBrushSwap = 'ok';
console.log('[GeoPixelcons++] ✅ Paint Brush Swap loaded');
} catch (err) {
_featureStatus.paintBrushSwap = 'error';
console.error('[GeoPixelcons++] ❌ Paint Brush Swap failed:', err);
}
}
// ============================================================
// FEATURE: Regions Highscore [regionsHighscore]
// ============================================================
if (_settings.regionsHighscore) {
try {
(function _init_regionsHighscore() {
// ==================== CONFIGURATION ====================
const GRID_SIZE = 25;
const TILE_SIZE = 1000;
const USERNAME_BATCH_SIZE = 10;
const SELECTION_COLOR = 'rgba(59, 130, 246, 0.3)';
const SELECTION_BORDER_COLOR = 'rgba(59, 130, 246, 0.8)';
// ==================== STATE ====================
let isSelectionModeActive = false;
let isDragging = false;
let selectionStart = null;
let selectionEnd = null;
let selectionCanvas = null;
let selectionCtx = null;
let highscoreButton = null;
let _map = null; // resolved MapLibre map object (not the DOM element)
// ==================== MAP ACCESS ====================
function _getMap() {
if (_map) return _map;
try { const m = (0, eval)('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {}
if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {} }
return null;
}
// ==================== INITIALIZATION ====================
function waitForGeoPixels() {
return new Promise((resolve) => {
const check = () => {
if (
_getMap() &&
typeof turf !== 'undefined' &&
typeof tileImageCache !== 'undefined' &&
document.getElementById('controls-left')
) {
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
}
async function init() {
await waitForGeoPixels();
console.log('[Regions Highscore] Initializing...');
createSelectionCanvas();
createHighscoreButton();
setupEventListeners();
console.log('[Regions Highscore] Ready!');
}
// ==================== UI COMPONENTS ====================
function createHighscoreButton() {
highscoreButton = document.createElement('button');
highscoreButton.id = 'gpc-highscore-trigger';
highscoreButton.style.display = 'none';
highscoreButton.addEventListener('click', toggleSelectionMode);
document.body.appendChild(highscoreButton);
}
function createSelectionCanvas() {
selectionCanvas = document.createElement('canvas');
selectionCanvas.id = 'highscore-selection-canvas';
selectionCanvas.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1000;
`;
document.body.appendChild(selectionCanvas);
selectionCtx = selectionCanvas.getContext('2d');
// Sync canvas size with viewport
const syncCanvasSize = () => {
const dpr = window.devicePixelRatio || 1;
selectionCanvas.width = window.innerWidth * dpr;
selectionCanvas.height = window.innerHeight * dpr;
selectionCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
syncCanvasSize();
window.addEventListener('resize', syncCanvasSize);
// Redraw on map events
['move', 'rotate', 'zoom'].forEach((ev) => {
_map.on(ev, () => {
if (isDragging) drawSelectionPreview();
});
});
}
function toggleSelectionMode() {
isSelectionModeActive = !isSelectionModeActive;
if (isSelectionModeActive) {
highscoreButton.style.backgroundColor = '#3b82f6';
highscoreButton.style.color = 'white';
highscoreButton.style.boxShadow = '0 0 10px rgba(59, 130, 246, 0.5)';
document.body.style.cursor = 'crosshair';
disableMapInteractions();
showNotification('Click and drag to select a region');
} else {
resetSelectionMode();
}
}
function disableMapInteractions() {
const m = _getMap(); if (!m) return;
m.dragPan.disable();
m.scrollZoom.disable();
m.boxZoom.disable();
m.doubleClickZoom.disable();
m.touchZoomRotate.disable();
}
function enableMapInteractions() {
const m = _getMap(); if (!m) return;
m.dragPan.enable();
m.scrollZoom.enable();
m.boxZoom.enable();
// Note: doubleClickZoom is intentionally NOT re-enabled — the native site disables it
m.touchZoomRotate.enable();
}
function resetSelectionMode() {
isSelectionModeActive = false;
isDragging = false;
selectionStart = null;
selectionEnd = null;
if (highscoreButton) {
highscoreButton.style.backgroundColor = 'white';
highscoreButton.style.color = 'black';
highscoreButton.style.boxShadow = '';
}
document.body.style.cursor = '';
enableMapInteractions();
clearSelectionCanvas();
}
function clearSelectionCanvas() {
if (selectionCtx && selectionCanvas) {
selectionCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
}
}
// ==================== EVENT HANDLERS ====================
function setupEventListeners() {
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('keydown', handleKeyDown);
}
function handleMouseDown(e) {
if (!isSelectionModeActive) return;
if (e.button !== 0) return; // Only left click
// Check if clicking on UI elements
if (e.target.closest('#controls-left') || e.target.closest('#controls-right') || e.target.closest('.modal-container')) {
return;
}
isDragging = true;
selectionStart = screenPointToGrid(e.clientX, e.clientY);
selectionEnd = selectionStart;
e.preventDefault();
e.stopPropagation();
}
function handleMouseMove(e) {
if (!isDragging || !selectionStart) return;
selectionEnd = screenPointToGrid(e.clientX, e.clientY);
drawSelectionPreview();
}
async function handleMouseUp(e) {
if (!isDragging || !selectionStart || !selectionEnd) return;
isDragging = false;
const bounds = getSelectionBounds();
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
if (width < 2 || height < 2) {
showNotification('Selection too small. Please select a larger area.');
clearSelectionCanvas();
resetSelectionMode();
return;
}
// Reset mode but keep selection visible during computation
isSelectionModeActive = false;
if (highscoreButton) {
highscoreButton.style.backgroundColor = 'white';
highscoreButton.style.color = 'black';
highscoreButton.style.boxShadow = '';
}
document.body.style.cursor = '';
enableMapInteractions();
// Show loading modal
const modal = createLeaderboardModal(bounds, null, true);
const progressEl = modal.querySelector('.rhs-progress-text');
const updateProgress = (text) => {
if (progressEl) progressEl.textContent = text;
};
try {
const userCounts = await computeRegionPixels(bounds, updateProgress);
updateProgress('Fetching usernames...');
const leaderboard = await buildLeaderboard(userCounts);
updateLeaderboardModal(modal, bounds, leaderboard);
} catch (error) {
console.error('[Regions Highscore] Error computing leaderboard:', error);
showNotification('Error computing leaderboard: ' + error.message);
modal.close();
}
clearSelectionCanvas();
selectionStart = null;
selectionEnd = null;
}
function handleKeyDown(e) {
if (e.key === 'Escape') {
if (isSelectionModeActive || isDragging) {
resetSelectionMode();
}
}
}
// ==================== COORDINATE HELPERS ====================
function screenPointToGrid(clientX, clientY) {
// _map.unproject expects point relative to map container
const mapContainer = _map.getContainer();
const rect = mapContainer.getBoundingClientRect();
const point = [clientX - rect.left, clientY - rect.top];
const lngLat = _map.unproject(point);
const merc = turf.toMercator([lngLat.lng, lngLat.lat]);
return {
gridX: Math.round(merc[0] / GRID_SIZE),
gridY: Math.round(merc[1] / GRID_SIZE),
};
}
function gridToScreen(gridX, gridY) {
const mercX = gridX * GRID_SIZE;
const mercY = gridY * GRID_SIZE;
const lngLat = turf.toWgs84([mercX, mercY]);
const point = _map.project(lngLat);
// Convert map-relative coordinates to screen coordinates
const mapContainer = _map.getContainer();
const rect = mapContainer.getBoundingClientRect();
return {
x: point.x + rect.left,
y: point.y + rect.top
};
}
function getSelectionBounds() {
return {
minX: Math.min(selectionStart.gridX, selectionEnd.gridX),
maxX: Math.max(selectionStart.gridX, selectionEnd.gridX),
minY: Math.min(selectionStart.gridY, selectionEnd.gridY),
maxY: Math.max(selectionStart.gridY, selectionEnd.gridY),
};
}
// ==================== DRAWING ====================
function drawSelectionPreview() {
clearSelectionCanvas();
if (!selectionStart || !selectionEnd) return;
const bounds = getSelectionBounds();
// Convert grid bounds to screen coordinates
const topLeft = gridToScreen(bounds.minX - 0.5, bounds.maxY + 0.5);
const bottomRight = gridToScreen(bounds.maxX + 0.5, bounds.minY - 0.5);
const x = topLeft.x;
const y = topLeft.y;
const width = bottomRight.x - topLeft.x;
const height = bottomRight.y - topLeft.y;
selectionCtx.fillStyle = SELECTION_COLOR;
selectionCtx.fillRect(x, y, width, height);
selectionCtx.strokeStyle = SELECTION_BORDER_COLOR;
selectionCtx.lineWidth = 2;
selectionCtx.strokeRect(x, y, width, height);
// Draw size indicator
const selWidth = bounds.maxX - bounds.minX + 1;
const selHeight = bounds.maxY - bounds.minY + 1;
const sizeText = `${selWidth} × ${selHeight}`;
selectionCtx.font = 'bold 14px sans-serif';
selectionCtx.fillStyle = 'white';
selectionCtx.strokeStyle = 'black';
selectionCtx.lineWidth = 3;
const textX = x + width / 2;
const textY = y + height / 2;
selectionCtx.textAlign = 'center';
selectionCtx.textBaseline = 'middle';
selectionCtx.strokeText(sizeText, textX, textY);
selectionCtx.fillText(sizeText, textX, textY);
}
// ==================== PIXEL COMPUTATION ====================
async function computeRegionPixels(bounds, updateProgress) {
const userCounts = new Map();
const { minX, maxX, minY, maxY } = bounds;
// Determine which tiles we need (more efficient iteration)
const neededTiles = new Set();
const startTileX = Math.floor(minX / TILE_SIZE) * TILE_SIZE;
const endTileX = Math.floor(maxX / TILE_SIZE) * TILE_SIZE;
const startTileY = Math.floor(minY / TILE_SIZE) * TILE_SIZE;
const endTileY = Math.floor(maxY / TILE_SIZE) * TILE_SIZE;
for (let tx = startTileX; tx <= endTileX; tx += TILE_SIZE) {
for (let ty = startTileY; ty <= endTileY; ty += TILE_SIZE) {
neededTiles.add(`${tx},${ty}`);
}
}
const tilesArray = [...neededTiles];
console.log(`[Regions Highscore] Need ${tilesArray.length} tiles for region ${maxX - minX + 1}×${maxY - minY + 1}`);
console.log(`[Regions Highscore] Selection bounds: X ${minX} to ${maxX}, Y ${minY} to ${maxY}`);
console.log(`[Regions Highscore] Tiles needed:`, tilesArray);
let processedTiles = 0;
let totalPixelsFound = 0;
for (const tileKey of tilesArray) {
const [tileX, tileY] = tileKey.split(',').map(Number);
// Update progress
processedTiles++;
if (updateProgress) {
updateProgress(`Processing tile ${processedTiles}/${tilesArray.length}...`);
}
// Yield to UI
await new Promise(resolve => setTimeout(resolve, 0));
// Try to get from cache first
let userBitmap = null;
const cached = tileImageCache.get(tileKey);
console.log(`[Regions Highscore] Cache lookup for ${tileKey}:`, cached ? 'FOUND' : 'NOT FOUND');
if (cached) {
console.log(`[Regions Highscore] Cache entry keys:`, Object.keys(cached));
console.log(`[Regions Highscore] Cache entry:`, cached);
}
if (cached && cached.userBitmap) {
userBitmap = cached.userBitmap;
console.log(`[Regions Highscore] Using cached userBitmap, size: ${userBitmap.width}x${userBitmap.height}`);
} else {
// Fetch from API
console.log(`[Regions Highscore] Fetching tile ${tileKey} from API...`);
if (updateProgress) {
updateProgress(`Fetching tile ${tileKey}...`);
}
try {
const tileData = await fetchTileData(tileX, tileY);
console.log(`[Regions Highscore] API response for ${tileKey}:`, tileData);
if (tileData && tileData.userBitmap) {
userBitmap = tileData.userBitmap;
console.log(`[Regions Highscore] Fetched userBitmap, size: ${userBitmap.width}x${userBitmap.height}`);
}
} catch (err) {
console.warn(`[Regions Highscore] Failed to fetch tile ${tileKey}:`, err);
continue;
}
}
if (!userBitmap) continue;
// Debug: check if bitmap has ANY non-zero data by sampling various points
const debugCanvas = new OffscreenCanvas(userBitmap.width, userBitmap.height);
const debugCtx = debugCanvas.getContext('2d', { willReadFrequently: true });
debugCtx.drawImage(userBitmap, 0, 0);
const fullData = debugCtx.getImageData(0, 0, userBitmap.width, userBitmap.height).data;
let nonZeroInFullBitmap = 0;
for (let i = 0; i < fullData.length; i += 4) {
if (fullData[i] !== 0 || fullData[i+1] !== 0 || fullData[i+2] !== 0) {
nonZeroInFullBitmap++;
if (nonZeroInFullBitmap <= 3) {
const pixelIndex = i / 4;
const bmpX = pixelIndex % userBitmap.width;
const bmpY = Math.floor(pixelIndex / userBitmap.width);
// Convert back to grid coordinates both ways
const gridXFromBmp = tileX + bmpX;
const gridYInverted = tileY + (TILE_SIZE - 1 - bmpY);
const gridYDirect = tileY + bmpY;
console.log(`[Regions Highscore] Non-zero pixel at bitmap (${bmpX}, ${bmpY}): RGB(${fullData[i]},${fullData[i+1]},${fullData[i+2]})`);
console.log(` - Grid coords if Y inverted: (${gridXFromBmp}, ${gridYInverted})`);
console.log(` - Grid coords if Y direct: (${gridXFromBmp}, ${gridYDirect})`);
}
}
}
console.log(`[Regions Highscore] Total non-zero pixels in FULL bitmap: ${nonZeroInFullBitmap}`);
// Check specific pixel (-351700, 218914) that user mentioned
const testGridX = -351700;
const testGridY = 218914;
if (testGridX >= tileX && testGridX < tileX + TILE_SIZE && testGridY >= tileY && testGridY < tileY + TILE_SIZE) {
const testLocalX = testGridX - tileX;
const testLocalYInverted = TILE_SIZE - 1 - (testGridY - tileY);
const testLocalYDirect = testGridY - tileY;
console.log(`[Regions Highscore] Test pixel (-351700, 218914):`);
console.log(` - If Y inverted: local (${testLocalX}, ${testLocalYInverted})`);
console.log(` - If Y direct: local (${testLocalX}, ${testLocalYDirect})`);
const testIdxInverted = (testLocalYInverted * userBitmap.width + testLocalX) * 4;
const testIdxDirect = (testLocalYDirect * userBitmap.width + testLocalX) * 4;
console.log(` - Inverted value: RGB(${fullData[testIdxInverted]},${fullData[testIdxInverted+1]},${fullData[testIdxInverted+2]},${fullData[testIdxInverted+3]})`);
console.log(` - Direct value: RGB(${fullData[testIdxDirect]},${fullData[testIdxDirect+1]},${fullData[testIdxDirect+2]},${fullData[testIdxDirect+3]})`);
}
// Calculate the region of this tile that overlaps with selection
const tileMinX = Math.max(minX, tileX);
const tileMaxX = Math.min(maxX, tileX + TILE_SIZE - 1);
const tileMinY = Math.max(minY, tileY);
const tileMaxY = Math.min(maxY, tileY + TILE_SIZE - 1);
const regionWidth = tileMaxX - tileMinX + 1;
const regionHeight = tileMaxY - tileMinY + 1;
if (regionWidth <= 0 || regionHeight <= 0) continue;
// Read the entire relevant region at once (much faster than 1x1)
// Y is NOT inverted in the bitmap - use direct coordinates
const localStartX = tileMinX - tileX;
const localStartY = tileMinY - tileY;
console.log(`[Regions Highscore] Tile ${tileKey}: reading region ${regionWidth}x${regionHeight} at local (${localStartX}, ${localStartY})`);
console.log(`[Regions Highscore] Tile bounds: X ${tileMinX}-${tileMaxX}, Y ${tileMinY}-${tileMaxY}`);
const tempCanvas = new OffscreenCanvas(regionWidth, regionHeight);
const tempCtx = tempCanvas.getContext('2d', { willReadFrequently: true });
tempCtx.drawImage(
userBitmap,
localStartX, localStartY, regionWidth, regionHeight,
0, 0, regionWidth, regionHeight
);
const imageData = tempCtx.getImageData(0, 0, regionWidth, regionHeight);
const data = imageData.data;
// Debug: log sample pixels to understand the data format
console.log(`[Regions Highscore] ImageData size: ${imageData.width}x${imageData.height}, data length: ${data.length}`);
const samplePixels = [];
for (let s = 0; s < Math.min(10, regionWidth * regionHeight); s++) {
const idx = s * 4;
samplePixels.push(`(${data[idx]},${data[idx+1]},${data[idx+2]},${data[idx+3]})`);
}
console.log(`[Regions Highscore] First 10 pixels (RGBA):`, samplePixels.join(' '));
// Process pixels in chunks to avoid blocking UI
const CHUNK_SIZE = 50000;
const totalPixels = regionWidth * regionHeight;
let nonZeroCount = 0;
for (let i = 0; i < totalPixels; i++) {
const offset = i * 4;
const r = data[offset];
const g = data[offset + 1];
const b = data[offset + 2];
const a = data[offset + 3];
// User ID is encoded in RGB; check if RGB is non-zero (not alpha)
const userId = (r << 16) | (g << 8) | b;
if (userId > 0) {
userCounts.set(userId, (userCounts.get(userId) || 0) + 1);
totalPixelsFound++;
if (nonZeroCount < 3) {
console.log(`[Regions Highscore] Found user pixel: userId=${userId} (R=${r},G=${g},B=${b},A=${a})`);
}
nonZeroCount++;
}
// Yield every CHUNK_SIZE pixels to keep UI responsive
if (i > 0 && i % CHUNK_SIZE === 0) {
await new Promise(resolve => setTimeout(resolve, 0));
}
}
console.log(`[Regions Highscore] Found ${nonZeroCount} non-zero pixels in tile region`);
}
console.log(`[Regions Highscore] Total pixels with users found: ${totalPixelsFound}`);
console.log(`[Regions Highscore] Unique users: ${userCounts.size}`);
return userCounts;
}
async function fetchTileData(tileX, tileY) {
const response = await fetch('https://geopixels.net/GetPixelsCached', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Tiles: [{ x: tileX, y: tileY, timestamp: 0 }],
}),
});
if (!response.ok) {
throw new Error(`API returned ${response.status}`);
}
const data = await response.json();
const tileKey = `tile_${tileX}_${tileY}`;
const tileInfo = data.Tiles[tileKey];
console.log(`[Regions Highscore] API tile key: ${tileKey}`);
console.log(`[Regions Highscore] Available tiles in response:`, Object.keys(data.Tiles));
console.log(`[Regions Highscore] Tile info:`, tileInfo);
if (!tileInfo) return null;
// Handle full tile with WebP images
if (tileInfo.Type === 'full' && tileInfo.UserWebP) {
const userBitmap = await decodeWebPToBitmap(tileInfo.UserWebP);
return { userBitmap };
}
// Handle delta (partial update) - we need to process deltas
if (tileInfo.Pixels && tileInfo.Pixels.length > 0) {
// Create bitmap from deltas
const userBitmap = await createBitmapFromDeltas(tileInfo.Pixels, tileX, tileY);
return { userBitmap };
}
return null;
}
async function decodeWebPToBitmap(base64Data) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => {
createImageBitmap(img).then(resolve).catch(reject);
};
img.onerror = reject;
img.src = `data:image/webp;base64,${base64Data}`;
});
}
async function createBitmapFromDeltas(deltas, tileX, tileY) {
const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE);
const ctx = canvas.getContext('2d');
for (const delta of deltas) {
const [gridX, gridY, color, userId] = delta;
// Y is NOT inverted - use direct coordinates
const localX = gridX - tileX;
const localY = gridY - tileY;
// Encode userId as RGB
const r = (userId >> 16) & 0xff;
const g = (userId >> 8) & 0xff;
const b = userId & 0xff;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(localX, localY, 1, 1);
}
return createImageBitmap(canvas);
}
// ==================== LEADERBOARD ====================
async function buildLeaderboard(userCounts) {
// Sort by pixel count descending
const sorted = [...userCounts.entries()].sort((a, b) => b[1] - a[1]);
// Fetch usernames
const userIds = sorted.map(([id]) => id);
const usernames = await fetchUsernames(userIds);
return sorted.map(([userId, count], index) => ({
rank: index + 1,
userId,
username: usernames.get(userId) || `User #${userId}`,
pixelCount: count,
}));
}
async function fetchUsernames(userIds) {
const usernames = new Map();
// Batch requests
for (let i = 0; i < userIds.length; i += USERNAME_BATCH_SIZE) {
const batch = userIds.slice(i, i + USERNAME_BATCH_SIZE);
const promises = batch.map(async (userId) => {
try {
const response = await fetch('https://geopixels.net/GetUserProfile', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ targetId: userId }),
});
if (response.ok) {
const data = await response.json();
return { userId, name: data.name || `User #${userId}` };
}
} catch (err) {
console.warn(`[Regions Highscore] Failed to fetch user ${userId}:`, err);
}
return { userId, name: `User #${userId}` };
});
const results = await Promise.all(promises);
for (const { userId, name } of results) {
usernames.set(userId, name);
}
}
return usernames;
}
// ==================== THEME HELPERS ====================
function isDarkMode() {
return getComputedStyle(document.documentElement).colorScheme === 'dark';
}
function getThemeColors() {
const dark = isDarkMode();
return {
modalBg: dark ? '#1e2939' : 'white',
overlayBg: dark ? 'rgba(0, 0, 0, 0.7)' : 'rgba(0, 0, 0, 0.5)',
text: dark ? '#f3f4f6' : '#333',
textSecondary: dark ? '#d1d5db' : '#666',
textMuted: dark ? '#99a1af' : '#888',
textSubtle: dark ? '#6a7282' : '#999',
border: dark ? '#364153' : '#eee',
headerBg: dark ? '#101828' : '#f0f0f0',
summaryBg: dark ? '#101828' : '#f8f9fa',
summaryText: dark ? '#d1d5db' : '#555',
closeBtnColor: dark ? '#99a1af' : '#666',
closeBtnHoverBg: dark ? '#364153' : '#f0f0f0',
closeBtnHoverColor: dark ? '#f3f4f6' : '#333',
notificationBg: dark ? '#1e2939' : '#333',
notificationText: dark ? '#f3f4f6' : 'white',
};
}
// ==================== ADJUST BOUNDS MODAL ====================
function showAdjustModal(currentBounds, onConfirm) {
const existing = document.querySelector('.rhs-adjust-overlay');
if (existing) existing.remove();
const t = getThemeColors();
const overlay = document.createElement('div');
overlay.className = 'rhs-adjust-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 10001;
background: ${t.overlayBg};
display: flex; align-items: center; justify-content: center;
font-family: system-ui, sans-serif;
`;
const inputStyle = `width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid ${t.border}; background: ${t.headerBg}; color: ${t.text}; font-size: 13px; font-family: monospace;`;
const box = document.createElement('div');
box.style.cssText = `
background: ${t.modalBg}; color: ${t.text}; border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
padding: 24px; min-width: 320px; display: flex; flex-direction: column; gap: 14px;
`;
box.innerHTML = `
<h3 style="margin: 0; font-size: 16px; font-weight: 700;">Adjust Region Bounds</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<label style="font-size: 12px; color: ${t.textSecondary};">X1 (min)
<input id="rhs-adj-x1" type="number" value="${currentBounds.minX}" style="${inputStyle}" />
</label>
<label style="font-size: 12px; color: ${t.textSecondary};">X2 (max)
<input id="rhs-adj-x2" type="number" value="${currentBounds.maxX}" style="${inputStyle}" />
</label>
<label style="font-size: 12px; color: ${t.textSecondary};">Y1 (min)
<input id="rhs-adj-y1" type="number" value="${currentBounds.minY}" style="${inputStyle}" />
</label>
<label style="font-size: 12px; color: ${t.textSecondary};">Y2 (max)
<input id="rhs-adj-y2" type="number" value="${currentBounds.maxY}" style="${inputStyle}" />
</label>
</div>
<div id="rhs-adj-error" style="font-size: 12px; color: #ef4444; display: none;"></div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button id="rhs-adj-cancel" style="padding: 8px 16px; border-radius: 8px; border: 1px solid ${t.border}; background: ${t.headerBg}; color: ${t.textSecondary}; cursor: pointer; font-size: 13px;">Cancel</button>
<button id="rhs-adj-confirm" style="padding: 8px 16px; border-radius: 8px; border: none; background: #3b82f6; color: white; cursor: pointer; font-size: 13px; font-weight: 600;">Apply</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
box.querySelector('#rhs-adj-cancel').onclick = close;
box.querySelector('#rhs-adj-confirm').onclick = () => {
const x1 = parseInt(box.querySelector('#rhs-adj-x1').value);
const x2 = parseInt(box.querySelector('#rhs-adj-x2').value);
const y1 = parseInt(box.querySelector('#rhs-adj-y1').value);
const y2 = parseInt(box.querySelector('#rhs-adj-y2').value);
const errEl = box.querySelector('#rhs-adj-error');
if ([x1, x2, y1, y2].some(isNaN)) {
errEl.textContent = 'All fields must be valid numbers.';
errEl.style.display = 'block';
return;
}
const newBounds = {
minX: Math.min(x1, x2), maxX: Math.max(x1, x2),
minY: Math.min(y1, y2), maxY: Math.max(y1, y2),
};
const w = newBounds.maxX - newBounds.minX + 1;
const h = newBounds.maxY - newBounds.minY + 1;
if (w < 2 || h < 2) {
errEl.textContent = 'Region must be at least 2×2.';
errEl.style.display = 'block';
return;
}
close();
onConfirm(newBounds);
};
const escH = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escH); } };
document.addEventListener('keydown', escH);
}
async function rerunLeaderboard(newBounds) {
// Remove any existing modal
const existing = document.querySelector('.rhs-modal-container');
if (existing) existing.remove();
const modal = createLeaderboardModal(newBounds, null, true);
const progressEl = modal.querySelector('.rhs-progress-text');
const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
try {
const userCounts = await computeRegionPixels(newBounds, updateProgress);
updateProgress('Fetching usernames...');
const leaderboard = await buildLeaderboard(userCounts);
updateLeaderboardModal(modal, newBounds, leaderboard);
} catch (error) {
console.error('[Regions Highscore] Error:', error);
showNotification('Error computing leaderboard: ' + error.message);
modal.close();
}
}
// ==================== MODAL ====================
function createLeaderboardModal(bounds, leaderboard = null, loading = false) {
// Remove existing modal if any
const existing = document.querySelector('.rhs-modal-container');
if (existing) existing.remove();
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
const totalPixels = width * height;
const t = getThemeColors();
const modalContainer = document.createElement('div');
modalContainer.className = 'rhs-modal-container';
modalContainer.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: ${t.overlayBg};
display: flex;
align-items: center;
justify-content: center;
`;
const modal = document.createElement('div');
modal.className = 'rhs-modal';
modal.style.cssText = `
position: relative;
background: ${t.modalBg};
color: ${t.text};
border-radius: 12px;
box-shadow: 0 20px 50px rgba(0, 0, 0, 0.3);
padding: 24px;
min-width: 400px;
max-width: 90vw;
max-height: 80vh;
display: flex;
flex-direction: column;
`;
// Close button
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `
position: absolute;
top: 12px;
right: 12px;
background: none;
border: none;
font-size: 20px;
cursor: pointer;
color: ${t.closeBtnColor};
width: 32px;
height: 32px;
border-radius: 50%;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s;
`;
closeBtn.onmouseover = () => { closeBtn.style.background = t.closeBtnHoverBg; closeBtn.style.color = t.closeBtnHoverColor; };
closeBtn.onmouseout = () => { closeBtn.style.background = 'none'; closeBtn.style.color = t.closeBtnColor; };
// Header
const header = document.createElement('div');
header.style.cssText = 'margin-bottom: 16px; padding-right: 32px;';
header.innerHTML = `
<h2 style="margin: 0 0 8px 0; font-size: 24px; font-weight: bold; color: ${t.text};">📊 Region Leaderboard</h2>
<p style="margin: 0; color: ${t.textSecondary}; font-size: 14px;">Selected area: ${width} × ${height} pixels (${totalPixels.toLocaleString()} total)</p>
<p style="margin: 4px 0 0 0; color: ${t.textMuted}; font-size: 12px; font-family: monospace;">X: ${bounds.minX} to ${bounds.maxX} | Y: ${bounds.minY} to ${bounds.maxY}</p>
`;
const adjustBtn = document.createElement('button');
adjustBtn.textContent = 'Adjust…';
adjustBtn.style.cssText = `
margin-top: 8px; padding: 5px 12px; border-radius: 6px; border: 1px solid ${t.border};
background: ${t.headerBg}; color: ${t.textSecondary}; font-size: 12px; cursor: pointer;
transition: background 0.15s; white-space: nowrap;
`;
adjustBtn.onmouseover = () => { adjustBtn.style.background = t.closeBtnHoverBg; };
adjustBtn.onmouseout = () => { adjustBtn.style.background = t.headerBg; };
adjustBtn.onclick = () => {
showAdjustModal(bounds, (newBounds) => {
rerunLeaderboard(newBounds);
});
};
header.appendChild(adjustBtn);
// Content area
const content = document.createElement('div');
content.className = 'rhs-modal-content';
content.style.cssText = `
flex: 1;
overflow-y: auto;
min-height: 200px;
`;
if (loading) {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 200px; color: ${t.textSecondary};">
<div style="font-size: 32px; margin-bottom: 16px;">⏳</div>
<div class="rhs-progress-text">Calculating leaderboard...</div>
<div style="font-size: 12px; margin-top: 8px; color: ${t.textSubtle};">This may take a moment for large regions</div>
</div>
`;
} else if (leaderboard) {
content.appendChild(createLeaderboardTable(leaderboard));
}
modal.appendChild(closeBtn);
modal.appendChild(header);
modal.appendChild(content);
modalContainer.appendChild(modal);
document.body.appendChild(modalContainer);
// Close handlers
const closeModal = () => modalContainer.remove();
closeBtn.onclick = closeModal;
modalContainer.onclick = (e) => { if (e.target === modalContainer) closeModal(); };
const escHandler = (e) => {
if (e.key === 'Escape') {
closeModal();
document.removeEventListener('keydown', escHandler);
}
};
document.addEventListener('keydown', escHandler);
modal.close = closeModal;
return modal;
}
function updateLeaderboardModal(modal, bounds, leaderboard) {
const content = modal.querySelector('.rhs-modal-content');
if (!content) return;
content.innerHTML = '';
if (leaderboard.length === 0) {
const t = getThemeColors();
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center; height: 150px; color: ${t.textSecondary};">
<div style="font-size: 32px; margin-bottom: 16px;">🤷</div>
<div>No pixels found in this region</div>
</div>
`;
} else {
content.appendChild(createLeaderboardTable(leaderboard));
}
}
function createLeaderboardTable(leaderboard) {
const t = getThemeColors();
const container = document.createElement('div');
// Summary
const totalPixels = leaderboard.reduce((sum, entry) => sum + entry.pixelCount, 0);
const summary = document.createElement('div');
summary.style.cssText = `margin-bottom: 16px; padding: 12px; background: ${t.summaryBg}; border-radius: 8px; font-size: 14px; color: ${t.summaryText};`;
summary.innerHTML = `<strong>${leaderboard.length}</strong> users placed <strong>${totalPixels.toLocaleString()}</strong> pixels in this region`;
container.appendChild(summary);
// Table
const table = document.createElement('table');
table.style.cssText = `width: 100%; border-collapse: collapse; font-size: 14px; color: ${t.text};`;
// Header row
const thead = document.createElement('thead');
thead.innerHTML = `
<tr style="background: ${t.headerBg}; text-align: left;">
<th style="padding: 10px 12px; font-weight: 600; width: 60px;">Rank</th>
<th style="padding: 10px 12px; font-weight: 600;">Username</th>
<th style="padding: 10px 12px; font-weight: 600; text-align: right; width: 100px;">Pixels</th>
<th style="padding: 10px 12px; font-weight: 600; text-align: right; width: 80px;">%</th>
</tr>
`;
table.appendChild(thead);
// Body rows
const tbody = document.createElement('tbody');
for (const entry of leaderboard) {
const row = document.createElement('tr');
row.style.cssText = `
border-bottom: 1px solid ${t.border};
${entry.rank <= 3 ? 'background: ' + getRankBackground(entry.rank) + ';' : ''}
`;
const percent = ((entry.pixelCount / totalPixels) * 100).toFixed(1);
const rankEmoji = getRankEmoji(entry.rank);
row.innerHTML = `
<td style="padding: 10px 12px; font-weight: ${entry.rank <= 3 ? 'bold' : 'normal'};">${rankEmoji} ${entry.rank}</td>
<td style="padding: 10px 12px;">${escapeHtml(entry.username)}</td>
<td style="padding: 10px 12px; text-align: right; font-family: monospace;">${entry.pixelCount.toLocaleString()}</td>
<td style="padding: 10px 12px; text-align: right; color: ${t.textSecondary};">${percent}%</td>
`;
tbody.appendChild(row);
}
table.appendChild(tbody);
container.appendChild(table);
return container;
}
function getRankEmoji(rank) {
switch (rank) {
case 1: return '🥇';
case 2: return '🥈';
case 3: return '🥉';
default: return '';
}
}
function getRankBackground(rank) {
switch (rank) {
case 1: return 'rgba(255, 215, 0, 0.15)';
case 2: return 'rgba(192, 192, 192, 0.15)';
case 3: return 'rgba(205, 127, 50, 0.15)';
default: return 'transparent';
}
}
function escapeHtml(text) {
const div = document.createElement('div');
div.textContent = text;
return div.innerHTML;
}
// ==================== NOTIFICATIONS ====================
function showNotification(message) {
// Use GeoPixels' notification system if available
if (typeof showAnnouncement === 'function') {
showAnnouncement(message);
return;
}
// Fallback notification
const t = getThemeColors();
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: ${t.notificationBg};
color: ${t.notificationText};
padding: 12px 24px;
border-radius: 8px;
z-index: 10001;
font-size: 14px;
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.3);
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3000);
}
// ==================== PROCESS WITH BOUNDS (for flyout) =========
async function processWithBounds(bounds) {
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
if (width < 2 || height < 2) { showNotification('Selection too small. Please select a larger area.'); return; }
const modal = createLeaderboardModal(bounds, null, true);
const progressEl = modal.querySelector('.rhs-progress-text');
const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
try {
const userCounts = await computeRegionPixels(bounds, updateProgress);
updateProgress('Fetching usernames...');
const leaderboard = await buildLeaderboard(userCounts);
updateLeaderboardModal(modal, bounds, leaderboard);
} catch (error) {
console.error('[Regions Highscore] Error computing leaderboard:', error);
showNotification('Error computing leaderboard: ' + error.message);
try { modal.close(); } catch {}
}
}
// ==================== START ====================
init();
// Expose API for flyout
_regionsHighscore = { processWithBounds, toggleSelectionMode };
})();
_featureStatus.regionsHighscore = 'ok';
console.log('[GeoPixelcons++] \u2705 Regions Highscore loaded');
} catch (err) {
_featureStatus.regionsHighscore = 'error';
console.error('[GeoPixelcons++] ❌ Regions Highscore failed:', err);
}
}
// ============================================================
// FEATURE: Region Screenshot [regionScreenshot]
// ============================================================
if (_settings.regionScreenshot) {
try {
(function _init_regionScreenshot() {
// ==================== CONFIGURATION ====================
const GRID_SIZE = 25;
const TILE_SIZE = 1000;
const MAX_REGION_PIXELS = 10000 * 10000; // 100M px — hard limit to prevent OOM
const SELECTION_COLOR = 'rgba(16, 185, 129, 0.25)';
const SELECTION_BORDER_COLOR = 'rgba(16, 185, 129, 0.9)';
// ==================== STATE ====================
let isSelectionModeActive = false;
let isDragging = false;
let selectionStart = null;
let selectionEnd = null;
let selectionCanvas = null;
let selectionCtx = null;
let screenshotButton = null;
let _map = null; // resolved MapLibre map object (not the DOM element)
// ==================== MAP ACCESS ====================
function _getMap() {
if (_map) return _map;
try { const m = (0, eval)('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {}
if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.scrollZoom !== 'undefined') return (_map = m); } catch {} }
return null;
}
// ==================== INITIALIZATION ====================
function waitForGeoPixels() {
return new Promise((resolve) => {
const check = () => {
if (
_getMap() &&
typeof turf !== 'undefined' &&
typeof tileImageCache !== 'undefined' &&
document.getElementById('controls-left')
) {
resolve();
} else {
setTimeout(check, 500);
}
};
check();
});
}
async function init() {
await waitForGeoPixels();
console.log('[Region Screenshot] Initializing...');
createSelectionCanvas();
createScreenshotButton();
setupEventListeners();
console.log('[Region Screenshot] Ready!');
}
// ==================== UI COMPONENTS ====================
function createScreenshotButton() {
screenshotButton = document.createElement('button');
screenshotButton.id = 'gpc-screenshot-trigger';
screenshotButton.style.display = 'none';
screenshotButton.addEventListener('click', toggleSelectionMode);
document.body.appendChild(screenshotButton);
}
function createSelectionCanvas() {
selectionCanvas = document.createElement('canvas');
selectionCanvas.id = 'screenshot-selection-canvas';
selectionCanvas.style.cssText = `
position: fixed;
top: 0;
left: 0;
width: 100vw;
height: 100vh;
pointer-events: none;
z-index: 1000;
`;
document.body.appendChild(selectionCanvas);
selectionCtx = selectionCanvas.getContext('2d');
const syncSize = () => {
const dpr = window.devicePixelRatio || 1;
selectionCanvas.width = window.innerWidth * dpr;
selectionCanvas.height = window.innerHeight * dpr;
selectionCtx.setTransform(dpr, 0, 0, dpr, 0, 0);
};
syncSize();
window.addEventListener('resize', syncSize);
['move', 'rotate', 'zoom'].forEach((ev) => {
_map.on(ev, () => {
if (isDragging) drawSelectionPreview();
});
});
}
// ==================== SELECTION MODE ====================
function toggleSelectionMode() {
isSelectionModeActive = !isSelectionModeActive;
if (isSelectionModeActive) {
screenshotButton.style.backgroundColor = '#10b981';
screenshotButton.style.color = 'white';
screenshotButton.style.boxShadow = '0 0 10px rgba(16, 185, 129, 0.6)';
document.body.style.cursor = 'crosshair';
disableMapInteractions();
showNotification('Click and drag to select a region to screenshot');
} else {
resetSelectionMode();
}
}
function disableMapInteractions() {
const m = _getMap(); if (!m) return;
m.dragPan.disable();
m.scrollZoom.disable();
m.boxZoom.disable();
m.doubleClickZoom.disable();
m.touchZoomRotate.disable();
}
function enableMapInteractions() {
const m = _getMap(); if (!m) return;
m.dragPan.enable();
m.scrollZoom.enable();
m.boxZoom.enable();
// Note: doubleClickZoom is intentionally NOT re-enabled — the native site disables it
m.touchZoomRotate.enable();
}
function resetSelectionMode() {
isSelectionModeActive = false;
isDragging = false;
selectionStart = null;
selectionEnd = null;
if (screenshotButton) {
screenshotButton.style.backgroundColor = 'white';
screenshotButton.style.color = 'black';
screenshotButton.style.boxShadow = '';
}
document.body.style.cursor = '';
enableMapInteractions();
clearSelectionCanvas();
}
function clearSelectionCanvas() {
if (selectionCtx && selectionCanvas) {
selectionCtx.clearRect(0, 0, window.innerWidth, window.innerHeight);
}
}
// ==================== EVENT HANDLERS ====================
function setupEventListeners() {
document.addEventListener('mousedown', handleMouseDown);
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
document.addEventListener('keydown', handleKeyDown);
}
function handleMouseDown(e) {
if (!isSelectionModeActive) return;
if (e.button !== 0) return;
if (
e.target.closest('#controls-left') ||
e.target.closest('#controls-right') ||
e.target.closest('.rsc-modal-container')
) return;
isDragging = true;
selectionStart = screenPointToGrid(e.clientX, e.clientY);
selectionEnd = selectionStart;
e.preventDefault();
e.stopPropagation();
}
function handleMouseMove(e) {
if (!isDragging || !selectionStart) return;
selectionEnd = screenPointToGrid(e.clientX, e.clientY);
drawSelectionPreview();
}
async function handleMouseUp(e) {
if (!isDragging || !selectionStart || !selectionEnd) return;
isDragging = false;
const bounds = getSelectionBounds();
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
if (width < 2 || height < 2) {
showNotification('Selection too small — please drag a larger area.');
clearSelectionCanvas();
resetSelectionMode();
return;
}
if (width * height > MAX_REGION_PIXELS) {
showNotification(`Region too large (${width}×${height}). Maximum is ~4000×4000 px.`);
clearSelectionCanvas();
resetSelectionMode();
return;
}
// Exit selection mode
isSelectionModeActive = false;
if (screenshotButton) {
screenshotButton.style.backgroundColor = 'white';
screenshotButton.style.color = 'black';
screenshotButton.style.boxShadow = '';
}
document.body.style.cursor = '';
enableMapInteractions();
// Show loading modal
const modal = createPreviewModal(bounds, null, true);
const progressEl = modal.querySelector('.rsc-progress-text');
const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
try {
const screenshotCanvas = await renderRegionToCanvas(bounds, updateProgress);
updatePreviewModal(modal, bounds, screenshotCanvas);
} catch (err) {
console.error('[Region Screenshot] Error:', err);
showNotification('Error capturing screenshot: ' + err.message);
modal.closeModal();
}
clearSelectionCanvas();
selectionStart = null;
selectionEnd = null;
}
function handleKeyDown(e) {
if (e.key === 'Escape' && (isSelectionModeActive || isDragging)) {
resetSelectionMode();
}
}
// ==================== COORDINATE HELPERS ====================
function screenPointToGrid(clientX, clientY) {
const mapContainer = _map.getContainer();
const rect = mapContainer.getBoundingClientRect();
const lngLat = _map.unproject([clientX - rect.left, clientY - rect.top]);
const merc = turf.toMercator([lngLat.lng, lngLat.lat]);
return {
gridX: Math.round(merc[0] / GRID_SIZE),
gridY: Math.round(merc[1] / GRID_SIZE),
};
}
function gridToScreen(gridX, gridY) {
const lngLat = turf.toWgs84([gridX * GRID_SIZE, gridY * GRID_SIZE]);
const point = _map.project(lngLat);
const rect = _map.getContainer().getBoundingClientRect();
return { x: point.x + rect.left, y: point.y + rect.top };
}
function getSelectionBounds() {
return {
minX: Math.min(selectionStart.gridX, selectionEnd.gridX),
maxX: Math.max(selectionStart.gridX, selectionEnd.gridX),
minY: Math.min(selectionStart.gridY, selectionEnd.gridY),
maxY: Math.max(selectionStart.gridY, selectionEnd.gridY),
};
}
// ==================== SELECTION DRAWING ====================
function drawSelectionPreview() {
clearSelectionCanvas();
if (!selectionStart || !selectionEnd) return;
const bounds = getSelectionBounds();
const topLeft = gridToScreen(bounds.minX - 0.5, bounds.maxY + 0.5);
const bottomRight = gridToScreen(bounds.maxX + 0.5, bounds.minY - 0.5);
const x = topLeft.x;
const y = topLeft.y;
const w = bottomRight.x - topLeft.x;
const h = bottomRight.y - topLeft.y;
selectionCtx.fillStyle = SELECTION_COLOR;
selectionCtx.fillRect(x, y, w, h);
selectionCtx.strokeStyle = SELECTION_BORDER_COLOR;
selectionCtx.lineWidth = 2;
selectionCtx.setLineDash([6, 3]);
selectionCtx.strokeRect(x, y, w, h);
selectionCtx.setLineDash([]);
const selW = bounds.maxX - bounds.minX + 1;
const selH = bounds.maxY - bounds.minY + 1;
const sizeText = `${selW} × ${selH}`;
selectionCtx.font = 'bold 14px sans-serif';
selectionCtx.textAlign = 'center';
selectionCtx.textBaseline = 'middle';
selectionCtx.lineWidth = 3;
selectionCtx.strokeStyle = 'rgba(0,0,0,0.6)';
selectionCtx.strokeText(sizeText, x + w / 2, y + h / 2);
selectionCtx.fillStyle = 'white';
selectionCtx.fillText(sizeText, x + w / 2, y + h / 2);
}
// ==================== SCREENSHOT RENDERING ====================
async function renderRegionToCanvas(bounds, updateProgress) {
const { minX, maxX, minY, maxY } = bounds;
const outWidth = maxX - minX + 1;
const outHeight = maxY - minY + 1;
// Output canvas — transparent background, 1px = 1 grid cell
const outputCanvas = new OffscreenCanvas(outWidth, outHeight);
const outputCtx = outputCanvas.getContext('2d');
outputCtx.clearRect(0, 0, outWidth, outHeight);
// Find all tiles that overlap with the selection
const startTileX = Math.floor(minX / TILE_SIZE) * TILE_SIZE;
const endTileX = Math.floor(maxX / TILE_SIZE) * TILE_SIZE;
const startTileY = Math.floor(minY / TILE_SIZE) * TILE_SIZE;
const endTileY = Math.floor(maxY / TILE_SIZE) * TILE_SIZE;
const neededTiles = [];
for (let tx = startTileX; tx <= endTileX; tx += TILE_SIZE) {
for (let ty = startTileY; ty <= endTileY; ty += TILE_SIZE) {
neededTiles.push([tx, ty]);
}
}
console.log(`[Region Screenshot] ${neededTiles.length} tile(s) needed for ${outWidth}×${outHeight} region`);
let processed = 0;
for (const [tileX, tileY] of neededTiles) {
processed++;
const tileKey = `${tileX},${tileY}`;
updateProgress && updateProgress(`Processing tile ${processed}/${neededTiles.length}…`);
// Yield to browser
await new Promise(r => setTimeout(r, 0));
// ---- Try cache first ----
let colorBitmap = null;
let deltas = null;
const cached = tileImageCache.get(tileKey);
if (cached) {
colorBitmap = cached.colorBitmap || null;
deltas = cached.deltas || null;
}
// ---- Fallback: fetch from API ----
if (!colorBitmap) {
updateProgress && updateProgress(`Fetching tile ${tileKey} from server…`);
try {
const fetched = await fetchTileColorBitmap(tileX, tileY);
colorBitmap = fetched.colorBitmap;
deltas = fetched.deltas;
} catch (err) {
console.warn(`[Region Screenshot] Could not load tile ${tileKey}:`, err);
continue;
}
}
if (!colorBitmap) continue;
// ---- Compute overlapping region in tile local coords ----
const tileMinX = Math.max(minX, tileX);
const tileMaxX = Math.min(maxX, tileX + TILE_SIZE - 1);
const tileMinY = Math.max(minY, tileY);
const tileMaxY = Math.min(maxY, tileY + TILE_SIZE - 1);
const regionW = tileMaxX - tileMinX + 1;
const regionH = tileMaxY - tileMinY + 1;
if (regionW <= 0 || regionH <= 0) continue;
const localStartX = tileMinX - tileX;
const localStartY = tileMinY - tileY;
// Destination position on output canvas
const destX = tileMinX - minX;
const destY = tileMinY - minY;
// Draw this tile's color section onto the output canvas
outputCtx.drawImage(
colorBitmap,
localStartX, localStartY, regionW, regionH,
destX, destY, regionW, regionH
);
// ---- Apply any recent in-memory deltas on top ----
// These are pixel updates that have arrived since the last full sync
// and may not yet be baked into the colorBitmap.
if (deltas && deltas.length > 0) {
for (const delta of deltas) {
const gx = delta.gridX;
const gy = delta.gridY;
// Skip if outside this tile's contributing region
if (gx < tileMinX || gx > tileMaxX || gy < tileMinY || gy > tileMaxY) continue;
const ox = gx - minX;
const oy = gy - minY;
if (delta.color === '#00000000' || delta.color === null) {
// Erased pixel — clear it
outputCtx.clearRect(ox, oy, 1, 1);
} else if (delta.color) {
outputCtx.fillStyle = delta.color;
outputCtx.fillRect(ox, oy, 1, 1);
}
}
}
}
updateProgress && updateProgress('Finalizing…');
// Transfer to a regular (main-thread) canvas so we can export it.
// Flip vertically: grid Y increases northward but canvas Y increases downward,
// so without a flip the image is upside-down.
const regularCanvas = document.createElement('canvas');
regularCanvas.width = outWidth;
regularCanvas.height = outHeight;
const regularCtx = regularCanvas.getContext('2d');
regularCtx.save();
regularCtx.translate(0, outHeight);
regularCtx.scale(1, -1);
regularCtx.drawImage(outputCanvas, 0, 0);
regularCtx.restore();
return regularCanvas;
}
// ---- API tile fetch (fallback when not cached) ----
async function fetchTileColorBitmap(tileX, tileY) {
const response = await fetch('https://geopixels.net/GetPixelsCached', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Tiles: [{ x: tileX, y: tileY, timestamp: 0 }] }),
});
if (!response.ok) throw new Error(`API returned ${response.status}`);
const data = await response.json();
const tileKey = `tile_${tileX}_${tileY}`;
const tileInfo = data.Tiles && data.Tiles[tileKey];
if (!tileInfo) return { colorBitmap: null, deltas: null };
// Full tile with WebP
if (tileInfo.Type === 'full' && tileInfo.ColorWebP) {
const colorBitmap = await decodeWebP(tileInfo.ColorWebP);
// Process any bundled deltas
const deltas = buildDeltasFromRaw(tileInfo.Deltas || []);
return { colorBitmap, deltas };
}
// Delta-only tile — build a small bitmap from the delta array
if (tileInfo.Pixels && tileInfo.Pixels.length > 0) {
const colorBitmap = await buildColorBitmapFromDeltas(tileInfo.Pixels, tileX, tileY);
return { colorBitmap, deltas: null };
}
return { colorBitmap: null, deltas: null };
}
async function decodeWebP(base64Data) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = () => createImageBitmap(img).then(resolve).catch(reject);
img.onerror = reject;
img.src = `data:image/webp;base64,${base64Data}`;
});
}
function buildDeltasFromRaw(rawDeltas) {
return rawDeltas.map(p => {
const [gridX, gridY, color] = p;
if (color === -1) return { gridX, gridY, color: null };
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
return { gridX, gridY, color: `rgb(${r},${g},${b})` };
});
}
async function buildColorBitmapFromDeltas(rawDeltas, tileX, tileY) {
const canvas = new OffscreenCanvas(TILE_SIZE, TILE_SIZE);
const ctx = canvas.getContext('2d');
for (const [gridX, gridY, color] of rawDeltas) {
if (color === -1) continue;
const r = (color >> 16) & 0xff;
const g = (color >> 8) & 0xff;
const b = color & 0xff;
ctx.fillStyle = `rgb(${r},${g},${b})`;
ctx.fillRect(gridX - tileX, gridY - tileY, 1, 1);
}
return createImageBitmap(canvas);
}
// ==================== ADJUST BOUNDS MODAL ====================
function showAdjustModal(currentBounds, onConfirm) {
const existing = document.querySelector('.rsc-adjust-overlay');
if (existing) existing.remove();
const overlay = document.createElement('div');
overlay.className = 'rsc-adjust-overlay';
overlay.style.cssText = `
position: fixed; inset: 0; z-index: 10001;
background: rgba(0,0,0,0.55);
display: flex; align-items: center; justify-content: center;
font-family: system-ui, sans-serif;
`;
const box = document.createElement('div');
box.style.cssText = `
background: #1e1e2e; color: #cdd6f4; border-radius: 12px;
box-shadow: 0 16px 48px rgba(0,0,0,0.5);
padding: 24px; min-width: 320px; display: flex; flex-direction: column; gap: 14px;
`;
box.innerHTML = `
<h3 style="margin: 0; font-size: 16px; font-weight: 700; color: #cba6f7;">Adjust Region Bounds</h3>
<div style="display: grid; grid-template-columns: 1fr 1fr; gap: 10px;">
<label style="font-size: 12px; color: #a6adc8;">X1 (min)
<input id="rsc-adj-x1" type="number" value="${currentBounds.minX}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
</label>
<label style="font-size: 12px; color: #a6adc8;">X2 (max)
<input id="rsc-adj-x2" type="number" value="${currentBounds.maxX}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
</label>
<label style="font-size: 12px; color: #a6adc8;">Y1 (min)
<input id="rsc-adj-y1" type="number" value="${currentBounds.minY}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
</label>
<label style="font-size: 12px; color: #a6adc8;">Y2 (max)
<input id="rsc-adj-y2" type="number" value="${currentBounds.maxY}" style="width: 100%; margin-top: 2px; padding: 6px 8px; border-radius: 6px; border: 1px solid #45475a; background: #313244; color: #cdd6f4; font-size: 13px; font-family: monospace;" />
</label>
</div>
<div id="rsc-adj-error" style="font-size: 12px; color: #f38ba8; display: none;"></div>
<div style="display: flex; gap: 10px; justify-content: flex-end;">
<button id="rsc-adj-cancel" style="padding: 8px 16px; border-radius: 8px; border: 1px solid #45475a; background: #313244; color: #a6adc8; cursor: pointer; font-size: 13px;">Cancel</button>
<button id="rsc-adj-confirm" style="padding: 8px 16px; border-radius: 8px; border: none; background: #cba6f7; color: #1e1e2e; cursor: pointer; font-size: 13px; font-weight: 600;">Apply</button>
</div>
`;
overlay.appendChild(box);
document.body.appendChild(overlay);
const close = () => overlay.remove();
overlay.addEventListener('click', (e) => { if (e.target === overlay) close(); });
box.querySelector('#rsc-adj-cancel').onclick = close;
box.querySelector('#rsc-adj-confirm').onclick = () => {
const x1 = parseInt(box.querySelector('#rsc-adj-x1').value);
const x2 = parseInt(box.querySelector('#rsc-adj-x2').value);
const y1 = parseInt(box.querySelector('#rsc-adj-y1').value);
const y2 = parseInt(box.querySelector('#rsc-adj-y2').value);
const errEl = box.querySelector('#rsc-adj-error');
if ([x1, x2, y1, y2].some(isNaN)) {
errEl.textContent = 'All fields must be valid numbers.';
errEl.style.display = 'block';
return;
}
const newBounds = {
minX: Math.min(x1, x2), maxX: Math.max(x1, x2),
minY: Math.min(y1, y2), maxY: Math.max(y1, y2),
};
const w = newBounds.maxX - newBounds.minX + 1;
const h = newBounds.maxY - newBounds.minY + 1;
if (w < 2 || h < 2) {
errEl.textContent = 'Region must be at least 2×2.';
errEl.style.display = 'block';
return;
}
if (w * h > MAX_REGION_PIXELS) {
errEl.textContent = `Region too large (${w}×${h}).`;
errEl.style.display = 'block';
return;
}
close();
onConfirm(newBounds);
};
const escH = (e) => { if (e.key === 'Escape') { close(); document.removeEventListener('keydown', escH); } };
document.addEventListener('keydown', escH);
}
async function rerunScreenshot(newBounds) {
// Remove any existing modal
const existing = document.querySelector('.rsc-modal-container');
if (existing) existing.remove();
const modal = createPreviewModal(newBounds, null, true);
const progressEl = modal.querySelector('.rsc-progress-text');
const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
try {
const screenshotCanvas = await renderRegionToCanvas(newBounds, updateProgress);
updatePreviewModal(modal, newBounds, screenshotCanvas);
} catch (err) {
console.error('[Region Screenshot] Error:', err);
showNotification('Error capturing screenshot: ' + err.message);
modal.closeModal();
}
}
// ==================== MODAL ====================
function createPreviewModal(bounds, screenshotCanvas, loading = false) {
// Remove any existing modal
const existing = document.querySelector('.rsc-modal-container');
if (existing) existing.remove();
const w = bounds.maxX - bounds.minX + 1;
const h = bounds.maxY - bounds.minY + 1;
// ---- Overlay ----
const overlay = document.createElement('div');
overlay.className = 'rsc-modal-container';
overlay.style.cssText = `
position: fixed;
inset: 0;
z-index: 10000;
background: rgba(0, 0, 0, 0.6);
display: flex;
align-items: center;
justify-content: center;
`;
// ---- Modal box ----
const modal = document.createElement('div');
modal.className = 'rsc-modal';
modal.style.cssText = `
position: relative;
background: #1e1e2e;
color: #cdd6f4;
border-radius: 14px;
box-shadow: 0 24px 60px rgba(0,0,0,0.55);
padding: 24px;
min-width: 380px;
max-width: min(90vw, 700px);
max-height: 90vh;
display: flex;
flex-direction: column;
gap: 16px;
font-family: system-ui, sans-serif;
`;
// ---- Header ----
const header = document.createElement('div');
header.style.cssText = 'display: flex; align-items: flex-start; justify-content: space-between; gap: 12px;';
header.innerHTML = `
<div>
<h2 style="margin: 0 0 6px 0; font-size: 20px; font-weight: 700; color: #cba6f7;">📷 Region Screenshot</h2>
<p style="margin: 0; font-size: 13px; color: #a6adc8;">${w} × ${h} px | X: ${bounds.minX} → ${bounds.maxX} | Y: ${bounds.minY} → ${bounds.maxY}</p>
</div>
`;
const adjustBtn = document.createElement('button');
adjustBtn.textContent = 'Adjust…';
adjustBtn.style.cssText = `
flex-shrink: 0; padding: 5px 12px; border-radius: 6px; border: 1px solid #45475a;
background: #313244; color: #a6adc8; font-size: 12px; cursor: pointer;
transition: background 0.15s; white-space: nowrap;
`;
adjustBtn.onmouseover = () => { adjustBtn.style.background = '#45475a'; };
adjustBtn.onmouseout = () => { adjustBtn.style.background = '#313244'; };
adjustBtn.onclick = () => {
showAdjustModal(bounds, (newBounds) => {
rerunScreenshot(newBounds);
});
};
header.insertBefore(adjustBtn, null);
const closeBtn = document.createElement('button');
closeBtn.innerHTML = '✕';
closeBtn.style.cssText = `
flex-shrink: 0;
background: #313244;
border: none;
color: #a6adc8;
font-size: 16px;
width: 32px;
height: 32px;
border-radius: 8px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: background 0.15s;
`;
closeBtn.onmouseover = () => { closeBtn.style.background = '#45475a'; };
closeBtn.onmouseout = () => { closeBtn.style.background = '#313244'; };
header.appendChild(closeBtn);
// ---- Content area ----
const content = document.createElement('div');
content.className = 'rsc-modal-content';
content.style.cssText = `
flex: 1;
overflow: auto;
display: flex;
flex-direction: column;
gap: 14px;
`;
if (loading) {
content.innerHTML = `
<div style="display: flex; flex-direction: column; align-items: center; justify-content: center;
min-height: 180px; color: #a6adc8; gap: 12px;">
<div style="font-size: 36px;">⏳</div>
<div class="rsc-progress-text" style="font-size: 14px;">Preparing screenshot…</div>
<div style="font-size: 12px; color: #6c7086;">Large regions may take a moment</div>
</div>
`;
} else if (screenshotCanvas) {
buildPreviewContent(content, bounds, screenshotCanvas);
}
// ---- Assemble ----
modal.appendChild(header);
modal.appendChild(content);
overlay.appendChild(modal);
document.body.appendChild(overlay);
const closeModal = () => overlay.remove();
closeBtn.onclick = closeModal;
overlay.onclick = (e) => { if (e.target === overlay) closeModal(); };
const escHandler = (e) => {
if (e.key === 'Escape') { closeModal(); document.removeEventListener('keydown', escHandler); }
};
document.addEventListener('keydown', escHandler);
modal.closeModal = closeModal;
return modal;
}
function updatePreviewModal(modal, bounds, screenshotCanvas) {
const content = modal.querySelector('.rsc-modal-content');
if (!content) return;
content.innerHTML = '';
buildPreviewContent(content, bounds, screenshotCanvas);
}
function buildPreviewContent(container, bounds, canvas) {
const w = bounds.maxX - bounds.minX + 1;
const h = bounds.maxY - bounds.minY + 1;
// ---- Checkerboard preview wrapper (shows transparency) ----
const previewWrapper = document.createElement('div');
previewWrapper.style.cssText = `
border-radius: 10px;
overflow: hidden;
max-height: 55vh;
background: repeating-conic-gradient(#313244 0% 25%, #45475a 0% 50%) 0 0 / 16px 16px;
display: flex;
align-items: center;
justify-content: center;
border: 2px solid #45475a;
`;
const imgEl = document.createElement('img');
imgEl.style.cssText = `
max-width: 100%;
max-height: 55vh;
image-rendering: pixelated;
object-fit: contain;
`;
imgEl.src = canvas.toDataURL('image/png');
previewWrapper.appendChild(imgEl);
container.appendChild(previewWrapper);
// ---- Info row ----
const info = document.createElement('p');
info.style.cssText = 'margin: 0; font-size: 12px; color: #6c7086; text-align: center;';
info.textContent = `${w}×${h} pixels • PNG with transparent background`;
container.appendChild(info);
// ---- Buttons ----
const btnRow = document.createElement('div');
btnRow.style.cssText = 'display: flex; gap: 10px; justify-content: stretch;';
// Download button
const downloadBtn = document.createElement('button');
downloadBtn.innerHTML = '⬇ Download PNG';
downloadBtn.style.cssText = `
flex: 1;
padding: 10px 16px;
background: #cba6f7;
color: #1e1e2e;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
`;
downloadBtn.onmouseover = () => { downloadBtn.style.opacity = '0.85'; };
downloadBtn.onmouseout = () => { downloadBtn.style.opacity = '1'; };
downloadBtn.onclick = () => {
const link = document.createElement('a');
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `geopixels-screenshot-${ts}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
};
// Copy button
const copyBtn = document.createElement('button');
copyBtn.innerHTML = '📋 Copy to Clipboard';
copyBtn.style.cssText = `
flex: 1;
padding: 10px 16px;
background: #89dceb;
color: #1e1e2e;
border: none;
border-radius: 8px;
font-size: 14px;
font-weight: 600;
cursor: pointer;
transition: opacity 0.15s;
`;
copyBtn.onmouseover = () => { copyBtn.style.opacity = '0.85'; };
copyBtn.onmouseout = () => { copyBtn.style.opacity = '1'; };
copyBtn.onclick = () => {
if (!navigator.clipboard || !window.ClipboardItem) {
showNotification('Clipboard API not supported in this browser.');
return;
}
canvas.toBlob(async (blob) => {
try {
await navigator.clipboard.write([new ClipboardItem({ 'image/png': blob })]);
copyBtn.innerHTML = '✅ Copied!';
setTimeout(() => { copyBtn.innerHTML = '📋 Copy to Clipboard'; }, 2000);
} catch (err) {
console.error('[Region Screenshot] Clipboard write failed:', err);
showNotification('Could not write to clipboard: ' + err.message);
}
}, 'image/png');
};
btnRow.appendChild(downloadBtn);
btnRow.appendChild(copyBtn);
container.appendChild(btnRow);
}
// ==================== NOTIFICATIONS ====================
function showNotification(message) {
if (typeof showAnnouncement === 'function') {
showAnnouncement(message);
return;
}
const notification = document.createElement('div');
notification.style.cssText = `
position: fixed;
top: 20px;
left: 50%;
transform: translateX(-50%);
background: #313244;
color: #cdd6f4;
padding: 12px 24px;
border-radius: 10px;
z-index: 10002;
font-size: 14px;
box-shadow: 0 4px 16px rgba(0,0,0,0.4);
font-family: system-ui, sans-serif;
max-width: 400px;
text-align: center;
`;
notification.textContent = message;
document.body.appendChild(notification);
setTimeout(() => {
notification.style.transition = 'opacity 0.3s';
notification.style.opacity = '0';
setTimeout(() => notification.remove(), 300);
}, 3500);
}
// ==================== PROCESS WITH BOUNDS (for flyout) =========
async function processWithBounds(bounds) {
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
if (width < 2 || height < 2) { showNotification('Selection too small — please select a larger area.'); return; }
if (width * height > MAX_REGION_PIXELS) { showNotification(`Region too large (${width}×${height}). Maximum is ~4000×4000 px.`); return; }
const modal = createPreviewModal(bounds, null, true);
const progressEl = modal.querySelector('.rsc-progress-text');
const updateProgress = (text) => { if (progressEl) progressEl.textContent = text; };
try {
const screenshotCanvas = await renderRegionToCanvas(bounds, updateProgress);
updatePreviewModal(modal, bounds, screenshotCanvas);
} catch (err) {
console.error('[Region Screenshot] Error:', err);
showNotification('Error capturing screenshot: ' + err.message);
try { modal.closeModal(); } catch {}
}
}
async function silentDownload(bounds) {
const width = bounds.maxX - bounds.minX + 1;
const height = bounds.maxY - bounds.minY + 1;
if (width < 2 || height < 2 || width * height > MAX_REGION_PIXELS) return;
try {
const canvas = await renderRegionToCanvas(bounds, () => {});
const link = document.createElement('a');
const ts = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
link.download = `geopixels-screenshot-${ts}.png`;
link.href = canvas.toDataURL('image/png');
link.click();
} catch (err) {
console.error('[Region Screenshot] Silent download error:', err);
}
}
// ==================== START ====================
init();
// Expose API for flyout
_regionScreenshot = { processWithBounds, toggleSelectionMode, silentDownload };
})();
_featureStatus.regionScreenshot = 'ok';
console.log('[GeoPixelcons++] \u2705 Region Screenshot loaded');
} catch (err) {
_featureStatus.regionScreenshot = 'error';
console.error('[GeoPixelcons++] ❌ Region Screenshot failed:', err);
}
}
// ============================================================
// FEATURE: Bulk Purchase Colors [bulkPurchaseColors]
// ============================================================
if (_settings.bulkPurchaseColors) {
try {
(function _init_bulkPurchaseColors() {
// ─── Constants ────────────────────────────────────────────────────────────────
const PIXELS_PER_COLOR = 100; // Informational cost shown in the preview
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
// ─── Dark mode detection (geopixels++ compatibility) ──────────────────────────
function isDarkMode() {
return getComputedStyle(document.documentElement).colorScheme === 'dark';
}
function t() {
const dark = isDarkMode();
return {
panelBg: dark ? '#1e2939' : '#fff',
text: dark ? '#f3f4f6' : '#1f2937',
textMed: dark ? '#e5e7eb' : '#374151',
textSec: dark ? '#d1d5db' : '#6b7280',
textMuted: dark ? '#99a1af' : '#9ca3af',
textOwned: dark ? '#6a7282' : '#b0b0b0',
border: dark ? '#364153' : '#e5e7eb',
borderLight: dark ? '#364153' : '#ececec',
inputBorder: dark ? '#4a5565' : '#d1d5db',
rowBg: dark ? '#101828' : '#fff',
rowOwnedBg: dark ? '#1e2939' : '#f3f4f6',
sepBg: dark ? '#101828' : '#f9fafb',
progressBg: dark ? '#364153' : '#e5e7eb',
cancelBg: dark ? '#364153' : '#e5e7eb',
cancelText: dark ? '#e5e7eb' : '#374151',
closeBg: dark ? '#364153' : '#f3f4f6',
closeText: dark ? '#d1d5db' : '#6b7280',
queueBg: dark ? '#101828' : '#fff',
queueBorder: dark ? '#364153' : '#e5e7eb',
};
}
// ─── Credential access ────────────────────────────────────────────────────────
//
// The page declares `tokenUser`, `userID`, and `subject` with `let` in
// index121.js. Top-level `let` is NOT a property of `window`, and
// _pw.eval() cannot reach them either (different script scope).
//
// Solution: inject a <script> tag that registers a live getter on
// window._gpAuth from within the page's own global scope.
//
(function installAuthBridge() {
const s = document.createElement('script');
s.textContent = `
Object.defineProperty(window, '_gpAuth', {
configurable: true,
get: function() {
return {
token: typeof tokenUser !== 'undefined' ? tokenUser : null,
userId: typeof userID !== 'undefined' ? userID : null,
subject: typeof subject !== 'undefined' ? subject : null,
};
}
});
`;
(document.head || document.documentElement).appendChild(s);
s.remove();
})();
/** Return auth credentials, or null if the user is not yet logged in. */
function getAuth() {
const a = _pw._gpAuth;
const token = (a && a.token) || localStorage.getItem('tokenUser');
const userId = (a && a.userId != null) ? a.userId : parseInt(localStorage.getItem('userID') || '', 10);
const subject = (a && a.subject) || '';
if (!token || isNaN(userId)) return null;
return { token, userId, subject };
}
// ─── Color sanitization ───────────────────────────────────────────────────────
/**
* Normalise a single raw token to an uppercase "#RRGGBB" string.
* Returns null for anything that cannot be interpreted as a valid RGB colour.
*
* Accepted formats:
* - Hex with hash: #FF0000
* - Hex without hash: FF0000
* - 3-digit shorthand:#F00 / F00
* - Decimal integer: 16711680
*/
function sanitizeToken(token) {
// Strip surrounding quotes that may appear in copy-pasted strings
token = (token || '').trim().replace(/^["'`]+|["'`]+$/g, '').trim();
if (!token) return null;
// Pure decimal integer (digits only, no a-f)
if (/^\d+$/.test(token)) {
const n = parseInt(token, 10);
if (n < 0 || n > 0xFFFFFF) return null;
return '#' + n.toString(16).toUpperCase().padStart(6, '0');
}
const stripped = token.replace(/^#/, '');
// 6-digit hex
if (/^[0-9A-Fa-f]{6}$/.test(stripped)) {
return '#' + stripped.toUpperCase();
}
// 3-digit shorthand → expand to 6-digit
if (/^[0-9A-Fa-f]{3}$/.test(stripped)) {
const expanded = stripped.split('').map(c => c + c).join('');
return '#' + expanded.toUpperCase();
}
return null;
}
/**
* Split raw textarea input (comma-, space-, or newline-separated) into
* sanitized, deduplicated colour strings. Returns { valid, invalid }.
*/
function parseColorInput(raw) {
const tokens = (raw || '').split(/[\s,\n]+/).filter(Boolean);
const seen = new Set();
const valid = [];
const invalid = [];
for (const t of tokens) {
const c = sanitizeToken(t);
if (c) {
if (!seen.has(c)) { seen.add(c); valid.push(c); }
} else {
invalid.push(t);
}
}
return { valid, invalid };
}
// ─── Owned-colour helpers ─────────────────────────────────────────────────────
/**
* Build a Set of uppercase "#RRGGBB" hex strings from window.Colors,
* which the site keeps in sync with the authenticated user's colour list.
*/
function buildOwnedSet() {
const set = new Set();
// `Colors` is a top-level `let` in the page script. With @grant none it is
// accessible as a bare name in most environments; use try/catch as guard.
let colors;
try { colors = Colors; } catch (_) { colors = window.Colors; }
if (Array.isArray(colors)) {
colors.forEach(h => {
if (h && typeof h === 'string') set.add(h.toUpperCase());
});
}
return set;
}
/**
* Fetch a fresh copy of the user's data from the server and return a Set
* of owned hex strings. Falls back to window.Colors on any error.
*/
async function fetchOwnedHexSet() {
const auth = getAuth();
if (!auth) {
console.warn('[BulkPurchase] Credentials not yet captured, falling back to local Colors.');
return buildOwnedSet();
}
try {
const resp = await window.fetch('/GetUserData', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ userId: auth.userId, token: auth.token }),
});
if (!resp.ok) throw new Error(`HTTP ${resp.status}`);
const data = await resp.json();
const set = new Set();
const raw = data.colors;
// Server returns a comma-separated decimal string, e.g. "16777215, 0, 65280"
if (typeof raw === 'string') {
raw.split(',').forEach(s => {
const n = parseInt(s.trim(), 10);
// n >= 0 deliberately includes 0 (== #000000)
if (!isNaN(n) && n >= 0 && n <= 0xFFFFFF) {
set.add('#' + n.toString(16).toUpperCase().padStart(6, '0'));
}
});
} else if (Array.isArray(raw)) {
raw.forEach(n => {
if (typeof n === 'number' && n >= 0 && n <= 0xFFFFFF) {
set.add('#' + n.toString(16).toUpperCase().padStart(6, '0'));
}
});
}
// Merge local Colors as belt-and-suspenders
buildOwnedSet().forEach(h => set.add(h));
return set;
} catch (err) {
console.warn('[BulkPurchase] GetUserData failed, falling back to local Colors:', err);
return buildOwnedSet();
}
}
// ─── Local hex → integer helper ───────────────────────────────────────────────
/** Convert "#RRGGBB" to its integer equivalent. */
function hexToInt(hex) {
return parseInt(hex.replace(/^#/, ''), 16);
}
// ─── Ghost palette DOM reader ─────────────────────────────────────────────────
/**
* Extract unique hex colours from the rendered #ghostColorPalette buttons.
*
* Two strategies, tried in order per-button:
* 1. data-color-rgba="rgba(R,G,B,1)" — set by ghost22.js, always present
* 2. title first-line — also set by ghost22.js, may vary in format
*
* Using `data-color-rgba` as primary avoids any dependency on the title format,
* which has changed across script versions (2-line, 3-line, etc.).
*/
function getGhostColorsFromDOM() {
const swatches = document.querySelectorAll('#ghostColorPalette button[data-color-rgba], #ghostColorPalette button[title]');
const seen = new Set();
const colors = [];
swatches.forEach(btn => {
let hex = null;
// Strategy 1: parse data-color-rgba="rgba(R,G,B,1)"
const rgba = btn.dataset.colorRgba;
if (rgba) {
const m = rgba.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (m) {
hex = '#' +
parseInt(m[1]).toString(16).toUpperCase().padStart(2, '0') +
parseInt(m[2]).toString(16).toUpperCase().padStart(2, '0') +
parseInt(m[3]).toString(16).toUpperCase().padStart(2, '0');
}
}
// Strategy 2: title first line (e.g. "#D5BFB2" or "#D5BFB2\n…")
if (!hex && btn.title) {
const candidate = btn.title.split(/[\r\n]+/)[0].trim().toUpperCase();
if (/^#[0-9A-F]{6}$/.test(candidate)) hex = candidate;
}
if (hex && /^#[0-9A-F]{6}$/.test(hex) && !seen.has(hex)) {
seen.add(hex);
colors.push(hex);
}
});
return colors;
}
// ─── Confirmation / preview modal ─────────────────────────────────────────────
/** The single shared overlay element (created once and reused). */
let _bulkOverlay = null;
/** Original ordered list passed to openBulkModal (preserved for results display). */
let _pendingColors = [];
/** Per-status visual style config. */
function getStatusStyles() {
const dark = isDarkMode();
return {
pending: { label: '', bg: dark ? '#101828' : '#f9fafb', border: dark ? '#364153' : '#e5e7eb', textColor: dark ? '#6a7282' : '#9ca3af' },
owned: { label: 'Already Owned', bg: dark ? '#422006' : '#fefce8', border: dark ? '#a16207' : '#fde047', textColor: dark ? '#fbbf24' : '#92400e' },
purchased: { label: 'Purchased ✓', bg: dark ? '#052e16' : '#f0fdf4', border: dark ? '#16a34a' : '#86efac', textColor: dark ? '#4ade80' : '#166534' },
failed: { label: 'Failed', bg: dark ? '#450a0a' : '#fef2f2', border: dark ? '#dc2626' : '#fca5a5', textColor: dark ? '#f87171' : '#991b1b' },
skipped: { label: 'Skipped (Insufficient Pixels)', bg: dark ? '#0f172a' : '#f1f5f9', border: dark ? '#475569' : '#cbd5e1', textColor: dark ? '#94a3b8' : '#64748b' },
};
}
function buildColorRow(hex, status) {
const STATUS_STYLES = getStatusStyles();
const s = STATUS_STYLES[status] || STATUS_STYLES.pending;
const c = t();
const row = document.createElement('div');
row.dataset.gpColor = hex;
row.style.cssText = `display:flex;align-items:center;gap:0.75rem;padding:0.5rem 0.75rem;` +
`background:${s.bg};border:1px solid ${s.border};border-radius:0.5rem;`;
const swatch = document.createElement('div');
swatch.style.cssText = `width:1.75rem;height:1.75rem;border-radius:0.25rem;border:1px solid ${c.inputBorder};flex-shrink:0;background:${hex};`;
const hexLabel = document.createElement('span');
hexLabel.style.cssText = `font-family:monospace;font-size:0.875rem;color:${c.textMed};flex:1;`;
hexLabel.textContent = hex;
const badge = document.createElement('span');
badge.className = 'gp-row-badge';
badge.style.cssText = `font-size:0.7rem;font-weight:600;color:${s.textColor};white-space:nowrap;`;
badge.textContent = status === 'pending' ? `${PIXELS_PER_COLOR} px` : s.label;
row.appendChild(swatch);
row.appendChild(hexLabel);
row.appendChild(badge);
return row;
}
function updateColorRow(hex, status) {
// Scoped to the modal list only — queue rows use data-gp-queue-color
const list = document.getElementById('gp-bulk-list');
if (!list) return;
const row = list.querySelector(`[data-gp-color="${hex}"]`);
if (!row) return;
const STATUS_STYLES = getStatusStyles();
const s = STATUS_STYLES[status] || STATUS_STYLES.pending;
row.style.background = s.bg;
row.style.borderColor = s.border;
const badge = row.querySelector('.gp-row-badge');
if (badge) { badge.textContent = s.label; badge.style.color = s.textColor; }
}
function ensureBulkModal() {
if (_bulkOverlay) return;
const c = t();
_bulkOverlay = document.createElement('div');
_bulkOverlay.id = 'gp-bulk-overlay';
// Overlay sits above everything — including z-50 profile panel and z-40 ghost modal
_bulkOverlay.style.cssText =
'position:fixed;inset:0;z-index:10000;display:none;align-items:center;justify-content:center;background:rgba(0,0,0,0.55);';
_bulkOverlay.innerHTML = `
<div id="gp-bulk-panel"
style="background:${c.panelBg};color:${c.text};border-radius:1rem;box-shadow:0 20px 60px rgba(0,0,0,0.3);width:90%;max-width:28rem;max-height:85vh;display:flex;flex-direction:column;padding:1.5rem;gap:1rem;overflow:hidden;">
<!-- Header -->
<div style="display:flex;align-items:center;justify-content:space-between;flex-shrink:0;">
<h2 id="gp-bulk-title" style="margin:0;font-size:1.25rem;font-weight:700;color:${c.text};">Bulk Purchase Preview</h2>
<button id="gp-bulk-close"
style="width:2rem;height:2rem;border-radius:50%;border:none;background:${c.closeBg};cursor:pointer;font-size:1rem;display:flex;align-items:center;justify-content:center;color:${c.closeText};"
title="Close">\u2715</button>
</div>
<!-- Subtitle -->
<p id="gp-bulk-subtitle" style="margin:0;font-size:0.85rem;color:${c.textSec};flex-shrink:0;"></p>
<!-- Colour list (all colors in original order, owned grayed out) -->
<div id="gp-bulk-list"
style="flex:1 1 auto;overflow-y:auto;display:flex;flex-direction:column;gap:0.5rem;padding-right:0.25rem;min-height:0;"></div>
<!-- Progress bar (shown during purchase) -->
<div id="gp-bulk-progress-wrap"
style="flex-shrink:0;display:none;">
<div style="width:100%;height:0.75rem;background:${c.progressBg};border-radius:9999px;overflow:hidden;">
<div id="gp-bulk-progress-bar"
style="height:100%;width:0%;background:#3b82f6;border-radius:9999px;transition:width 0.2s ease;"></div>
</div>
<p id="gp-bulk-progress-text"
style="margin:0.25rem 0 0;font-size:0.75rem;color:${c.textSec};text-align:center;"></p>
</div>
<!-- Action buttons -->
<div style="display:flex;gap:0.75rem;flex-shrink:0;">
<button id="gp-bulk-cancel"
style="flex:1;padding:0.5rem 1rem;background:${c.cancelBg};border:none;border-radius:0.5rem;font-weight:600;cursor:pointer;font-size:0.9rem;color:${c.cancelText};">
Cancel
</button>
<button id="gp-bulk-confirm"
style="flex:1;padding:0.5rem 1rem;background:#3b82f6;color:#fff;border:none;border-radius:0.5rem;font-weight:600;cursor:pointer;font-size:0.9rem;">
Purchase All
</button>
</div>
</div>`;
document.body.appendChild(_bulkOverlay);
document.getElementById('gp-bulk-close').addEventListener('click', closeBulkModal);
document.getElementById('gp-bulk-cancel').addEventListener('click', closeBulkModal);
_bulkOverlay.addEventListener('click', e => { if (e.target === _bulkOverlay) closeBulkModal(); });
document.getElementById('gp-bulk-confirm').addEventListener('click', onBulkConfirm);
}
/**
* Open the preview modal for a given list of "#RRGGBB" hex strings.
* All colors are shown in original order; already-owned ones get an
* "Already Owned" badge and are non-destructively skipped on confirm.
*/
function openBulkModal(colors) {
ensureBulkModal();
_pendingColors = colors;
const ownedSet = buildOwnedSet();
const toBuyCount = colors.filter(c => !ownedSet.has(c)).length;
const ownedCount = colors.length - toBuyCount;
// --- Populate list (original order, owned shown in-place) ---
const list = document.getElementById('gp-bulk-list');
list.innerHTML = '';
colors.forEach(hex => {
list.appendChild(buildColorRow(hex, ownedSet.has(hex) ? 'owned' : 'pending'));
});
// --- Header / subtitle ---
document.getElementById('gp-bulk-title').textContent = 'Bulk Purchase Preview';
const parts = [`${toBuyCount} to purchase · est. ${(toBuyCount * PIXELS_PER_COLOR).toLocaleString()} Pixels`];
if (ownedCount > 0) parts.push(`${ownedCount} already owned (will skip)`);
document.getElementById('gp-bulk-subtitle').textContent = parts.join(' · ');
// --- Reset progress bar ---
document.getElementById('gp-bulk-progress-wrap').style.display = 'none';
document.getElementById('gp-bulk-progress-bar').style.width = '0%';
document.getElementById('gp-bulk-progress-text').textContent = '';
// --- Reset action buttons ---
const confirmBtn = document.getElementById('gp-bulk-confirm');
const cancelBtn = document.getElementById('gp-bulk-cancel');
confirmBtn.style.display = '';
confirmBtn.textContent = 'Purchase All';
confirmBtn.disabled = toBuyCount === 0;
confirmBtn.style.opacity = toBuyCount === 0 ? '0.5' : '1';
confirmBtn.style.cursor = toBuyCount === 0 ? 'not-allowed' : 'pointer';
cancelBtn.textContent = 'Cancel';
cancelBtn.disabled = false;
_bulkOverlay.style.display = 'flex';
}
function closeBulkModal() {
if (_bulkOverlay) _bulkOverlay.style.display = 'none';
_pendingColors = [];
// Sync the profile card queue now that the modal is gone
if (document.getElementById('gp-bulk-queue-list')) refreshColorQueue();
}
async function onBulkConfirm() {
const confirmBtn = document.getElementById('gp-bulk-confirm');
const cancelBtn = document.getElementById('gp-bulk-cancel');
const closeBtn = document.getElementById('gp-bulk-close');
// Lock UI during purchase
confirmBtn.disabled = true;
confirmBtn.style.opacity = '0.5';
confirmBtn.style.cursor = 'not-allowed';
cancelBtn.disabled = true;
closeBtn.disabled = true;
const colors = [..._pendingColors];
const results = await executeBulkPurchase(colors);
// Silently strip purchased colors from textarea (queue refreshes when modal closes)
const textarea = document.getElementById('gp-bulk-textarea');
if (textarea) {
const { valid } = parseColorInput(textarea.value);
const purchasedSet = new Set(colors.filter(h => results.get(h) === 'purchased'));
const remaining = valid.filter(c => !purchasedSet.has(c));
textarea.value = remaining.length ? remaining.join(', ') : '';
}
// Switch to results view
showBulkResults(colors, results);
closeBtn.disabled = false;
cancelBtn.disabled = false;
cancelBtn.textContent = 'Close';
}
// ─── Purchase logic ───────────────────────────────────────────────────────────
/**
* Attempt to purchase each non-owned color in order.
* On HTTP 402 (insufficient pixels), stops immediately and marks the current
* color plus all remaining unattempted colors as 'skipped'.
* Returns a Map<hex, 'owned'|'purchased'|'failed'|'skipped'>.
*/
async function executeBulkPurchase(colors) {
const progressWrap = document.getElementById('gp-bulk-progress-wrap');
const progressBar = document.getElementById('gp-bulk-progress-bar');
const progressText = document.getElementById('gp-bulk-progress-text');
progressWrap.style.display = 'block';
const auth = getAuth();
if (!auth) {
progressText.textContent = 'Error: credentials not captured yet.';
if (_pw.showAlert) _pw.showAlert('Error', 'Credentials not ready. Place a pixel first to initialise auth, then retry.');
return new Map();
}
const ownedSet = buildOwnedSet();
const results = new Map();
colors.forEach(hex => { if (ownedSet.has(hex)) results.set(hex, 'owned'); });
const toPurchase = colors.filter(hex => !ownedSet.has(hex));
let stoppedAt = -1;
for (let i = 0; i < toPurchase.length; i++) {
const hex = toPurchase[i];
const pct = Math.round((i / toPurchase.length) * 100);
progressBar.style.width = pct + '%';
progressText.textContent = `Purchasing ${i + 1} of ${toPurchase.length}: ${hex}`;
try {
const resp = await window.fetch('/MakePurchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Token: auth.token,
UserId: auth.userId,
Subject: auth.subject,
type: 'ExtraColor',
amount: hexToInt(hex),
}),
});
if (resp.status === 200) {
results.set(hex, 'purchased');
updateColorRow(hex, 'purchased');
} else if (resp.status === 402) {
// Insufficient pixels — stop the loop here
results.set(hex, 'skipped');
updateColorRow(hex, 'skipped');
stoppedAt = i;
break;
} else {
results.set(hex, 'failed');
updateColorRow(hex, 'failed');
console.warn(`[BulkPurchase] Failed ${hex}: HTTP ${resp.status}`);
}
} catch (err) {
results.set(hex, 'failed');
updateColorRow(hex, 'failed');
console.error('[BulkPurchase] Error purchasing', hex, err);
}
}
// Mark everything that was never attempted (after the 402) as skipped
if (stoppedAt >= 0) {
for (let j = stoppedAt + 1; j < toPurchase.length; j++) {
const hex = toPurchase[j];
results.set(hex, 'skipped');
updateColorRow(hex, 'skipped');
}
}
progressBar.style.width = '100%';
if (typeof window.synchronize === 'function') window.synchronize();
return results;
}
/**
* Switch the open modal into a results view.
* The colour rows are already updated in real-time; this just updates the
* title/subtitle and hides the confirm button.
*/
function showBulkResults(original, results) {
const purchased = original.filter(h => results.get(h) === 'purchased').length;
const owned = original.filter(h => results.get(h) === 'owned').length;
const failed = original.filter(h => results.get(h) === 'failed').length;
const skipped = original.filter(h => results.get(h) === 'skipped').length;
document.getElementById('gp-bulk-title').textContent = 'Purchase Complete';
const parts = [];
if (purchased > 0) parts.push(`${purchased} purchased`);
if (owned > 0) parts.push(`${owned} already owned`);
if (skipped > 0) parts.push(`${skipped} skipped — insufficient Pixels`);
if (failed > 0) parts.push(`${failed} failed`);
document.getElementById('gp-bulk-subtitle').textContent = parts.join(' · ');
document.getElementById('gp-bulk-confirm').style.display = 'none';
}
// ─── Profile card queue helpers ───────────────────────────────────────────────
/** Remove a single hex value from the textarea and fire 'input' to refresh the queue. */
function removeColorFromTextarea(hex) {
const textarea = document.getElementById('gp-bulk-textarea');
if (!textarea) return;
const { valid } = parseColorInput(textarea.value);
const remaining = valid.filter(c => c !== hex);
textarea.value = remaining.length ? remaining.join(', ') : '';
textarea.dispatchEvent(new Event('input'));
}
/**
* Re-render the right-side queue list from the current textarea contents.
* Unowned colors come first (with Buy buttons); owned are grayed at the bottom.
*/
function refreshColorQueue() {
const textarea = document.getElementById('gp-bulk-textarea');
const list = document.getElementById('gp-bulk-queue-list');
const emptyHint = document.getElementById('gp-bulk-empty-hint');
const buyAllBtn = document.getElementById('gp-bulk-buy-all-btn');
const infoEl = document.getElementById('gp-bulk-parse-info');
if (!textarea || !list) return;
const { valid, invalid } = parseColorInput(textarea.value);
const ownedSet = buildOwnedSet();
const unowned = valid.filter(c => !ownedSet.has(c));
const owned = valid.filter(c => ownedSet.has(c));
// Parse-info label (below textarea)
if (infoEl) {
if (!textarea.value.trim()) {
infoEl.textContent = '';
} else {
const parts = [`${unowned.length} to purchase`];
if (owned.length > 0) parts.push(`${owned.length} already owned`);
if (invalid.length > 0) parts.push(`${invalid.length} unrecognised`);
infoEl.textContent = parts.join(' · ');
}
}
// Buy All button state
if (buyAllBtn) {
const n = unowned.length;
buyAllBtn.textContent = `🛒 Buy All (${n})`;
buyAllBtn.disabled = n === 0;
buyAllBtn.style.opacity = n === 0 ? '0.5' : '1';
buyAllBtn.style.cursor = n === 0 ? 'not-allowed' : 'pointer';
}
// Rebuild list
list.innerHTML = '';
if (valid.length === 0) {
if (emptyHint) emptyHint.style.display = 'block';
return;
}
if (emptyHint) emptyHint.style.display = 'none';
unowned.forEach(hex => list.appendChild(buildQueueRow(hex, false)));
if (owned.length > 0) {
const sep = document.createElement('div');
const c = t();
sep.style.cssText =
`font-size:0.6rem;color:${c.textMuted};text-align:center;padding:0.2rem 0;` +
`border-top:1px solid ${c.border};border-bottom:1px solid ${c.border};` +
`background:${c.sepBg};letter-spacing:0.05em;user-select:none;`;
sep.textContent = '── Already Owned ──';
list.appendChild(sep);
owned.forEach(hex => list.appendChild(buildQueueRow(hex, true)));
}
}
/** Build a single color row for the profile queue. */
function buildQueueRow(hex, isOwned) {
const c = t();
const row = document.createElement('div');
row.dataset.gpQueueColor = hex;
// Fixed height + no gap = button stays in same screen position as rows are removed
row.style.cssText =
'display:flex;align-items:center;height:1.625rem;padding:0 0.35rem;' +
`background:${isOwned ? c.rowOwnedBg : c.rowBg};` +
`border-bottom:1px solid ${isOwned ? c.border : c.borderLight};` +
`${isOwned ? 'opacity:0.45;' : ''}`;
const swatch = document.createElement('div');
swatch.style.cssText =
`width:0.875rem;height:0.875rem;border-radius:2px;flex-shrink:0;` +
`background:${hex};border:1px solid rgba(0,0,0,0.12);margin-right:0.35rem;`;
const label = document.createElement('span');
label.style.cssText =
`font-family:monospace;font-size:0.68rem;flex:1;overflow:hidden;` +
`color:${isOwned ? c.textOwned : c.textMed};letter-spacing:-0.01em;`;
label.textContent = hex;
row.appendChild(swatch);
row.appendChild(label);
if (isOwned) {
const badge = document.createElement('span');
badge.style.cssText =
`font-size:0.6rem;color:${c.textOwned};white-space:nowrap;flex-shrink:0;padding-left:0.25rem;`;
badge.textContent = 'owned';
row.appendChild(badge);
} else {
const btn = document.createElement('button');
// Fixed width so the button is always at the same X — critical for spam-clicking
btn.style.cssText =
'width:2.25rem;height:1.25rem;flex-shrink:0;background:#3b82f6;color:#fff;border:none;' +
'border-radius:3px;font-size:0.65rem;font-weight:700;cursor:pointer;' +
'display:flex;align-items:center;justify-content:center;letter-spacing:0.02em;';
btn.textContent = 'BUY';
btn.addEventListener('mouseover', () => { if (!btn.disabled) btn.style.background = '#2563eb'; });
btn.addEventListener('mouseout', () => { if (!btn.disabled) btn.style.background = '#3b82f6'; });
btn.addEventListener('click', () => buyIndividualColor(hex, btn));
row.appendChild(btn);
}
return row;
}
/** Purchase one color immediately; on success remove it from the textarea and queue. */
async function buyIndividualColor(hex, btn) {
btn.disabled = true;
btn.style.opacity = '0.5';
btn.style.cursor = 'not-allowed';
btn.textContent = '…';
const auth = getAuth();
if (!auth) {
if (_pw.showAlert) _pw.showAlert('Error', 'Not ready yet — credentials not captured. Try placing a pixel first, then retry.');
btn.disabled = false; btn.style.opacity = '1';
btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
return;
}
try {
const resp = await window.fetch('/MakePurchase', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
Token: auth.token,
UserId: auth.userId,
Subject: auth.subject,
type: 'ExtraColor',
amount: hexToInt(hex),
}),
});
if (resp.status === 200) {
// Remove from textarea → fires 'input' → refreshColorQueue removes the row
removeColorFromTextarea(hex);
if (typeof window.synchronize === 'function') window.synchronize();
if (_pw.showAlert) _pw.showAlert('Success', `${hex} purchased successfully!`);
} else if (resp.status === 402) {
if (_pw.showAlert) _pw.showAlert('Error', 'Insufficient Pixels to purchase this color.');
btn.disabled = false; btn.style.opacity = '1';
btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
} else {
const text = await resp.text().catch(() => '');
if (_pw.showAlert) _pw.showAlert('Error', `Failed to purchase ${hex}. ${text}`.trim());
btn.disabled = false; btn.style.opacity = '1';
btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
}
} catch (err) {
console.error('[BulkPurchase] Network error:', err);
if (_pw.showAlert) _pw.showAlert('Error', 'Network error during purchase.');
btn.disabled = false; btn.style.opacity = '1';
btn.style.cursor = 'pointer'; btn.textContent = 'Buy';
}
}
// ─── Profile panel injection ──────────────────────────────────────────────────
function injectProfileSection() {
if (document.getElementById('gp-bulk-profile-card')) return;
// Locate "Unlock Extra Color" card by its unique child element
const freeColorNotice = document.getElementById('freeColorNotice');
if (!freeColorNotice) return;
// Walk up to the card wrapper (p-4 bg-gray-100 rounded-xl shadow)
const extraColorCard = freeColorNotice.closest('div[class*="p-4"]');
if (!extraColorCard) return;
// The grid that contains all upgrade cards
const grid = extraColorCard.parentElement;
if (!grid) return;
const c = t();
const card = document.createElement('div');
card.id = 'gp-bulk-profile-card';
card.className = 'p-4 bg-gray-100 rounded-xl shadow flex flex-col gap-3';
card.style.gridColumn = '1 / -1';
card.innerHTML = `
<div style="font-weight:600;font-size:1rem;color:${c.text};margin-bottom:0.125rem;">Bulk Purchase Colors</div>
<div style="display:flex;gap:1rem;align-items:flex-start;flex-wrap:wrap;">
<!-- Left: textarea input -->
<div style="flex:1;min-width:11rem;display:flex;flex-direction:column;gap:0.5rem;">
<div class="text-sm text-gray-500">Comma, space, or newline — hex or decimal</div>
<textarea id="gp-bulk-textarea"
rows="6"
placeholder="#FF0000, #00FF00 FF0000 00FF00 16711680"
class="w-full border rounded-lg px-3 py-2 text-sm font-mono resize-y"
style="outline:none;transition:box-shadow 0.15s;"
onfocus="this.style.boxShadow='0 0 0 2px #3b82f6'"
onblur="this.style.boxShadow='none'"
></textarea>
<p id="gp-bulk-parse-info" style="margin:0;font-size:0.75rem;color:${c.textMuted};min-height:1rem;"></p>
</div>
<!-- Right: live color queue -->
<div style="flex:1;min-width:12rem;display:flex;flex-direction:column;gap:0.5rem;">
<button id="gp-bulk-buy-all-btn"
style="width:100%;padding:0.5rem 0.75rem;background:#3b82f6;color:#fff;
border:none;border-radius:0.5rem;font-weight:600;font-size:0.875rem;
cursor:not-allowed;opacity:0.5;text-align:center;"
disabled>
🛒 Buy All (0)
</button>
<div id="gp-bulk-queue-list"
style="max-height:260px;overflow-y:auto;display:flex;flex-direction:column;
border:1px solid ${c.queueBorder};border-radius:0.375rem;overflow-x:hidden;
background:${c.queueBg};"></div>
<p id="gp-bulk-empty-hint"
style="margin:0;font-size:0.75rem;color:${c.textMuted};text-align:center;">Enter colors on the left</p>
</div>
</div>`;
grid.appendChild(card);
const textarea = document.getElementById('gp-bulk-textarea');
textarea.addEventListener('input', refreshColorQueue);
document.getElementById('gp-bulk-buy-all-btn').addEventListener('click', () => {
const { valid } = parseColorInput(textarea.value);
if (valid.length === 0) return;
openBulkModal(valid);
});
// "Add Ghost Template Colors" button — fetches colors from the active ghost image palette
const ghostFetchBtn = document.createElement('button');
ghostFetchBtn.id = 'gp-bulk-ghost-fetch-btn';
ghostFetchBtn.className = 'px-3 py-2 text-white text-sm rounded-lg shadow transition cursor-pointer';
ghostFetchBtn.style.background = '#7c3aed';
ghostFetchBtn.style.border = 'none';
ghostFetchBtn.style.fontWeight = '600';
ghostFetchBtn.textContent = '👻 Add Ghost Template Colors';
ghostFetchBtn.title = 'Fetch colors from the current ghost template and populate the text field';
ghostFetchBtn.addEventListener('mouseover', () => { ghostFetchBtn.style.background = '#6d28d9'; });
ghostFetchBtn.addEventListener('mouseout', () => { ghostFetchBtn.style.background = '#7c3aed'; });
ghostFetchBtn.addEventListener('click', () => {
const ghostColors = getGhostColorsFromDOM();
if (ghostColors.length === 0) {
alert('No ghost palette colors found. Make sure a ghost image is loaded.');
return;
}
const existing = textarea.value.trim();
textarea.value = existing
? existing + ', ' + ghostColors.join(', ')
: ghostColors.join(', ');
textarea.dispatchEvent(new Event('input'));
});
// Insert below the parse info line, inside the left column
const parseInfo = document.getElementById('gp-bulk-parse-info');
if (parseInfo && parseInfo.parentElement) {
parseInfo.parentElement.appendChild(ghostFetchBtn);
}
refreshColorQueue();
}
// ─── Ghost modal injection ─────────────────────────────────────────────────────
function injectGhostButton() {
if (document.getElementById('gp-ghost-buy-btn')) return;
// The "Match My Palette" button is a reliable anchor inside the ghost modal
const anchorBtn = document.getElementById('filterByUserPaletteBtn');
if (!anchorBtn) return;
const btn = document.createElement('button');
btn.id = 'gp-ghost-buy-btn';
btn.className = 'px-3 py-2 text-white text-sm rounded-lg shadow transition cursor-pointer';
btn.style.background = '#7c3aed';
btn.title = "Find ghost-image colors you don't own yet and open the bulk-purchase flow";
btn.textContent = 'Bulk Purchase Colors';
btn.addEventListener('mouseover', () => { btn.style.background = '#6d28d9'; });
btn.addEventListener('mouseout', () => { btn.style.background = '#7c3aed'; });
btn.addEventListener('click', handlePurchaseUnowned);
// Insert after the existing button row so it appears as a natural addition
anchorBtn.parentElement.appendChild(btn);
}
async function handlePurchaseUnowned() {
const btn = document.getElementById('gp-ghost-buy-btn');
if (btn) {
btn.disabled = true;
btn.textContent = 'Checking…';
}
try {
// Read directly from the rendered palette DOM — reliable source of truth.
// ghost22.js sets swatch.title = `${colorData.hex}\n${totalCount} pixels`
// so the first line before \n is always the canonical hex value.
const ghostColors = getGhostColorsFromDOM();
if (ghostColors.length === 0) {
if (_pw.showAlert) {
_pw.showAlert('Info', 'No ghost palette colors found. Make sure a ghost image is loaded and its color palette is visible in the modal.');
}
return;
}
// Fetch fresh ownership data from the server
const ownedSet = await fetchOwnedHexSet();
const unowned = ghostColors.filter(h => !ownedSet.has(h));
if (unowned.length === 0) {
if (_pw.showAlert) {
_pw.showAlert('Info', 'You already own all colors used in this ghost image!');
}
return;
}
// Close the ghost modal
const _pw = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
if (typeof _pw.toggleGhostModal === 'function') _pw.toggleGhostModal(false);
// Open the profile panel if it is currently hidden
const profileOverlay = document.getElementById('profileOverlay');
if (profileOverlay && profileOverlay.classList.contains('hidden') &&
typeof _pw.toggleProfile === 'function') {
_pw.toggleProfile();
}
// Give the profile panel time to animate in, then populate the textarea
setTimeout(() => {
injectProfileSection();
const textarea = document.getElementById('gp-bulk-textarea');
if (textarea) {
// Always wipe first so repeated presses don't accumulate stale colors
textarea.value = unowned.join(', ');
textarea.dispatchEvent(new Event('input'));
textarea.scrollIntoView({ behavior: 'smooth', block: 'center' });
}
// Flash the card with a yellow glow to draw the user's eye
const card = document.getElementById('gp-bulk-profile-card');
if (card) {
card.style.transition = 'box-shadow 0.15s ease';
card.style.boxShadow = '0 0 0 3px #fbbf24, 0 0 18px 6px rgba(251,191,36,0.55)';
setTimeout(() => { card.style.boxShadow = 'none'; }, 900);
}
}, 300);
} finally {
if (btn) {
btn.disabled = false;
btn.textContent = 'Bulk Purchase Colors';
}
}
}
// ─── DOM observation and entry point ──────────────────────────────────────────
/**
* Watch the DOM for relevant containers and inject our UI whenever they appear.
* Both the profile panel and the ghost modal already exist in the HTML on load,
* but the observer also handles any future dynamic additions gracefully.
*/
function observeAndInject() {
// Try immediately (elements may already be in the DOM)
injectProfileSection();
injectGhostButton();
// Re-check on every DOM change (guards against dynamic re-renders)
const observer = new MutationObserver(() => {
injectProfileSection();
injectGhostButton();
});
observer.observe(document.body, { childList: true, subtree: true });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', observeAndInject);
} else {
observeAndInject();
}
})();
_featureStatus.bulkPurchaseColors = 'ok';
console.log('[GeoPixelcons++] ✅ Bulk Purchase Colors loaded');
} catch (err) {
_featureStatus.bulkPurchaseColors = 'error';
console.error('[GeoPixelcons++] ❌ Bulk Purchase Colors failed:', err);
}
}
// ============================================================
// EXTENSION: Auto-open Menus on Hover [extAutoHoverMenus]
// ============================================================
if (_settings.extAutoHoverMenus) {
try {
(function _ext_autoHoverMenus() {
const VERTICAL_ZONE_PX = 250;
const PER_BUTTON_COOLDOWN_MS = 200;
const buttonsState = new WeakMap();
let trackedButtons = [];
let latestMouse = null;
let rafScheduled = false;
function onMouseMove(e) {
latestMouse = { x: e.clientX, y: e.clientY };
if (!rafScheduled) {
rafScheduled = true;
requestAnimationFrame(processMouse);
}
}
function processMouse() {
rafScheduled = false;
if (!latestMouse) return;
const { x, y } = latestMouse;
// Check if any menu is currently open
const anyMenuOpen = trackedButtons.some(info => isMenuOpen(info));
trackedButtons.forEach(info => {
if (!info.button || !info.parent) return;
const rect = info.button.getBoundingClientRect();
const left = Math.floor(rect.left);
const right = Math.ceil(rect.right);
const top = Math.floor(rect.top);
const bottom = Math.ceil(rect.bottom);
const insideButton = (x >= left) && (x <= right) && (y >= top) && (y <= bottom);
if (anyMenuOpen) {
// When a menu is open, switch to another button only if
// the mouse is directly over that button (no extended zone).
if (insideButton) tryOpen(info);
} else {
// No menu open — only open when hovering directly over the button
if (insideButton) tryOpen(info);
}
});
}
function isMenuOpen(info) {
const { button, parent, dropdown } = info;
if (!button || !dropdown) return false;
const visible = dropdown.offsetParent !== null;
const hasActive = button.classList.contains('active') || parent.classList.contains('active');
const hasShow = dropdown.classList.contains('show') || dropdown.classList.contains('open');
return visible || hasActive || hasShow;
}
function tryOpen(info) {
const now = Date.now();
const last = buttonsState.get(info.button) || 0;
if (now - last < PER_BUTTON_COOLDOWN_MS) return;
if (isMenuOpen(info)) return;
try {
info.button.click();
buttonsState.set(info.button, now);
} catch (_) {}
}
function scanAndAttach() {
const controlsLeft = document.getElementById('controls-left');
if (!controlsLeft) {
setTimeout(scanAndAttach, 500);
return;
}
const buttons = Array.from(
controlsLeft.querySelectorAll('button[id$="GroupBtn"], button[id$="plusplusBtn"]')
);
trackedButtons = buttons.map(button => {
const parent = button.closest('.relative') || button.parentElement;
const dropdown = parent ? parent.querySelector('.dropdown-menu') : null;
return { button, parent, dropdown };
});
if (trackedButtons.length > 0) {
document.addEventListener('mousemove', onMouseMove, { passive: true });
}
}
function installMutationObserver() {
const body = document.body;
if (!body) return;
const observer = new MutationObserver(() => {
clearTimeout(scanDebounceTimer);
scanDebounceTimer = setTimeout(scanAndAttach, 150);
});
observer.observe(body, { childList: true, subtree: true, attributes: true });
}
let scanDebounceTimer = null;
function init() {
scanAndAttach();
installMutationObserver();
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
_featureStatus.extAutoHoverMenus = 'ok';
console.log('[GeoPixelcons++] ✅ Auto-open Menus on Hover loaded');
} catch (err) {
_featureStatus.extAutoHoverMenus = 'error';
console.error('[GeoPixelcons++] ❌ Auto-open Menus on Hover failed:', err);
}
}
// ============================================================
// EXTENSION: Auto-Go to Last Location [extGoToLastLocation]
// ============================================================
if (_settings.extGoToLastLocation) {
try {
(function _ext_goToLastLocation() {
const SPAWN_LNG_MIN = -75;
const SPAWN_LNG_MAX = -73;
const SPAWN_LAT_MIN = 39;
const SPAWN_LAT_MAX = 41;
let hasClicked = false;
let observer = null;
function checkAndClick() {
if (hasClicked) return;
const button = document.getElementById('lastLocationButton');
let mapObj = null;
try {
mapObj = eval('map');
} catch (e) {
return;
}
if (button && typeof window.goToLocation === 'function' && mapObj && typeof mapObj.getCenter === 'function') {
try {
const center = mapObj.getCenter();
const lng = center.lng;
const lat = center.lat;
if (lng >= SPAWN_LNG_MIN && lng <= SPAWN_LNG_MAX && lat >= SPAWN_LAT_MIN && lat <= SPAWN_LAT_MAX) {
hasClicked = true;
if (observer) observer.disconnect();
button.click();
}
} catch (e) {}
}
}
checkAndClick();
if (!hasClicked) {
observer = new MutationObserver(() => {
checkAndClick();
});
observer.observe(document.body, {
childList: true,
subtree: true
});
setTimeout(() => {
if (!hasClicked && observer) {
observer.disconnect();
}
}, 10000);
}
})();
_featureStatus.extGoToLastLocation = 'ok';
console.log('[GeoPixelcons++] ✅ Auto-Go to Last Location loaded');
} catch (err) {
_featureStatus.extGoToLastLocation = 'error';
console.error('[GeoPixelcons++] ❌ Auto-Go to Last Location failed:', err);
}
}
// ============================================================
// EXTENSION: Pill Hover Labels [extPillHoverLabels]
// ============================================================
if (_settings.extPillHoverLabels) {
try {
(function _ext_pillHoverLabels() {
const PROCESSED_ATTR = 'data-gpc-pill';
function transformButton(btn) {
if (btn.hasAttribute(PROCESSED_ATTR)) return;
// Skip buttons that are GeoPixelcons++ pills already
if (btn.classList.contains('gpc-pill-btn')) return;
// Only target round 40px submenu buttons (rounded-full or rounded-xl for GeoPixels++ select buttons)
if (!btn.classList.contains('w-10') || !btn.classList.contains('h-10')) return;
if (!btn.classList.contains('rounded-full') && !btn.classList.contains('rounded-xl')) return;
// Must be inside a dropdown-menu
if (!btn.closest('.dropdown-menu')) return;
// Must be inside controls-left
if (!btn.closest('#controls-left')) return;
// Skip buttons that are hidden (mod tools, etc.) — they'll be
// picked up by the MutationObserver when they become visible
if (btn.classList.contains('hidden')) return;
btn.setAttribute(PROCESSED_ATTR, '1');
const label = btn.title || btn.getAttribute('aria-label') || '';
if (!label) return;
// Save the original icon content — could be text/emoji or an SVG element
const svg = btn.querySelector('svg');
const iconText = btn.textContent.trim();
// Create icon span
const iconSpan = document.createElement('span');
iconSpan.style.cssText = 'width:40px;min-width:40px;display:flex;align-items:center;justify-content:center;flex-shrink:0;line-height:40px;pointer-events:none;';
if (svg) {
iconSpan.appendChild(svg.cloneNode(true));
} else {
iconSpan.textContent = iconText;
}
// Create label span — use CSS var for text color so it follows the active theme
const labelSpan = document.createElement('span');
labelSpan.style.cssText = 'white-space:nowrap;font-size:12px;font-weight:600;color:var(--color-gray-700, #374151);opacity:0;transition:opacity .2s .05s;padding-right:12px;pointer-events:none;';
labelSpan.textContent = label;
// Restyle the button — use CSS custom properties so GeoPixels++ themes apply
btn.innerHTML = '';
btn.style.position = 'relative';
btn.style.width = '40px';
btn.style.height = '40px';
btn.style.borderRadius = '9999px';
btn.style.background = 'var(--color-white, #fff)';
btn.style.boxShadow = '0 1px 3px rgba(0,0,0,.12)';
btn.style.alignItems = 'center';
btn.style.justifyContent = 'flex-start';
btn.style.border = 'none';
btn.style.cursor = 'pointer';
btn.style.overflow = 'hidden';
btn.style.transition = 'width .25s cubic-bezier(.4,0,.2,1), background .15s';
btn.style.padding = '0';
btn.style.fontSize = '16px';
btn.style.flexShrink = '0';
// Only set display to flex if not hidden
if (!btn.classList.contains('hidden')) {
btn.style.display = 'flex';
}
// Keep original classes needed for visibility toggling but drop sizing
btn.classList.remove('w-10', 'h-10');
btn.appendChild(iconSpan);
btn.appendChild(labelSpan);
btn.addEventListener('mouseenter', () => {
const textW = labelSpan.scrollWidth + 12;
btn.style.width = (40 + textW) + 'px';
labelSpan.style.opacity = '1';
btn.style.background = 'var(--color-gray-100, #f3f4f6)';
});
btn.addEventListener('mouseleave', () => {
btn.style.width = '40px';
labelSpan.style.opacity = '0';
btn.style.background = 'var(--color-white, #fff)';
});
}
function scanAll() {
const container = document.getElementById('controls-left');
if (!container) return;
// Match both rounded-full (native) and rounded-xl (GeoPixels++ select buttons)
container.querySelectorAll('.dropdown-menu button.rounded-full, .dropdown-menu button.rounded-xl').forEach(transformButton);
// Re-check already-processed buttons whose content was externally replaced
// (e.g. togglePrimaryMode replaces innerHTML, destroying our pill structure)
container.querySelectorAll('.dropdown-menu button[' + PROCESSED_ATTR + ']').forEach(btn => {
if (!btn.querySelector('span')) {
// Our spans were destroyed — reset and re-transform
btn.removeAttribute(PROCESSED_ATTR);
btn.classList.add('w-10', 'h-10');
transformButton(btn);
}
});
}
function init() {
const container = document.getElementById('controls-left');
if (!container) {
setTimeout(init, 500);
return;
}
scanAll();
// Watch for dynamically added buttons
const observer = new MutationObserver(() => {
clearTimeout(debounce);
debounce = setTimeout(scanAll, 150);
});
let debounce = null;
observer.observe(container, { childList: true, subtree: true, attributes: true, attributeFilter: ['class'] });
}
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
init();
}
})();
_featureStatus.extPillHoverLabels = 'ok';
console.log('[GeoPixelcons++] ✅ Pill Hover Labels loaded');
} catch (err) {
_featureStatus.extPillHoverLabels = 'error';
console.error('[GeoPixelcons++] ❌ Pill Hover Labels failed:', err);
}
}
// ============================================================
// FEATURE: Theme Editor [themeEditor]
// ============================================================
if (_settings.themeEditor) {
try {
(function _init_themeEditor() {
// ─── Constants ───────────────────────────────────────────────────
const TE_STORAGE_KEY = 'geoThemeEditor_themes';
const TE_ACTIVE_KEY = 'geoThemeEditor_active';
const TE_BASE_URL = 'https://geopixels.net';
const TE_MINOR_ROAD_KEY = 'geoThemeEditor_minorRoadsHidden_v1';
const TE_FEEDBACK_KEY = 'geoThemeEditor_themeFeedback_v2';
const HIDE_MINOR_ROAD_KEYS = {
dark: ['highway_path::line-color','highway_minor::line-color','highway_name_other::text-color','highway_name_other::text-halo-color'],
light: ['road_path_pedestrian::line-color','road_minor::line-color','highway-name-minor::text-color']
};
const BUNDLED_THEMES = {"default_light":{"base":"light","name":"Default","overrides":{}},"default_dark":{"base":"dark","name":"Default Dark","overrides":{}},"fjord":{"base":"dark","name":"Fjord","overrides":{"background::background-color":"#45516E","water::fill-color":"#38435C","waterway::line-color":"#2f3436","water_name::text-color":"#90a3b7","water_name::text-halo-color":"#45516E","landcover_ice_shelf::fill-color":"#2f3647","landcover_glacier::fill-color":"#2f3647","landcover_wood::fill-color":"#3a3d46","landuse_park::fill-color":"#394854","landuse_residential::fill-color":"#3a3e47","building::fill-color":"#1c232f","building::fill-outline-color":"#2d3440","aeroway-area::fill-color":"#374455","aeroway-taxiway::line-color":"#527386","road_area_pier::fill-color":"#45516E","road_pier::line-color":"#45516E","highway_path::line-color":"#485866","highway_minor::line-color":"#6b7f8f","highway_major_inner::line-color":"#8fa0b0","highway_major_casing::line-color":"#5a6b7d","highway_major_subtle::line-color":"#4a5b6d","highway_motorway_inner::line-color":"#a0b0c0","highway_motorway_casing::line-color":"#6a7b8d","highway_motorway_subtle::line-color":"#5a6b7d","railway::line-color":"#5a6b7d","railway_dashline::line-color":"#3a4b5d","railway_transit::line-color":"#5a6b7d","railway_transit_dashline::line-color":"#3a4b5d","boundary_state::line-color":"#7a8b9d","boundary_country_z0-4::line-color":"#8a9baf","boundary_country_z5-::line-color":"#8a9baf","highway_name_other::text-color":"#b0c0d0","highway_name_other::text-halo-color":"#1a2a3a","highway_name_motorway::text-color":"#c0d0e0","place_other::text-color":"#a0b0c0","place_other::text-halo-color":"#1a2a3a","place_village::text-color":"#a0b0c0","place_village::text-halo-color":"#1a2a3a","place_town::text-color":"#b0c0d0","place_town::text-halo-color":"#1a2a3a","place_city::text-color":"#c0d0e0","place_city::text-halo-color":"#1a2a3a","place_city_large::text-color":"#d0e0f0","place_city_large::text-halo-color":"#1a2a3a","place_state::text-color":"#a0b0c0","place_state::text-halo-color":"#1a2a3a","place_country_major::text-color":"#c0d0e0","place_country_major::text-halo-color":"#1a2a3a","place_country_minor::text-color":"#a0b0c0","place_country_minor::text-halo-color":"#1a2a3a","place_country_other::text-color":"#909fa0","place_country_other::text-halo-color":"#1a2a3a"}},"debug_black":{"base":"dark","name":"Debug Black","overrides":{"background::background-color":"#000000","water::fill-color":"#000000","waterway::line-color":"#000000","water_name::text-color":"#000000","water_name::text-halo-color":"#000000","landcover_ice_shelf::fill-color":"#000000","landcover_glacier::fill-color":"#000000","landcover_wood::fill-color":"#000000","landuse_park::fill-color":"#000000","landuse_residential::fill-color":"#000000","building::fill-color":"#000000","building::fill-outline-color":"#000000","aeroway-area::fill-color":"#000000","aeroway-taxiway::line-color":"#000000","road_area_pier::fill-color":"#000000","road_pier::line-color":"#000000","highway_path::line-color":"#000000","highway_minor::line-color":"#000000","highway_major_inner::line-color":"#000000","highway_major_casing::line-color":"#000000","highway_major_subtle::line-color":"#000000","highway_motorway_inner::line-color":"#000000","highway_motorway_casing::line-color":"#000000","highway_motorway_subtle::line-color":"#000000","railway::line-color":"#000000","railway_dashline::line-color":"#000000","railway_transit::line-color":"#000000","railway_transit_dashline::line-color":"#000000","boundary_state::line-color":"#000000","boundary_country_z0-4::line-color":"#000000","boundary_country_z5-::line-color":"#000000","highway_name_other::text-color":"#000000","highway_name_other::text-halo-color":"#000000","highway_name_motorway::text-color":"#000000","place_other::text-color":"#000000","place_other::text-halo-color":"#000000","place_village::text-color":"#000000","place_village::text-halo-color":"#000000","place_town::text-color":"#000000","place_town::text-halo-color":"#000000","place_city::text-color":"#000000","place_city::text-halo-color":"#000000","place_city_large::text-color":"#000000","place_city_large::text-halo-color":"#000000","place_state::text-color":"#000000","place_state::text-halo-color":"#000000","place_country_major::text-color":"#000000","place_country_major::text-halo-color":"#000000","place_country_minor::text-color":"#000000","place_country_minor::text-halo-color":"#000000","place_country_other::text-color":"#000000","place_country_other::text-halo-color":"#000000"}},"debug_white":{"base":"light","name":"Debug White","overrides":{"background::background-color":"#ffffff","water::fill-color":"#ffffff","waterway_river::line-color":"#ffffff","waterway_other::line-color":"#ffffff","water_name_point_label::text-color":"#ffffff","water_name_point_label::text-halo-color":"#ffffff","water_name_line_label::text-color":"#ffffff","water_name_line_label::text-halo-color":"#ffffff","landcover_ice::fill-color":"#ffffff","landcover_wood::fill-color":"#ffffff","park::fill-color":"#ffffff","landuse_residential::fill-color":"#ffffff","building::fill-color":"#ffffff","aeroway_fill::fill-color":"#ffffff","road_path_pedestrian::line-color":"#ffffff","road_minor::line-color":"#ffffff","road_secondary_tertiary::line-color":"#ffffff","road_trunk_primary::line-color":"#ffffff","road_trunk_primary_casing::line-color":"#ffffff","road_motorway::line-color":"#ffffff","road_motorway_casing::line-color":"#ffffff","road_motorway_link::line-color":"#ffffff","road_major_rail::line-color":"#ffffff","road_major_rail_hatching::line-color":"#ffffff","road_transit_rail::line-color":"#ffffff","road_transit_rail_hatching::line-color":"#ffffff","boundary_3::line-color":"#ffffff","boundary_2::line-color":"#ffffff","boundary_disputed::line-color":"#ffffff","highway-name-minor::text-color":"#ffffff","highway-name-major::text-color":"#ffffff","label_other::text-color":"#ffffff","label_other::text-halo-color":"#ffffff","label_village::text-color":"#ffffff","label_village::text-halo-color":"#ffffff","label_town::text-color":"#ffffff","label_town::text-halo-color":"#ffffff","label_city::text-color":"#ffffff","label_city::text-halo-color":"#ffffff","label_city_capital::text-color":"#ffffff","label_city_capital::text-halo-color":"#ffffff","label_state::text-color":"#ffffff","label_state::text-halo-color":"#ffffff","label_country_1::text-color":"#ffffff","label_country_1::text-halo-color":"#ffffff","label_country_2::text-color":"#ffffff","label_country_2::text-halo-color":"#ffffff","label_country_3::text-color":"#ffffff","label_country_3::text-halo-color":"#ffffff"}},"ayu_mirage":{"base":"light","name":"Ayu Mirage","overrides":{"background::background-color":"#f3f4f6","park::fill-color":"#e6eec8","landuse_residential::fill-color":"hsla(35,57%,88%,0.49)","landcover_wood::fill-color":"#e6eec8","landcover_ice::fill-color":"rgba(224, 236, 236, 1)","waterway_river::line-color":"#dbe6f0","waterway_other::line-color":"#dbe6f0","water::fill-color":"#dbe6f0","building::fill-color":"#e6e1cf","building::fill-outline-color":"#f3f4f6","road_path_pedestrian::line-color":"#cfccc6","road_minor::line-color":"#cfccc6","road_secondary_tertiary::line-color":"#cfccc6","road_trunk_primary::line-color":"#ffae57","road_trunk_primary_casing::line-color":"#ffae57","road_motorway::line-color":"#ffae57","road_motorway_casing::line-color":"#ffae57","road_motorway_link::line-color":"#ffae57","road_major_rail::line-color":"#ffae57","road_major_rail_hatching::line-color":"#ffae57","road_transit_rail::line-color":"#cfccc6","road_transit_rail_hatching::line-color":"#cfccc6","boundary_3::line-color":"#5c6773","boundary_2::line-color":"#5c6773","boundary_disputed::line-color":"#5c6773","highway-name-minor::text-color":"#5c6773","highway-name-major::text-color":"#5c6773","label_other::text-color":"#5c6773","label_other::text-halo-color":"#f3f4f6","label_village::text-color":"#5c6773","label_village::text-halo-color":"#f3f4f6","label_town::text-color":"#5c6773","label_town::text-halo-color":"#f3f4f6","label_city::text-color":"#5c6773","label_city::text-halo-color":"#f3f4f6","label_city_capital::text-color":"#5c6773","label_city_capital::text-halo-color":"#f3f4f6","label_state::text-color":"#5c6773","label_state::text-halo-color":"#f3f4f6","label_country_1::text-color":"#5c6773","label_country_1::text-halo-color":"#f3f4f6","label_country_2::text-color":"#5c6773","label_country_2::text-halo-color":"#f3f4f6","label_country_3::text-color":"#5c6773","label_country_3::text-halo-color":"#f3f4f6"}},"cute_pink":{"base":"light","name":"Cute & Pink","overrides":{"background::background-color":"#fff0f5","park::fill-color":"#ffe6f2","landcover_wood::fill-color":"#ffe6f2","waterway_river::line-color":"#cceeff","waterway_other::line-color":"#cceeff","water::fill-color":"#cceeff","road_path_pedestrian::line-color":"#ffb3d9","road_minor::line-color":"#ffb3d9","road_secondary_tertiary::line-color":"#ffb3d9","road_trunk_primary::line-color":"#ffb3d9","road_trunk_primary_casing::line-color":"#ffb3d9","road_motorway::line-color":"#ffb3d9","road_motorway_casing::line-color":"#ffb3d9","road_motorway_link::line-color":"#ffb3d9","road_major_rail::line-color":"#ffb3d9","road_transit_rail::line-color":"#ffb3d9","building::fill-color":"#ffdaeb","building::fill-outline-color":"#fff0f5","boundary_3::line-color":"#993366","boundary_2::line-color":"#993366","boundary_disputed::line-color":"#993366","highway-name-minor::text-color":"#993366","highway-name-major::text-color":"#993366","label_other::text-color":"#993366","label_other::text-halo-color":"#fff0f5","label_village::text-color":"#993366","label_village::text-halo-color":"#fff0f5","label_town::text-color":"#993366","label_town::text-halo-color":"#fff0f5","label_city::text-color":"#993366","label_city::text-halo-color":"#fff0f5","label_city_capital::text-color":"#993366","label_city_capital::text-halo-color":"#fff0f5","label_state::text-color":"#993366","label_state::text-halo-color":"#fff0f5","label_country_1::text-color":"#993366","label_country_1::text-halo-color":"#fff0f5","label_country_2::text-color":"#993366","label_country_2::text-halo-color":"#fff0f5","label_country_3::text-color":"#993366","label_country_3::text-halo-color":"#fff0f5"}},"discord_gold":{"base":"dark","name":"Discord Gold","overrides":{"background::background-color":"#171717","water::fill-color":"#23272A","waterway::line-color":"hsl(232, 23%, 28%)","water_name::text-color":"hsl(38, 60%, 50%)","water_name::text-halo-color":"hsl(232, 5%, 19%)","landcover_ice_shelf::fill-color":"hsl(232, 33%, 34%)","landuse_residential::fill-color":"transparent","landcover_wood::fill-color":"hsla(232, 18%, 30%, 0.57)","landuse_park::fill-color":"hsl(204, 17%, 35%)","building::fill-color":"hsla(232, 47%, 18%, 0.65)","highway_path::line-color":"hsl(211, 29%, 38%)","highway_minor::line-color":"hsl(224, 22%, 45%)","highway_major_casing::line-color":"hsl(224, 22%, 45%)","highway_major_inner::line-color":"#36393F","highway_major_subtle::line-color":"#38393E","highway_motorway_casing::line-color":"hsl(224, 22%, 45%)","highway_motorway_inner::line-color":"hsl(224, 20%, 29%)","highway_motorway_subtle::line-color":"hsla(239, 45%, 69%, 0.2)","railway::line-color":"hsl(200, 10%, 18%)","railway_dashline::line-color":"hsl(224, 20%, 41%)","boundary_state::line-color":"hsla(195, 47%, 62%, 0.26)","boundary_country_z0-4::line-color":"hsl(214, 63%, 76%)","boundary_country_z5-::line-color":"hsl(214, 63%, 76%)","highway_name_other::text-color":"hsl(38, 70%, 60%)","highway_name_other::text-halo-color":"hsl(232, 9%, 23%)","highway_name_motorway::text-color":"hsl(38, 70%, 60%)","place_other::text-color":"hsl(38, 65%, 60%)","place_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_village::text-color":"hsl(38, 70%, 45%)","place_village::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_town::text-color":"hsl(38, 75%, 65%)","place_town::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city::text-color":"hsl(38, 75%, 65%)","place_city::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city_large::text-color":"hsl(38, 75%, 65%)","place_city_large::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_state::text-color":"rgb(113, 129, 144)","place_state::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_other::text-color":"rgb(153, 153, 153)","place_country_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_minor::text-color":"rgb(153, 153, 153)","place_country_minor::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_major::text-color":"rgb(153, 153, 153)","place_country_major::text-halo-color":"hsla(228, 60%, 21%, 0.7)"}},"monokai":{"base":"dark","name":"Monokai","overrides":{"background::background-color":"#000000","water::fill-color":"#2D2E28","waterway::line-color":"hsl(232, 23%, 28%)","water_name::text-color":"hsl(223, 21%, 52%)","water_name::text-halo-color":"hsl(232, 5%, 19%)","landcover_ice_shelf::fill-color":"hsl(70, 15%, 35%)","landuse_residential::fill-color":"transparent","landcover_wood::fill-color":"hsla(232, 18%, 30%, 0.57)","landuse_park::fill-color":"hsl(204, 17%, 35%)","building::fill-color":"hsla(232, 47%, 18%, 0.65)","highway_path::line-color":"hsl(211, 29%, 38%)","highway_minor::line-color":"hsl(70, 20%, 40%)","highway_major_casing::line-color":"hsl(70, 20%, 40%)","highway_major_inner::line-color":"#3A3E38","highway_major_subtle::line-color":"#273a2d","highway_motorway_casing::line-color":"hsl(70, 20%, 40%)","highway_motorway_inner::line-color":"hsl(70, 18%, 28%)","highway_motorway_subtle::line-color":"hsla(239, 45%, 69%, 0.2)","railway::line-color":"hsl(40, 20%, 18%)","railway_dashline::line-color":"hsl(224, 20%, 41%)","boundary_state::line-color":"hsla(195, 47%, 62%, 0.26)","boundary_country_z0-4::line-color":"hsl(214, 63%, 76%)","boundary_country_z5-::line-color":"hsl(214, 63%, 76%)","highway_name_other::text-color":"hsl(223, 31%, 61%)","highway_name_other::text-halo-color":"hsl(232, 9%, 23%)","place_other::text-color":"hsl(195, 37%, 73%)","place_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_village::text-color":"hsl(195, 41%, 49%)","place_village::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_town::text-color":"hsl(195, 25%, 76%)","place_town::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city::text-color":"hsl(195, 25%, 76%)","place_city::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_city_large::text-color":"hsl(195, 25%, 76%)","place_city_large::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_state::text-color":"rgb(140, 130, 100)","place_state::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_other::text-color":"rgb(153, 153, 153)","place_country_other::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_minor::text-color":"rgb(153, 153, 153)","place_country_minor::text-halo-color":"hsla(228, 60%, 21%, 0.7)","place_country_major::text-color":"rgb(153, 153, 153)","place_country_major::text-halo-color":"hsla(228, 60%, 21%, 0.7)"}},"obsidian":{"base":"dark","name":"Obsidian","overrides":{"background::background-color":"#1c1c1c","water::fill-color":"#262626","waterway::line-color":"#262626","water_name::text-color":"#a0a0a0","water_name::text-halo-color":"#1c1c1c","landcover_ice_shelf::fill-color":"rgb(12,12,12)","landcover_glacier::fill-color":"hsl(0, 1%, 2%)","landuse_residential::fill-color":"hsl(0, 2%, 5%)","landcover_wood::fill-color":"#2a2a2a","landuse_park::fill-color":"#2a2a2a","building::fill-color":"#242424","building::fill-outline-color":"#1c1c1c","highway_path::line-color":"#3d3d3d","highway_minor::line-color":"#3d3d3d","highway_major_casing::line-color":"#555555","highway_major_inner::line-color":"#555555","highway_major_subtle::line-color":"#555555","highway_motorway_casing::line-color":"#555555","highway_motorway_inner::line-color":"#555555","highway_motorway_subtle::line-color":"#555555","railway::line-color":"rgb(35,35,35)","railway_dashline::line-color":"rgb(12,12,12)","boundary_state::line-color":"#a0a0a0","boundary_country_z0-4::line-color":"#a0a0a0","boundary_country_z5-::line-color":"#a0a0a0","highway_name_other::text-color":"#a0a0a0","highway_name_other::text-halo-color":"#1c1c1c","highway_name_motorway::text-color":"#a0a0a0","place_other::text-color":"#a0a0a0","place_other::text-halo-color":"#1c1c1c","place_village::text-color":"#a0a0a0","place_village::text-halo-color":"#1c1c1c","place_town::text-color":"#a0a0a0","place_town::text-halo-color":"#1c1c1c","place_city::text-color":"#a0a0a0","place_city::text-halo-color":"#1c1c1c","place_city_large::text-color":"#a0a0a0","place_city_large::text-halo-color":"#1c1c1c","place_state::text-color":"#a0a0a0","place_state::text-halo-color":"#1c1c1c","place_country_other::text-color":"#a0a0a0","place_country_other::text-halo-color":"#1c1c1c","place_country_minor::text-color":"#a0a0a0","place_country_minor::text-halo-color":"#1c1c1c","place_country_major::text-color":"#a0a0a0","place_country_major::text-halo-color":"#1c1c1c"}},"vintage_sepia":{"base":"light","name":"Vintage Sepia","overrides":{"background::background-color":"#f4e4bc","park::fill-color":"#c5d5a7","landcover_wood::fill-color":"#c5d5a7","waterway_river::line-color":"#d2c29d","waterway_other::line-color":"#d2c29d","water::fill-color":"#d2c29d","road_path_pedestrian::line-color":"#a89f91","road_minor::line-color":"#a89f91","road_secondary_tertiary::line-color":"#a89f91","road_trunk_primary::line-color":"#8f8170","road_trunk_primary_casing::line-color":"#8f8170","road_motorway::line-color":"#8f8170","road_motorway_casing::line-color":"#8f8170","road_motorway_link::line-color":"#8f8170","road_major_rail::line-color":"#8f8170","road_transit_rail::line-color":"#a89f91","building::fill-color":"#e8d5a8","building::fill-outline-color":"#f4e4bc","boundary_3::line-color":"#5b4a42","boundary_2::line-color":"#5b4a42","boundary_disputed::line-color":"#5b4a42","highway-name-minor::text-color":"#5b4a42","highway-name-major::text-color":"#5b4a42","label_other::text-color":"#5b4a42","label_other::text-halo-color":"#f4e4bc","label_village::text-color":"#5b4a42","label_village::text-halo-color":"#f4e4bc","label_town::text-color":"#5b4a42","label_town::text-halo-color":"#f4e4bc","label_city::text-color":"#5b4a42","label_city::text-halo-color":"#f4e4bc","label_city_capital::text-color":"#5b4a42","label_city_capital::text-halo-color":"#f4e4bc","label_state::text-color":"#5b4a42","label_state::text-halo-color":"#f4e4bc","label_country_1::text-color":"#5b4a42","label_country_1::text-halo-color":"#f4e4bc","label_country_2::text-color":"#5b4a42","label_country_2::text-halo-color":"#f4e4bc","label_country_3::text-color":"#5b4a42","label_country_3::text-halo-color":"#f4e4bc"}}};
const EDITABLE_LAYERS = {
dark: [
{ group: 'Base', layers: [{ id: 'background', prop: 'background-color', label: 'Background' }] },
{ group: 'Water', layers: [{ id: 'water', prop: 'fill-color', label: 'Water Fill' },{ id: 'waterway', prop: 'line-color', label: 'Waterways' },{ id: 'water_name', prop: 'text-color', label: 'Water Labels' },{ id: 'water_name', prop: 'text-halo-color', label: 'Water Label Halo' }] },
{ group: 'Land & Nature', layers: [{ id: 'landcover_ice_shelf', prop: 'fill-color', label: 'Ice Shelf' },{ id: 'landcover_glacier', prop: 'fill-color', label: 'Glaciers' },{ id: 'landcover_wood', prop: 'fill-color', label: 'Forests / Wood' },{ id: 'landuse_park', prop: 'fill-color', label: 'Parks' },{ id: 'landuse_residential', prop: 'fill-color', label: 'Residential' }] },
{ group: 'Buildings & Areas', layers: [{ id: 'building', prop: 'fill-color', label: 'Building Fill' },{ id: 'building', prop: 'fill-outline-color', label: 'Building Outline' },{ id: 'aeroway-area', prop: 'fill-color', label: 'Airport Area' },{ id: 'road_area_pier', prop: 'fill-color', label: 'Pier Area' }] },
{ group: 'Roads', layers: [{ id: 'highway_path', prop: 'line-color', label: 'Paths' },{ id: 'highway_minor', prop: 'line-color', label: 'Minor Roads' },{ id: 'highway_major_inner', prop: 'line-color', label: 'Major Roads' },{ id: 'highway_major_casing', prop: 'line-color', label: 'Major Road Casing' },{ id: 'highway_major_subtle', prop: 'line-color', label: 'Major Roads (Subtle)' },{ id: 'highway_motorway_inner', prop: 'line-color', label: 'Motorway' },{ id: 'highway_motorway_casing', prop: 'line-color', label: 'Motorway Casing' },{ id: 'highway_motorway_subtle', prop: 'line-color', label: 'Motorway (Subtle)' },{ id: 'road_pier', prop: 'line-color', label: 'Pier Roads' }] },
{ group: 'Railways', layers: [{ id: 'railway', prop: 'line-color', label: 'Railways' },{ id: 'railway_dashline', prop: 'line-color', label: 'Railway Dashes' },{ id: 'railway_transit', prop: 'line-color', label: 'Transit Rail' },{ id: 'railway_transit_dashline', prop: 'line-color', label: 'Transit Dashes' }] },
{ group: 'Boundaries', layers: [{ id: 'boundary_state', prop: 'line-color', label: 'State Borders' },{ id: 'boundary_country_z0-4', prop: 'line-color', label: 'Country Borders (Far)' },{ id: 'boundary_country_z5-', prop: 'line-color', label: 'Country Borders (Near)' }] },
{ group: 'Labels', layers: [{ id: 'highway_name_other', prop: 'text-color', label: 'Road Labels' },{ id: 'highway_name_other', prop: 'text-halo-color', label: 'Road Label Halo' },{ id: 'highway_name_motorway', prop: 'text-color', label: 'Motorway Labels' },{ id: 'place_other', prop: 'text-color', label: 'Hamlet / Neighborhood' },{ id: 'place_other', prop: 'text-halo-color', label: 'Hamlet Halo' },{ id: 'place_village', prop: 'text-color', label: 'Villages' },{ id: 'place_village', prop: 'text-halo-color', label: 'Village Halo' },{ id: 'place_town', prop: 'text-color', label: 'Towns' },{ id: 'place_town', prop: 'text-halo-color', label: 'Town Halo' },{ id: 'place_city', prop: 'text-color', label: 'Cities' },{ id: 'place_city', prop: 'text-halo-color', label: 'City Halo' },{ id: 'place_city_large', prop: 'text-color', label: 'Major Cities' },{ id: 'place_city_large', prop: 'text-halo-color', label: 'Major City Halo' },{ id: 'place_state', prop: 'text-color', label: 'States' },{ id: 'place_state', prop: 'text-halo-color', label: 'State Halo' },{ id: 'place_country_major', prop: 'text-color', label: 'Countries (Major)' },{ id: 'place_country_major', prop: 'text-halo-color', label: 'Country Halo (Major)' },{ id: 'place_country_minor', prop: 'text-color', label: 'Countries (Minor)' },{ id: 'place_country_other', prop: 'text-color', label: 'Countries (Other)' }] },
],
light: [
{ group: 'Base', layers: [{ id: 'background', prop: 'background-color', label: 'Background' }] },
{ group: 'Water', layers: [{ id: 'water', prop: 'fill-color', label: 'Water Fill' },{ id: 'waterway_river', prop: 'line-color', label: 'Rivers' },{ id: 'waterway_other', prop: 'line-color', label: 'Other Waterways' },{ id: 'water_name_point_label', prop: 'text-color', label: 'Water Labels' },{ id: 'water_name_point_label', prop: 'text-halo-color', label: 'Water Label Halo' }] },
{ group: 'Land & Nature', layers: [{ id: 'landcover_ice', prop: 'fill-color', label: 'Ice / Snow' },{ id: 'landcover_wood', prop: 'fill-color', label: 'Forests / Wood' },{ id: 'park', prop: 'fill-color', label: 'Parks' },{ id: 'landuse_residential', prop: 'fill-color', label: 'Residential' }] },
{ group: 'Buildings & Areas', layers: [{ id: 'building', prop: 'fill-color', label: 'Building Fill' },{ id: 'aeroway_fill', prop: 'fill-color', label: 'Airport Area' }] },
{ group: 'Roads', layers: [{ id: 'road_path_pedestrian', prop: 'line-color', label: 'Paths' },{ id: 'road_minor', prop: 'line-color', label: 'Minor Roads' },{ id: 'road_secondary_tertiary', prop: 'line-color', label: 'Secondary / Tertiary' },{ id: 'road_trunk_primary', prop: 'line-color', label: 'Trunk / Primary' },{ id: 'road_trunk_primary_casing', prop: 'line-color', label: 'Road Casing' },{ id: 'road_motorway', prop: 'line-color', label: 'Motorway' },{ id: 'road_motorway_casing', prop: 'line-color', label: 'Motorway Casing' },{ id: 'road_motorway_link', prop: 'line-color', label: 'Motorway Links' }] },
{ group: 'Railways', layers: [{ id: 'road_major_rail', prop: 'line-color', label: 'Railways' },{ id: 'road_major_rail_hatching', prop: 'line-color', label: 'Railway Hatching' },{ id: 'road_transit_rail', prop: 'line-color', label: 'Transit Rail' },{ id: 'road_transit_rail_hatching', prop: 'line-color', label: 'Transit Hatching' }] },
{ group: 'Boundaries', layers: [{ id: 'boundary_3', prop: 'line-color', label: 'State Borders' },{ id: 'boundary_2', prop: 'line-color', label: 'Country Borders' },{ id: 'boundary_disputed', prop: 'line-color', label: 'Disputed Borders' }] },
{ group: 'Labels', layers: [{ id: 'highway-name-minor', prop: 'text-color', label: 'Road Labels' },{ id: 'highway-name-major', prop: 'text-color', label: 'Major Road Labels' },{ id: 'label_other', prop: 'text-color', label: 'Hamlet / Neighborhood' },{ id: 'label_other', prop: 'text-halo-color', label: 'Hamlet Halo' },{ id: 'label_village', prop: 'text-color', label: 'Villages' },{ id: 'label_village', prop: 'text-halo-color', label: 'Village Halo' },{ id: 'label_town', prop: 'text-color', label: 'Towns' },{ id: 'label_town', prop: 'text-halo-color', label: 'Town Halo' },{ id: 'label_city', prop: 'text-color', label: 'Cities' },{ id: 'label_city', prop: 'text-halo-color', label: 'City Halo' },{ id: 'label_city_capital', prop: 'text-color', label: 'Capital Cities' },{ id: 'label_city_capital', prop: 'text-halo-color', label: 'Capital City Halo' },{ id: 'label_state', prop: 'text-color', label: 'States' },{ id: 'label_state', prop: 'text-halo-color', label: 'State Halo' },{ id: 'label_country_1', prop: 'text-color', label: 'Countries (Major)' },{ id: 'label_country_1', prop: 'text-halo-color', label: 'Country Halo (Major)' },{ id: 'label_country_2', prop: 'text-color', label: 'Countries (Minor)' },{ id: 'label_country_3', prop: 'text-color', label: 'Countries (Other)' }] },
],
};
const SIMPLE_LAYERS = {
dark: [
{ group: 'Base', layers: [{ label: 'Background', keys: ['background::background-color'] }] },
{ group: 'Water', layers: [{ label: 'Water', keys: ['water::fill-color','waterway::line-color'] },{ label: 'Water Labels', keys: ['water_name::text-color'] },{ label: 'Water Label Halo', keys: ['water_name::text-halo-color'] }] },
{ group: 'Land & Nature', layers: [{ label: 'Nature / Parks', keys: ['landcover_wood::fill-color','landuse_park::fill-color','landcover_ice_shelf::fill-color','landcover_glacier::fill-color'] },{ label: 'Residential', keys: ['landuse_residential::fill-color'] }] },
{ group: 'Buildings & Areas', layers: [{ label: 'Buildings', keys: ['building::fill-color','building::fill-outline-color'] },{ label: 'Airports / Piers', keys: ['aeroway-area::fill-color','road_area_pier::fill-color'] }] },
{ group: 'Roads', layers: [{ label: 'Minor Roads', keys: ['highway_path::line-color','highway_minor::line-color','road_pier::line-color'] },{ label: 'Major Roads', keys: ['highway_major_inner::line-color','highway_major_casing::line-color','highway_major_subtle::line-color'] },{ label: 'Motorways', keys: ['highway_motorway_inner::line-color','highway_motorway_casing::line-color','highway_motorway_subtle::line-color'] }] },
{ group: 'Railways', layers: [{ label: 'All Railways', keys: ['railway::line-color','railway_dashline::line-color','railway_transit::line-color','railway_transit_dashline::line-color'] }] },
{ group: 'Boundaries', layers: [{ label: 'All Borders', keys: ['boundary_state::line-color','boundary_country_z0-4::line-color','boundary_country_z5-::line-color'] }] },
{ group: 'Labels', layers: [{ label: 'Road Labels', keys: ['highway_name_other::text-color','highway_name_motorway::text-color'] },{ label: 'Road Label Halo', keys: ['highway_name_other::text-halo-color'] },{ label: 'Place Labels', keys: ['place_other::text-color','place_village::text-color','place_town::text-color','place_city::text-color','place_city_large::text-color','place_state::text-color','place_country_major::text-color','place_country_minor::text-color','place_country_other::text-color'] },{ label: 'Place Label Halo', keys: ['place_other::text-halo-color','place_village::text-halo-color','place_town::text-halo-color','place_city::text-halo-color','place_city_large::text-halo-color','place_state::text-halo-color','place_country_major::text-halo-color'] }] },
],
light: [
{ group: 'Base', layers: [{ label: 'Background', keys: ['background::background-color'] }] },
{ group: 'Water', layers: [{ label: 'Water', keys: ['water::fill-color','waterway_river::line-color','waterway_other::line-color'] },{ label: 'Water Labels', keys: ['water_name_point_label::text-color','water_name_line_label::text-color'] },{ label: 'Water Label Halo', keys: ['water_name_point_label::text-halo-color','water_name_line_label::text-halo-color'] }] },
{ group: 'Land & Nature', layers: [{ label: 'Nature / Parks', keys: ['landcover_wood::fill-color','park::fill-color','landcover_ice::fill-color'] },{ label: 'Residential', keys: ['landuse_residential::fill-color'] }] },
{ group: 'Buildings & Areas', layers: [{ label: 'Buildings', keys: ['building::fill-color'] },{ label: 'Airports', keys: ['aeroway_fill::fill-color'] }] },
{ group: 'Roads', layers: [{ label: 'Minor Roads', keys: ['road_path_pedestrian::line-color','road_minor::line-color'] },{ label: 'Major Roads', keys: ['road_secondary_tertiary::line-color','road_trunk_primary::line-color','road_trunk_primary_casing::line-color'] },{ label: 'Motorways', keys: ['road_motorway::line-color','road_motorway_casing::line-color','road_motorway_link::line-color'] }] },
{ group: 'Railways', layers: [{ label: 'All Railways', keys: ['road_major_rail::line-color','road_major_rail_hatching::line-color','road_transit_rail::line-color','road_transit_rail_hatching::line-color'] }] },
{ group: 'Boundaries', layers: [{ label: 'All Borders', keys: ['boundary_3::line-color','boundary_2::line-color','boundary_disputed::line-color'] }] },
{ group: 'Labels', layers: [{ label: 'Road Labels', keys: ['highway-name-minor::text-color','highway-name-major::text-color'] },{ label: 'Place Labels', keys: ['label_other::text-color','label_village::text-color','label_town::text-color','label_city::text-color','label_city_capital::text-color','label_state::text-color','label_country_1::text-color','label_country_2::text-color','label_country_3::text-color'] },{ label: 'Place Label Halo', keys: ['label_other::text-halo-color','label_village::text-halo-color','label_town::text-halo-color','label_city::text-halo-color','label_city_capital::text-halo-color','label_state::text-halo-color','label_country_1::text-halo-color'] }] },
],
};
const BASE_DEFAULTS = {
dark: {'background::background-color':'#0c0c0c','water::fill-color':'#1b1b1d','waterway::line-color':'#1b1b1d','water_name::text-color':'#000000','water_name::text-halo-color':'#454545','landcover_ice_shelf::fill-color':'#0c0c0c','landcover_glacier::fill-color':'#050505','landcover_wood::fill-color':'#202020','landuse_park::fill-color':'#202020','landuse_residential::fill-color':'#0d0d0d','building::fill-color':'#0a0a0a','building::fill-outline-color':'#1b1b1d','aeroway-area::fill-color':'#000000','road_area_pier::fill-color':'#0c0c0c','road_pier::line-color':'#0c0c0c','highway_path::line-color':'#1b1b1d','highway_minor::line-color':'#181818','highway_major_inner::line-color':'#121212','highway_major_casing::line-color':'#3c3c3c','highway_major_subtle::line-color':'#2a2a2a','highway_motorway_inner::line-color':'#000000','highway_motorway_casing::line-color':'#3c3c3c','highway_motorway_subtle::line-color':'#181818','railway::line-color':'#232323','railway_dashline::line-color':'#0c0c0c','railway_transit::line-color':'#232323','railway_transit_dashline::line-color':'#0c0c0c','boundary_state::line-color':'#363636','boundary_country_z0-4::line-color':'#3b3b3b','boundary_country_z5-::line-color':'#3b3b3b','highway_name_other::text-color':'#504e4e','highway_name_other::text-halo-color':'#000000','highway_name_motorway::text-color':'#5e5e5e','place_other::text-color':'#656565','place_other::text-halo-color':'#000000','place_village::text-color':'#656565','place_village::text-halo-color':'#000000','place_town::text-color':'#656565','place_town::text-halo-color':'#000000','place_city::text-color':'#656565','place_city::text-halo-color':'#000000','place_city_large::text-color':'#656565','place_city_large::text-halo-color':'#000000','place_state::text-color':'#656565','place_state::text-halo-color':'#000000','place_country_other::text-color':'#656565','place_country_other::text-halo-color':'#000000','place_country_minor::text-color':'#656565','place_country_minor::text-halo-color':'#000000','place_country_major::text-color':'#656565','place_country_major::text-halo-color':'#000000'},
light: {'background::background-color':'#f8f4f0','water::fill-color':'#9ebdff','waterway_river::line-color':'#a0c8f0','waterway_other::line-color':'#a0c8f0','water_name_point_label::text-color':'#495e91','water_name_point_label::text-halo-color':'#ffffff','water_name_line_label::text-color':'#495e91','water_name_line_label::text-halo-color':'#ffffff','landcover_ice::fill-color':'#e0ecec','landcover_wood::fill-color':'#a4d898','park::fill-color':'#d8e8c8','landuse_residential::fill-color':'#e8dece','building::fill-color':'#d4cfc9','aeroway_fill::fill-color':'#e5e4e0','road_path_pedestrian::line-color':'#ffffff','road_minor::line-color':'#ffffff','road_secondary_tertiary::line-color':'#ffeeaa','road_trunk_primary::line-color':'#ffeeaa','road_trunk_primary_casing::line-color':'#e9ac77','road_motorway::line-color':'#ffcc88','road_motorway_casing::line-color':'#e9ac77','road_motorway_link::line-color':'#ffcc88','road_major_rail::line-color':'#bbbbbb','road_major_rail_hatching::line-color':'#bbbbbb','road_transit_rail::line-color':'#bbbbbb','road_transit_rail_hatching::line-color':'#bbbbbb','boundary_3::line-color':'#b3b3b3','boundary_2::line-color':'#696969','boundary_disputed::line-color':'#696969','highway-name-minor::text-color':'#666666','highway-name-major::text-color':'#666666','label_other::text-color':'#333333','label_other::text-halo-color':'#ffffff','label_village::text-color':'#000000','label_village::text-halo-color':'#ffffff','label_town::text-color':'#000000','label_town::text-halo-color':'#ffffff','label_city::text-color':'#000000','label_city::text-halo-color':'#ffffff','label_city_capital::text-color':'#000000','label_city_capital::text-halo-color':'#ffffff','label_state::text-color':'#333333','label_state::text-halo-color':'#ffffff','label_country_1::text-color':'#000000','label_country_1::text-halo-color':'#ffffff','label_country_2::text-color':'#000000','label_country_2::text-halo-color':'#ffffff','label_country_3::text-color':'#000000','label_country_3::text-halo-color':'#ffffff'}
};
function getEditableLayers(base) { return EDITABLE_LAYERS[base] || EDITABLE_LAYERS.dark; }
function getSimpleLayers(base) { return SIMPLE_LAYERS[base] || SIMPLE_LAYERS.dark; }
function getDefaultColor(key, base) { return (BASE_DEFAULTS[base] || BASE_DEFAULTS.dark)[key] || '#808080'; }
// ─── Storage Helpers ─────────────────────────────────────────────
function teLoadThemes() {
let themes = {};
try { themes = JSON.parse(localStorage.getItem(TE_STORAGE_KEY)) || {}; } catch {}
for (const [key, bundled] of Object.entries(BUNDLED_THEMES)) {
const name = bundled.name;
if (!themes[name] || !themes[name].bundled) {
themes[name] = { overrides: { ...bundled.overrides }, base: bundled.base, bundled: true, createdAt: themes[name]?.createdAt || Date.now(), updatedAt: Date.now() };
}
if (name === 'Debug White' && !themes[name].overrides['*::all-color']) themes[name].overrides['*::all-color'] = '#ffffff';
if (name === 'Debug Black' && !themes[name].overrides['*::all-color']) themes[name].overrides['*::all-color'] = '#000000';
}
if (!localStorage.getItem(TE_MINOR_ROAD_KEY)) {
for (const bundled of Object.values(BUNDLED_THEMES)) {
const theme = themes[bundled.name];
if (!theme || !theme.bundled || bundled.name === 'Debug White' || bundled.name === 'Debug Black') continue;
const keys = HIDE_MINOR_ROAD_KEYS[theme.base || 'dark'] || [];
for (const k of keys) { const cur = theme.overrides[k]; const orig = bundled.overrides[k]; if (cur == null || cur === orig) theme.overrides[k] = 'transparent'; }
theme.updatedAt = Date.now();
}
localStorage.setItem(TE_MINOR_ROAD_KEY, '1');
}
if (!localStorage.getItem(TE_FEEDBACK_KEY)) {
const dg = themes['Discord Gold']; if (dg?.bundled) { dg.overrides['background::background-color'] = '#171717'; dg.overrides['landuse_residential::fill-color'] = 'transparent'; dg.updatedAt = Date.now(); }
const cp = themes['Cute & Pink']; if (cp?.bundled) { cp.overrides['landuse_residential::fill-color'] = 'transparent'; cp.updatedAt = Date.now(); }
const mk = themes['Monokai']; if (mk?.bundled) { mk.overrides['background::background-color'] = '#000000'; mk.overrides['landuse_residential::fill-color'] = 'transparent'; mk.overrides['highway_major_subtle::line-color'] = '#273a2d'; mk.updatedAt = Date.now(); }
localStorage.setItem(TE_FEEDBACK_KEY, '1');
}
teSaveThemes(themes);
return themes;
}
function teSaveThemes(themes) { localStorage.setItem(TE_STORAGE_KEY, JSON.stringify(themes)); }
function teGetActive() { return localStorage.getItem(TE_ACTIVE_KEY) || ''; }
function teSetActive(name) { localStorage.setItem(TE_ACTIVE_KEY, name); }
// ─── Color Helpers ───────────────────────────────────────────────
function colorToHex(color) {
if (!color || typeof color !== 'string') return '#000000';
if (/^#[0-9A-Fa-f]{6}$/.test(color)) return color;
if (/^#[0-9A-Fa-f]{3}$/.test(color)) { const [,r,g,b] = color.match(/^#(.)(.)(.)$/); return '#'+r+r+g+g+b+b; }
const tmp = document.createElement('div'); tmp.style.color = color; document.body.appendChild(tmp);
const computed = getComputedStyle(tmp).color; document.body.removeChild(tmp);
const m = computed.match(/rgba?\((\d+),\s*(\d+),\s*(\d+)/);
if (!m) return '#000000';
const hex = n => parseInt(n,10).toString(16).padStart(2,'0');
return '#'+hex(m[1])+hex(m[2])+hex(m[3]);
}
function isColorDark(hex) { const h = colorToHex(hex).replace('#',''); const r = parseInt(h.substring(0,2),16)/255; const g = parseInt(h.substring(2,4),16)/255; const b = parseInt(h.substring(4,6),16)/255; return (0.299*r+0.587*g+0.114*b) < 0.4; }
// ─── Base Style Templates ────────────────────────────────────────
let lightStyleTemplate = null, darkStyleTemplate = null;
async function fetchStyle(endpoint) { let text = await fetch(TE_BASE_URL + endpoint).then(r => r.text()); text = text.replaceAll('http://localhost:5039', TE_BASE_URL); return JSON.parse(text); }
async function getBaseStyle(base) {
if (base === 'dark') { if (!darkStyleTemplate) { try { darkStyleTemplate = await fetchStyle('/styleDark'); } catch { return null; } } return JSON.parse(JSON.stringify(darkStyleTemplate)); }
if (!lightStyleTemplate) { try { lightStyleTemplate = await fetchStyle('/style'); } catch { return null; } } return JSON.parse(JSON.stringify(lightStyleTemplate));
}
// ─── Style Builder ───────────────────────────────────────────────
function buildStyle(baseStyle, overrides) {
const style = JSON.parse(JSON.stringify(baseStyle));
const allColor = overrides['*::all-color'];
for (const layer of style.layers) {
if (allColor && layer.paint) {
if (layer.paint['background-color'] != null) layer.paint['background-color'] = allColor;
if (layer.paint['fill-color'] != null) layer.paint['fill-color'] = allColor;
if (layer.paint['fill-outline-color'] != null) layer.paint['fill-outline-color'] = allColor;
if (layer.paint['line-color'] != null) layer.paint['line-color'] = allColor;
if (layer.paint['text-color'] != null) layer.paint['text-color'] = allColor;
if (layer.paint['text-halo-color'] != null) layer.paint['text-halo-color'] = allColor;
if (layer.type === 'raster' && layer.paint['raster-opacity'] != null) layer.paint['raster-opacity'] = 0;
if (layer.type === 'symbol') layer.paint['icon-opacity'] = 0;
}
const check = (prop) => { const key = layer.id+'::'+prop; if (overrides[key]) { if (!layer.paint) layer.paint = {}; layer.paint[prop] = overrides[key]; } };
if (layer.type === 'background') check('background-color');
if (layer.type === 'fill') { check('fill-color'); check('fill-outline-color'); }
if (layer.type === 'line') check('line-color');
if (layer.type === 'symbol') { check('text-color'); check('text-halo-color'); }
}
return style;
}
function readColorsFromStyle(style, base) {
const overrides = {};
for (const group of getEditableLayers(base || 'dark')) {
for (const entry of group.layers) {
const layer = style.layers.find(l => l.id === entry.id);
if (layer?.paint?.[entry.prop] != null) { let val = layer.paint[entry.prop]; if (typeof val === 'object' && !Array.isArray(val) && val.stops) val = val.stops[val.stops.length-1][1]; if (typeof val === 'string') overrides[entry.id+'::'+entry.prop] = colorToHex(val); }
}
}
return overrides;
}
function readAllColorsFromStyle(style) {
const propMap = { background: ['background-color'], fill: ['fill-color','fill-outline-color'], line: ['line-color'], symbol: ['text-color','text-halo-color'] };
const overrides = {};
for (const layer of style.layers) { const props = propMap[layer.type]; if (!props || !layer.paint) continue; for (const p of props) { let val = layer.paint[p]; if (val == null) continue; if (typeof val === 'object' && !Array.isArray(val) && val.stops) val = val.stops[val.stops.length-1][1]; if (typeof val === 'string') overrides[layer.id+'::'+p] = colorToHex(val); } }
return overrides;
}
// ─── Map Integration ─────────────────────────────────────────────
function teGetMap() {
try { const m = (0, eval)('map'); if (m && typeof m.setStyle === 'function') return m; } catch {}
if (typeof unsafeWindow !== 'undefined') { try { const m = unsafeWindow.eval('map'); if (m && typeof m.setStyle === 'function') return m; } catch {} }
return null;
}
function teGetUserConfig() { if (typeof unsafeWindow !== 'undefined' && unsafeWindow.userConfig) return unsafeWindow.userConfig; if (window.userConfig) return window.userConfig; try { return JSON.parse(localStorage.getItem('userConfig')); } catch { return null; } }
function inferBase(style) { if (!style?.layers) return 'dark'; const ids = new Set(style.layers.map(l => l.id)); if (ids.has('road_minor') || ids.has('highway-name-minor') || ids.has('boundary_3')) return 'light'; return 'dark'; }
function setStyleCustomInPage(style) {
const json = JSON.stringify(style).replace(/</g, '\\u003c');
const code = 'styleCustom = ' + json;
try { if (window.wrappedJSObject?.eval) { window.wrappedJSObject.eval(code); return true; } } catch {}
try { const s = document.createElement('script'); s.textContent = '(function(){try{'+code+'}catch(e){}})();'; (document.head||document.documentElement).appendChild(s); s.remove(); return true; } catch {}
try { (0, eval)(code); return true; } catch {}
return false;
}
function applyStyleInPlace(map, style) {
const liveStyle = map.getStyle(); if (!liveStyle?.layers) return false;
const liveIds = new Set(liveStyle.layers.map(l => l.id));
const allowed = new Set(['background-color','fill-color','fill-outline-color','line-color','text-color','text-halo-color','icon-opacity','raster-opacity']);
for (const layer of style.layers||[]) { if (!liveIds.has(layer.id) || !layer.paint) continue; for (const [prop,value] of Object.entries(layer.paint)) { if (!allowed.has(prop)) continue; try { map.setPaintProperty(layer.id, prop, value); } catch {} } }
return true;
}
function triggerRepaint() { try { (0, eval)('try{if(typeof drawCachedTilesOnMap==="function")drawCachedTilesOnMap()}catch(e){};try{if(typeof synchronize==="function")synchronize("partial")}catch(e){};try{if(typeof refresh==="function")refresh()}catch(e){}'); } catch {} }
function applyStyleToMap(style, targetBase) {
localStorage.setItem('customTheme', JSON.stringify(style));
const uc = teGetUserConfig() || {}; uc.theme = 'custom'; localStorage.setItem('userConfig', JSON.stringify(uc));
const map = teGetMap(); if (!map) { teShowToast('Map not found — please wait for the page to load.', true); return; }
const liveStyle = map.getStyle ? map.getStyle() : null;
const liveBase = inferBase(liveStyle); const desiredBase = targetBase || inferBase(style);
if (liveStyle && liveBase === desiredBase) { applyStyleInPlace(map, style); setStyleCustomInPage(style); return; }
const applyFull = (attempt = 0) => {
try { if (map.isStyleLoaded && !map.isStyleLoaded()) { if (attempt < 40) return setTimeout(() => applyFull(attempt+1), 75); }
map.setStyle(style); setStyleCustomInPage(style);
const kick = () => { triggerRepaint(); setTimeout(triggerRepaint, 120); setTimeout(triggerRepaint, 450); };
try { map.once('styledata', kick); } catch {} try { map.once('idle', kick); } catch {}
} catch (e) { if (attempt < 40) return setTimeout(() => applyFull(attempt+1), 75);
const json = JSON.stringify(style).replace(/</g, '\\u003c');
try { const s = document.createElement('script'); s.textContent = '(function(){try{styleCustom='+json+';applyTheme("custom")}catch(e){}})();'; (document.head||document.documentElement).appendChild(s); s.remove(); triggerRepaint(); setTimeout(triggerRepaint,120); setTimeout(triggerRepaint,450); } catch { teShowToast('Failed to switch base style.', true); }
}
};
applyFull();
}
function persistTheme(style) { localStorage.setItem('customTheme', JSON.stringify(style)); const uc = teGetUserConfig() || {}; uc.theme = 'custom'; localStorage.setItem('userConfig', JSON.stringify(uc)); }
// ─── Toast ───────────────────────────────────────────────────────
function teShowToast(msg, isError) {
const existing = document.getElementById('gte-toast'); if (existing) existing.remove();
const toast = document.createElement('div'); toast.id = 'gte-toast'; toast.textContent = msg;
Object.assign(toast.style, { position:'fixed',bottom:'20px',left:'50%',transform:'translateX(-50%)',background:isError?'#f38ba8':'#a6e3a1',color:'#1e1e2e',padding:'8px 18px',borderRadius:'8px',fontSize:'12px',fontWeight:'600',zIndex:'100001',boxShadow:'0 4px 12px rgba(0,0,0,.3)',transition:'opacity .3s',fontFamily:"'Segoe UI',system-ui,sans-serif" });
document.body.appendChild(toast); setTimeout(() => { toast.style.opacity = '0'; setTimeout(() => toast.remove(), 300); }, 2500);
}
// ─── HTML Utils ──────────────────────────────────────────────────
function teEscHTML(str) { const d = document.createElement('div'); d.textContent = str; return d.innerHTML; }
function teEscAttr(str) { return str.replace(/"/g,'"').replace(/'/g,''').replace(/</g,'<').replace(/>/g,'>'); }
// ─── CSS ─────────────────────────────────────────────────────────
function teInjectCSS() {
if (document.getElementById('gte-style')) return;
const css = document.createElement('style'); css.id = 'gte-style';
css.textContent = `
#gte-modal { position:fixed; z-index:100000; background:#1e1e2e; color:#cdd6f4; border:1px solid #45475a; border-radius:12px; box-shadow:0 8px 32px rgba(0,0,0,.55); width:380px; max-height:80vh; display:flex; flex-direction:column; font-family:'Segoe UI',system-ui,sans-serif; font-size:13px; user-select:none; }
#gte-modal.gte-hidden { display:none; }
#gte-titlebar { display:flex; align-items:center; justify-content:space-between; padding:10px 14px; background:#181825; border-radius:12px 12px 0 0; cursor:grab; flex-shrink:0; }
#gte-titlebar:active { cursor:grabbing; }
#gte-titlebar h2 { margin:0; font-size:14px; font-weight:600; color:#cba6f7; }
#gte-close-btn { background:none; border:none; color:#6c7086; cursor:pointer; font-size:18px; line-height:1; padding:0 4px; }
#gte-close-btn:hover { color:#f38ba8; }
#gte-tabs { display:flex; border-bottom:1px solid #313244; flex-shrink:0; }
.gte-tab { flex:1; padding:8px 0; text-align:center; background:none; border:none; color:#6c7086; cursor:pointer; font-size:12px; font-weight:500; border-bottom:2px solid transparent; transition:color .15s,border-color .15s; }
.gte-tab:hover { color:#bac2de; }
.gte-tab.gte-active { color:#cba6f7; border-bottom-color:#cba6f7; }
#gte-body { overflow-y:auto; padding:12px 14px; flex:1; }
#gte-body::-webkit-scrollbar { width:6px; }
#gte-body::-webkit-scrollbar-thumb { background:#45475a; border-radius:3px; }
.gte-panel { display:none; }
.gte-panel.gte-active { display:block; }
.gte-group-header { font-size:11px; font-weight:700; text-transform:uppercase; color:#89b4fa; margin:12px 0 6px; letter-spacing:.5px; }
.gte-group-header:first-child { margin-top:0; }
.gte-color-row { display:flex; align-items:center; justify-content:space-between; padding:4px 0; }
.gte-color-label { font-size:12px; color:#a6adc8; }
.gte-color-input-wrap { display:flex; align-items:center; gap:6px; }
.gte-color-input { -webkit-appearance:none; appearance:none; width:32px; height:24px; border:1px solid #45475a; border-radius:4px; cursor:pointer; background:none; padding:0; }
.gte-color-input::-webkit-color-swatch-wrapper { padding:0; }
.gte-color-input::-webkit-color-swatch { border:none; border-radius:3px; }
.gte-hex-display { font-family:'Cascadia Code','Consolas',monospace; font-size:11px; color:#6c7086; width:62px; text-align:right; }
.gte-hex-display.gte-hidden-color { color:#f38ba8; font-style:italic; }
.gte-vis-btn { background:none; border:none; cursor:pointer; font-size:14px; padding:0 2px; line-height:1; opacity:0.6; transition:opacity .15s; }
.gte-vis-btn:hover { opacity:1; }
.gte-vis-btn.gte-layer-hidden { opacity:0.35; }
.gte-reset-btn { background:none; border:none; cursor:pointer; font-size:11px; padding:0 2px; line-height:1; opacity:0.5; transition:opacity .15s; color:#89b4fa; }
.gte-reset-btn:hover { opacity:1; }
.gte-name-row { display:flex; gap:8px; margin-bottom:12px; }
.gte-name-input { flex:1; padding:6px 10px; border-radius:6px; background:#313244; color:#cdd6f4; border:1px solid #45475a; font-size:13px; outline:none; }
.gte-name-input:focus { border-color:#cba6f7; }
.gte-name-input::placeholder { color:#585b70; }
.gte-preview-row { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding:6px 8px; background:#313244; border-radius:6px; }
.gte-preview-row input[type=checkbox] { accent-color:#cba6f7; width:15px; height:15px; cursor:pointer; }
.gte-preview-row label { font-size:12px; color:#a6adc8; cursor:pointer; user-select:none; }
.gte-mode-toggle { display:flex; align-items:center; gap:8px; margin-bottom:10px; padding:6px 8px; background:#313244; border-radius:6px; }
.gte-mode-toggle span { font-size:12px; color:#a6adc8; }
.gte-mode-toggle span.gte-mode-active { color:#cba6f7; font-weight:600; }
.gte-mode-switch { position:relative; width:36px; height:18px; background:#585b70; border-radius:9px; cursor:pointer; transition:background .2s; border:none; padding:0; }
.gte-mode-switch.gte-on { background:#cba6f7; }
.gte-mode-switch::after { content:''; position:absolute; top:2px; left:2px; width:14px; height:14px; background:#fff; border-radius:50%; transition:transform .2s; }
.gte-mode-switch.gte-on::after { transform:translateX(18px); }
.gte-btn { padding:7px 14px; border:none; border-radius:6px; font-size:12px; font-weight:600; cursor:pointer; transition:filter .15s; }
.gte-btn:hover { filter:brightness(1.15); }
.gte-btn-primary { background:#cba6f7; color:#1e1e2e; }
.gte-btn-secondary { background:#45475a; color:#cdd6f4; }
.gte-btn-danger { background:#f38ba8; color:#1e1e2e; }
.gte-btn-sm { padding:4px 10px; font-size:11px; }
.gte-btn-row { display:flex; gap:8px; margin-top:12px; flex-wrap:wrap; }
.gte-theme-card { display:flex; align-items:center; justify-content:space-between; padding:8px 10px; margin-bottom:6px; background:#313244; border-radius:8px; border:1px solid transparent; transition:border-color .15s; }
.gte-theme-card.gte-active-theme { border-color:#a6e3a1; }
.gte-theme-card-name { display:flex; align-items:center; gap:6px; font-size:13px; font-weight:500; color:#cdd6f4; overflow:hidden; max-width:180px; }
.gte-theme-name-text { overflow:hidden; text-overflow:ellipsis; white-space:nowrap; }
.gte-theme-badge { font-size:10px; font-weight:700; letter-spacing:0.2px; text-transform:uppercase; border-radius:999px; padding:2px 6px; line-height:1; flex-shrink:0; border:1px solid transparent; }
.gte-theme-badge-light { color:#1e1e2e; background:#f9e2af; border-color:#eabf5d; }
.gte-theme-badge-dark { color:#cdd6f4; background:#313244; border-color:#585b70; }
.gte-theme-card-actions { display:flex; gap:4px; }
.gte-empty-msg { color:#585b70; font-size:12px; text-align:center; padding:20px 0; }
.gte-io-section { margin-top:14px; padding-top:12px; border-top:1px solid #313244; }
`;
document.head.appendChild(css);
}
// ─── Modal ───────────────────────────────────────────────────────
let teModal = null;
function teBuildModal() {
const modal = document.createElement('div'); modal.id = 'gte-modal'; modal.className = 'gte-hidden';
modal.innerHTML = '<div id="gte-titlebar"><h2>\ud83c\udfa8 Theme Editor</h2><button id="gte-close-btn">×</button></div><div id="gte-tabs"><button class="gte-tab gte-active" data-panel="editor">Editor</button><button class="gte-tab" data-panel="manager">My Themes</button></div><div id="gte-body"><div id="gte-panel-editor" class="gte-panel gte-active"></div><div id="gte-panel-manager" class="gte-panel"></div></div>';
document.body.appendChild(modal);
modal.style.top = '60px'; modal.style.right = '20px';
modal.querySelector('#gte-close-btn').addEventListener('click', () => modal.classList.add('gte-hidden'));
modal.querySelectorAll('.gte-tab').forEach(tab => tab.addEventListener('click', () => {
modal.querySelectorAll('.gte-tab').forEach(t => t.classList.remove('gte-active'));
modal.querySelectorAll('.gte-panel').forEach(p => p.classList.remove('gte-active'));
tab.classList.add('gte-active'); modal.querySelector('#gte-panel-'+tab.dataset.panel).classList.add('gte-active');
if (tab.dataset.panel === 'manager') renderManager();
}));
// Dragging
let ox=0,oy=0,sx=0,sy=0;
const handle = modal.querySelector('#gte-titlebar');
handle.addEventListener('mousedown', e => { if (e.target.tagName==='BUTTON') return; e.preventDefault(); sx=e.clientX; sy=e.clientY; document.addEventListener('mousemove',drag); document.addEventListener('mouseup',dragEnd); });
function drag(e) { ox=sx-e.clientX; oy=sy-e.clientY; sx=e.clientX; sy=e.clientY; modal.style.top=Math.max(0,Math.min(window.innerHeight-50,modal.offsetTop-oy))+'px'; modal.style.left=Math.max(0,Math.min(window.innerWidth-100,modal.offsetLeft-ox))+'px'; modal.style.right='auto'; }
function dragEnd() { document.removeEventListener('mousemove',drag); document.removeEventListener('mouseup',dragEnd); }
return modal;
}
// ─── Editor Panel ────────────────────────────────────────────────
let curOverrides = {}, curEditName = '', curBase = 'dark', livePreview = false, previewTimer = null, simpleMode = true;
function scheduleLivePreview() {
if (!livePreview) return; clearTimeout(previewTimer);
previewTimer = setTimeout(async () => { const base = await getBaseStyle(curBase); if (!base) return; applyStyleToMap(buildStyle(base, curOverrides), curBase); }, 120);
}
function renderEditor(overrides, editName, base) {
curOverrides = overrides || {}; curEditName = editName || ''; curBase = base || 'dark';
const panel = document.getElementById('gte-panel-editor'); panel.innerHTML = '';
// Name + base selector
const nameRow = document.createElement('div'); nameRow.className = 'gte-name-row';
nameRow.innerHTML = '<input type="text" class="gte-name-input" id="gte-theme-name" placeholder="Theme name\u2026" value="'+teEscAttr(curEditName)+'" maxlength="50"><select id="gte-base-select" class="gte-name-input" style="flex:0 0 auto;width:auto;padding:6px 8px;"><option value="dark" '+(curBase==='dark'?'selected':'')+'>Dark base</option><option value="light" '+(curBase==='light'?'selected':'')+'>Light base</option></select>';
nameRow.querySelector('#gte-base-select').addEventListener('change', e => { curBase = e.target.value; renderEditor(curOverrides, curEditName, curBase); scheduleLivePreview(); });
panel.appendChild(nameRow);
// Live preview
const previewRow = document.createElement('div'); previewRow.className = 'gte-preview-row';
previewRow.innerHTML = '<input type="checkbox" id="gte-live-preview" '+(livePreview?'checked':'')+'><label for="gte-live-preview">Live preview</label>';
previewRow.querySelector('#gte-live-preview').addEventListener('change', e => { livePreview = e.target.checked; if (livePreview) scheduleLivePreview(); });
panel.appendChild(previewRow);
// Simple/Full toggle
const modeRow = document.createElement('div'); modeRow.className = 'gte-mode-toggle';
modeRow.innerHTML = '<span class="'+(simpleMode?'gte-mode-active':'')+'">Simple</span><button type="button" class="gte-mode-switch '+(simpleMode?'':'gte-on')+'" id="gte-mode-switch"></button><span class="'+(simpleMode?'':'gte-mode-active')+'">Full</span>';
modeRow.querySelector('#gte-mode-switch').addEventListener('click', () => { simpleMode = !simpleMode; renderEditor(curOverrides, curEditName, curBase); });
panel.appendChild(modeRow);
// Color rows
const layerGroups = simpleMode ? getSimpleLayers(curBase) : getEditableLayers(curBase);
for (const group of layerGroups) {
const header = document.createElement('div'); header.className = 'gte-group-header'; header.textContent = group.group; panel.appendChild(header);
for (const entry of group.layers) {
const keys = simpleMode ? entry.keys : [entry.id+'::'+entry.prop];
const firstKey = keys[0];
const currentColor = curOverrides[firstKey] || getDefaultColor(firstKey, curBase);
const isHidden = currentColor === 'transparent';
const displayColor = isHidden ? getDefaultColor(firstKey, curBase) : currentColor;
const row = document.createElement('div'); row.className = 'gte-color-row';
row.innerHTML = '<span class="gte-color-label">'+teEscHTML(simpleMode ? entry.label : entry.label)+'</span><div class="gte-color-input-wrap"><button type="button" class="gte-vis-btn '+(isHidden?'gte-layer-hidden':'')+'" title="Toggle visibility">'+(isHidden?'\ud83d\udeab':'\ud83d\udc41\ufe0f')+'</button><span class="gte-hex-display '+(isHidden?'gte-hidden-color':'')+'">'+(isHidden?'hidden':currentColor)+'</span><input type="color" class="gte-color-input" value="'+displayColor+'" '+(isHidden?'disabled':'')+'><button type="button" class="gte-reset-btn" title="Reset to default">\u21bb</button></div>';
const colorInput = row.querySelector('.gte-color-input'), hexDisplay = row.querySelector('.gte-hex-display'), visBtn = row.querySelector('.gte-vis-btn'), resetBtn = row.querySelector('.gte-reset-btn');
colorInput.addEventListener('input', e => { for (const k of keys) curOverrides[k] = e.target.value; hexDisplay.textContent = e.target.value; scheduleLivePreview(); });
visBtn.addEventListener('click', () => {
const nowHidden = curOverrides[firstKey] === 'transparent';
if (nowHidden) { const restored = colorInput.value; for (const k of keys) curOverrides[k] = restored; hexDisplay.textContent = restored; hexDisplay.classList.remove('gte-hidden-color'); colorInput.disabled = false; visBtn.textContent = '\ud83d\udc41\ufe0f'; visBtn.classList.remove('gte-layer-hidden'); }
else { for (const k of keys) curOverrides[k] = 'transparent'; hexDisplay.textContent = 'hidden'; hexDisplay.classList.add('gte-hidden-color'); colorInput.disabled = true; visBtn.textContent = '\ud83d\udeab'; visBtn.classList.add('gte-layer-hidden'); }
scheduleLivePreview();
});
resetBtn.addEventListener('click', () => { const def = getDefaultColor(firstKey, curBase); for (const k of keys) curOverrides[k] = def; colorInput.value = def; hexDisplay.textContent = def; hexDisplay.classList.remove('gte-hidden-color'); colorInput.disabled = false; visBtn.textContent = '\ud83d\udc41\ufe0f'; visBtn.classList.remove('gte-layer-hidden'); scheduleLivePreview(); });
panel.appendChild(row);
}
}
// Action buttons
const btnRow = document.createElement('div'); btnRow.className = 'gte-btn-row';
btnRow.innerHTML = '<button class="gte-btn gte-btn-primary" id="gte-save-apply">Save & Apply</button><button class="gte-btn gte-btn-secondary" id="gte-load-current">Load Current</button><button class="gte-btn gte-btn-secondary" id="gte-export-json">Export JSON</button><button class="gte-btn gte-btn-secondary" id="gte-import-json-btn">Import JSON</button><input type="file" id="gte-import-json-file" accept=".json" style="display:none">';
panel.appendChild(btnRow);
panel.querySelector('#gte-save-apply').addEventListener('click', async () => {
const nameInput = document.getElementById('gte-theme-name'); const name = nameInput.value.trim();
if (!name) { nameInput.style.borderColor = '#f38ba8'; nameInput.focus(); setTimeout(() => nameInput.style.borderColor = '', 1500); return; }
const themes = teLoadThemes(); themes[name] = { overrides: { ...curOverrides }, base: curBase, createdAt: themes[name]?.createdAt || Date.now(), updatedAt: Date.now() };
teSaveThemes(themes); teSetActive(name); curEditName = name;
const base = await getBaseStyle(curBase); if (!base) return;
const style = buildStyle(base, curOverrides); applyStyleToMap(style, curBase); persistTheme(style);
teShowToast('Theme "'+name+'" saved & applied!');
});
panel.querySelector('#gte-load-current').addEventListener('click', async () => {
const map = teGetMap(); if (!map) return teShowToast('Map not ready.', true);
const style = map.getStyle(); if (!style) return teShowToast('No style loaded.', true);
renderEditor(readColorsFromStyle(style, curBase), curEditName, curBase); teShowToast('Loaded colors from current map.');
});
panel.querySelector('#gte-export-json').addEventListener('click', async () => {
const base = await getBaseStyle(curBase); if (!base) return;
const style = buildStyle(base, curOverrides); const name = document.getElementById('gte-theme-name').value.trim() || 'custom_theme'; style.name = name;
const blob = new Blob([JSON.stringify(style, null, 2)], { type: 'application/json' }); const a = document.createElement('a'); a.href = URL.createObjectURL(blob); a.download = name.replace(/[^a-z0-9_-]/gi, '_')+'.json'; a.click(); URL.revokeObjectURL(a.href);
});
panel.querySelector('#gte-import-json-btn').addEventListener('click', () => panel.querySelector('#gte-import-json-file').click());
panel.querySelector('#gte-import-json-file').addEventListener('change', async e => {
const file = e.target.files[0]; if (!file) return;
try { const text = await file.text(); const style = JSON.parse(text); if (!style.layers || !Array.isArray(style.layers)) return teShowToast('Invalid theme JSON.', true);
const overrides = readAllColorsFromStyle(style); const name = style.name || file.name.replace(/\.json$/i, '');
const bg = overrides['background::background-color']; const detectedBase = bg ? (isColorDark(bg) ? 'dark' : 'light') : 'dark';
renderEditor(overrides, name, detectedBase); teShowToast('Imported "'+name+'" — adjust and Save & Apply.');
} catch { teShowToast('Failed to parse JSON file.', true); }
e.target.value = '';
});
}
// ─── Manager Panel ───────────────────────────────────────────────
function renderManager() {
const panel = document.getElementById('gte-panel-manager');
const themes = teLoadThemes(); const activeTheme = teGetActive();
const names = Object.keys(themes).sort((a, b) => {
const pa = a === 'Default' ? 0 : a === 'Default Dark' ? 1 : 2;
const pb = b === 'Default' ? 0 : b === 'Default Dark' ? 1 : 2;
return pa !== pb ? pa - pb : a.localeCompare(b);
});
let html = '';
if (names.length === 0) { html = '<div class="gte-empty-msg">No saved themes yet.<br>Use the Editor tab to create one!</div>'; }
else { for (const name of names) { const theme = themes[name]; const isActive = name === activeTheme; const isBundled = theme.bundled; const isLight = (theme.base||'dark') === 'light';
html += '<div class="gte-theme-card '+(isActive?'gte-active-theme':'')+'"><span class="gte-theme-card-name" title="'+teEscAttr(name)+'"><span class="gte-theme-name-text">'+teEscHTML(name)+(isActive?' \u2713':'')+(isBundled?' \ud83d\udccc':'')+'</span><span class="gte-theme-badge '+(isLight?'gte-theme-badge-light':'gte-theme-badge-dark')+'">'+(isLight?'Light':'Dark')+'</span></span><div class="gte-theme-card-actions"><button class="gte-btn gte-btn-primary gte-btn-sm" data-action="apply" data-name="'+teEscAttr(name)+'">Apply</button><button class="gte-btn gte-btn-secondary gte-btn-sm" data-action="edit" data-name="'+teEscAttr(name)+'">Edit</button>'+(!isBundled?'<button class="gte-btn gte-btn-danger gte-btn-sm" data-action="delete" data-name="'+teEscAttr(name)+'">Delete</button>':'<span class="gte-btn gte-btn-danger gte-btn-sm" style="opacity:0.5;cursor:not-allowed;" title="Built-in">Delete</span>')+'</div></div>'; } }
html += '<div class="gte-io-section"><button class="gte-btn gte-btn-secondary" id="gte-restore-default" style="width:100%">Restore Default Theme</button></div>';
panel.innerHTML = html;
panel.querySelectorAll('[data-action]').forEach(btn => btn.addEventListener('click', async () => {
const action = btn.dataset.action, name = btn.dataset.name;
if (action === 'apply') await teApplyByName(name);
if (action === 'edit') teEditTheme(name);
if (action === 'delete') teDeleteTheme(name);
}));
const restoreBtn = panel.querySelector('#gte-restore-default');
if (restoreBtn) restoreBtn.addEventListener('click', async () => {
teSetActive(''); localStorage.removeItem('customTheme');
const uc = teGetUserConfig(); if (uc) uc.theme = 'default'; localStorage.setItem('userConfig', JSON.stringify(uc));
try { const base = await getBaseStyle('light'); if (base) { const map = teGetMap(); if (map) map.setStyle(base); } } catch {}
renderManager(); teShowToast('Restored default theme.');
});
}
async function teApplyByName(name) {
const themes = teLoadThemes(); const theme = themes[name]; if (!theme) return;
const base = await getBaseStyle(theme.base || 'dark'); if (!base) return;
const style = buildStyle(base, theme.overrides); applyStyleToMap(style, theme.base || 'dark');
teSetActive(name); persistTheme(style); renderManager(); teShowToast('Applied "'+name+'".');
}
function teEditTheme(name) {
const themes = teLoadThemes(); const theme = themes[name]; if (!theme) return;
teModal.querySelectorAll('.gte-tab').forEach(t => t.classList.remove('gte-active'));
teModal.querySelectorAll('.gte-panel').forEach(p => p.classList.remove('gte-active'));
teModal.querySelector('[data-panel="editor"]').classList.add('gte-active');
teModal.querySelector('#gte-panel-editor').classList.add('gte-active');
renderEditor(theme.overrides, name, theme.base || 'dark');
}
function teDeleteTheme(name) {
const themes = teLoadThemes(); if (themes[name]?.bundled) { teShowToast('Cannot delete built-in themes.', true); return; }
if (!confirm('Delete theme "'+name+'"?')) return;
delete themes[name]; teSaveThemes(themes); if (teGetActive() === name) teSetActive(''); renderManager(); teShowToast('Deleted "'+name+'".');
}
// ─── Init ────────────────────────────────────────────────────────
teInjectCSS();
teModal = teBuildModal();
renderEditor({}, '', 'dark');
// Expose API for dropdown flyout
_themeEditor = {
loadThemes: teLoadThemes,
getActiveThemeName: teGetActive,
applyThemeByName: teApplyByName,
toggleModal: () => {
const isHidden = teModal.classList.contains('gte-hidden');
if (isHidden) { teModal.classList.remove('gte-hidden'); if (!document.getElementById('gte-theme-name')) renderEditor({}, ''); }
else teModal.classList.add('gte-hidden');
}
};
// Re-apply active theme on load
(async () => {
let tries = 0;
while (!teGetMap() && tries < 60) { await new Promise(r => setTimeout(r, 500)); tries++; }
const activeName = teGetActive();
if (activeName) {
const themes = teLoadThemes();
if (themes[activeName]) {
const themeBase = themes[activeName].base || 'dark';
const base = await getBaseStyle(themeBase);
if (base) { const style = buildStyle(base, themes[activeName].overrides); applyStyleToMap(style, themeBase); persistTheme(style); }
}
}
})();
})();
_featureStatus.themeEditor = 'ok';
console.log('[GeoPixelcons++] \u2705 Theme Editor loaded');
} catch (err) {
_featureStatus.themeEditor = 'error';
console.error('[GeoPixelcons++] \u274c Theme Editor failed:', err);
}
}
// ============================================================
// AUTO-SCREENSHOT ON PAINT (fetch interceptor)
// ============================================================
if (_settings.regionScreenshot) {
try {
const _targetWindow = (typeof unsafeWindow !== 'undefined') ? unsafeWindow : window;
const _origFetch = _targetWindow.fetch.bind(_targetWindow);
_targetWindow.fetch = async function(...args) {
const response = await _origFetch(...args);
try {
const url = typeof args[0] === 'string' ? args[0] : args[0]?.url || '';
if (url.includes('/PlacePixels') && isAutoScreenshotEnabled() && _regionScreenshot) {
const coords = loadCachedCoords();
if (coords && response.ok) {
// Small delay to let the tile cache update
setTimeout(() => {
_regionScreenshot.silentDownload(coords);
}, 800);
}
}
} catch {}
return response;
};
console.log('[GeoPixelcons++] \u2705 Auto-screenshot fetch hook installed');
} catch (err) {
console.error('[GeoPixelcons++] \u274c Auto-screenshot hook failed:', err);
}
}
console.log('[GeoPixelcons++] v' + VERSION + ' initialized. Features:', _featureStatus);
})();