Sleazy Fork is available in English.

GeoPixels++

QOL features for https://geopixels.net/ with color palette management

You will need to install an extension such as Tampermonkey, Greasemonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Violentmonkey to install this script.

You will need to install an extension such as Tampermonkey or Userscripts to install this script.

You will need to install an extension such as Tampermonkey to install this script.

You will need to install a user script manager extension to install this script.

(I already have a user script manager, let me install it!)

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install an extension such as Stylus to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

You will need to install a user style manager extension to install this style.

(I already have a user style manager, let me install it!)

// ==UserScript==
// @name         GeoPixels++
// @description  QOL features for https://geopixels.net/ with color palette management
// @author       Thinkbot
// @contributor  Observable
// @contributor  Phlegethonia
// @contributor  h65e3j
// @version      0.7.0
// @match        https://*.geopixels.net/*
// @namespace    https://github.com/thin-kbot
// @homepage     https://github.com/thin-kbot/geopixels-plusplus
// @icon         https://raw.githubusercontent.com/thin-kbot/geopixels-plusplus/refs/heads/main/img/icon.png
// @license      GPL-3.0-or-later; http://www.gnu.org/licenses/gpl-3.0.txt
// @supportURL   https://ko-fi.com/thinkbot
// @grant        unsafeWindow
// ==/UserScript==

(function () {
	//#region Global variables
	const usw = unsafeWindow;

	const LOG_LEVELS = {
		error: { label: "ERR", color: "red" },
		info: { label: "INF", color: "lime" },
		warn: { label: "WRN", color: "yellow" },
		debug: { label: "DBG", color: "cyan" },
	};

	const STORAGE_KEYS = {
		censor: "geo++_censorRects",
		keybinds: "geo++_keybinds",
		settings: "geo++_settings",
	};

	const SOUNDS = [
		{
			name: "Pixel placement",
			variable: "soundBufferPop",
		},
		{
			name: '"Paint"',
			variable: "soundBufferThump",
		},
		{
			name: "Max charges reached",
			variable: "soundBufferMaxCharges",
		},
	];
	let soundToChangeIdx = 0;

	let censorRects;
	let censorMode = false;
	let isDraggingCensor = false;
	let censorStartPoint = null;
	let tempCensorRect = null;
	let oldCensorRect = null;
	let censorCanvas;

	const KEY_BINDINGS = {
		toggleGhost: {
			text: "Toggle ghost image",
			defaultKey: "y",
			keydown: () => document.getElementById("ghost-canvas").toggleAttribute("hidden"),
		},
		placeGhost: {
			text: "Set ghost image's top left",
			defaultKey: "e",
			keydown: () => {
				const pos = screenPointToGrid(document.getElementById("pixel-canvas"), mouseX, mouseY);
				ghostImageTopLeft = pos;
				localStorage.setItem("ghostImageCoords", JSON.stringify(pos));
				log(LOG_LEVELS.debug, "Ghost image position set.");
				drawGhostImageOnCanvas();
			},
		},
		toggleCensorMode: {
			text: "Toggle Censor Mode",
			defaultKey: "m",
			keydown: () => toggleCensorMode(),
		},
		addCensorRect: {
			text: "Add one Censor Rect at Mouse",
			defaultKey: "l",
			keydown: (e) => {
				if (e.repeat) return;
				if (!censorMode) enableCensorMode();
				startCensorDraw();
			},
			keyup: (e) => {
				if (e.repeat || !isDraggingCensor) return;
				endCensorDraw();
				if (censorMode) disableCensorMode();
			},
		},
	};
	let mouseX, mouseY;

	const THEMES = {
		system: {
			name: "System default",
			style: () =>
				window.matchMedia("(prefers-color-scheme: dark)").matches ? THEMES.simple_black.style : "",
		},
		default: {
			name: "GeoPixels++ Default",
			style: "",
		},
		simple_black: {
			name: "Simple Black",
			style:
				":root{color-scheme:dark;--color-red-900:#ffe2e2;--color-red-800:#ffc9c9;--color-red-700:#ffa2a2;--color-red-600:#ff6467;--color-red-500:#fb2c36;--color-red-400:#e7000b;--color-red-300:#c10007;--color-red-200:#9f0712;--color-red-100:#82181a;--color-red-50:#460809;--color-orange-800:#ffd6a7;--color-orange-700:#ffb869;--color-orange-600:#ff8903;--color-orange-500:#ff6900;--color-orange-400:#f54900;--color-orange-200:#9f2d00;--color-orange-100:#7e2a0c;--color-orange-50:#441306;--color-yellow-900:#fef9c2;--color-yellow-800:#fff085;--color-yellow-700:#ffdf20;--color-yellow-600:#fdc700;--color-yellow-500:#f0b100;--color-yellow-400:#d08700;--color-yellow-300:#a65f00;--color-yellow-200:#894b00;--color-yellow-100:#733e0a;--color-yellow-50:#432004;--color-green-900:#dcfce7;--color-green-700:#7bf1a7;--color-green-600:#06df72;--color-green-500:#00c950;--color-green-200:#026630;--color-green-100:#0d542b;--color-green-50:#032e15;--color-sky-800:#b8e6fe;--color-sky-500:#00a6f4;--color-sky-200:#00598a;--color-sky-50:#052f4a;--color-blue-900:#dbeafe;--color-blue-800:#bedbff;--color-blue-700:#8ec5ff;--color-blue-600:#50a2ff;--color-blue-500:#2b7fff;--color-blue-400:#155dfb;--color-blue-300:#1447e6;--color-blue-200:#193cb8;--color-blue-100:#1c398e;--color-blue-50:#162556;--color-indigo-700:#a3b3ff;--color-indigo-600:#7c86ff;--color-indigo-500:#615fff;--color-purple-600:#c27aff;--color-purple-500:#ad46ff;--color-slate-700:#cad5e2;--color-slate-500:#62748e;--color-gray-900:#f3f4f6;--color-gray-800:#e5e7eb;--color-gray-700:#d1d5db;--color-gray-600:#99a1af;--color-gray-500:#6a7282;--color-gray-400:#4a5565;--color-gray-300:#364153;--color-gray-200:#1e2939;--color-gray-100:#101828;--color-gray-50:#030712;--color-white:#000;color:#fff}select{background:#000}.keybind-input{color:#d1d5db;background-color:#1f2937;border-color:#4b5563}#hoverProfileImg,#hoverBannerImg{background:#fff}#visualIdentitySection svg,#visualIdentitySection span{color:#fff;}",
		},
	};

	const SETTINGS_CATEGORIES = {
		appearance: "Appearance",
		censor: "Censor",
		keybinds: "Keybindings",
	};

	const SETTINGS = {
		theme: {
			name: "UI Theme",
			element: "select",
			category: SETTINGS_CATEGORIES.appearance,
			options: Object.entries(THEMES).map(([id, { name }]) => ({ value: id, text: name })),
			default: "system",
			apply: (val) => {
				let themeStyle = document.getElementById("gpp-theme-style");
				if (!themeStyle) {
					themeStyle = document.createElement("style");
					themeStyle.id = "gpp-theme-style";
					document.head.appendChild(themeStyle);
				}
				themeStyle.innerHTML =
					typeof THEMES[val]?.style === "function" ? THEMES[val].style() : THEMES[val]?.style ?? "";
			},
			attributes: {
				onchange: (e) => {
					SETTINGS.theme.apply(e.target.value);
					saveSetting("theme", e.target.value);
				},
			},
		},
		censorColor: {
			name: "Censor rects color",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: "#000",
			attributes: {
				type: "color",
				onchange: (e) => {
					saveSetting("censorColor", e.target.value);
					drawCensorRects();
				},
				oninput: (e) => {
					settings.censorColor = e.target.value;
					drawCensorRects();
				},
			},
		},
		censorAlpha: {
			name: "Censor rects alpha",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: 1,
			attributes: {
				type: "range",
				min: 0,
				max: 1,
				step: 0.01,
				onchange: (e) => {
					saveSetting("censorAlpha", e.target.value);
					drawCensorRects();
				},
				oninput: (e) => {
					settings.censorAlpha = e.target.value;
					drawCensorRects();
				},
			},
		},
		doInteractCensor: {
			name: "Interact with censors out of censor mode",
			element: "input",
			category: SETTINGS_CATEGORIES.censor,
			default: true,
			attributes: {
				type: "checkbox",
				onchange: (e) => saveSetting("doInteractCensor", e.target.checked),
			},
		},
		censorTextarea: {
			name: "Censor config",
			element: "textarea",
			category: SETTINGS_CATEGORIES.censor,
			value: () => JSON.stringify(getCensorRects()),
			init: (elm) =>
				document.addEventListener(
					"censorRectsChanged",
					() => (elm.value = JSON.stringify(getCensorRects()))
				),
			reset: (elm) => {
				elm.value = SETTINGS.censorTextarea.value();
				elm.dispatchEvent(new Event("input"));
			},
			attributes: {
				onchange: (e) => {
					let config;
					try {
						config = JSON.parse(e.target.value);
					} catch (err) {
						return alert(`Invalid json: ${err.message}`);
					}

					if (!isValidCensorConfig(config)) return alert("Invalid censor config");

					saveCensorRects(config);
					validateInput(e.target);
					log(LOG_LEVELS.info, "replaced Censor rects with", config);
				},
				oninput: (e) => {
					if (!isJsonString(e.target.value) || !isValidCensorConfig(JSON.parse(e.target.value)))
						e.target.style.borderColor = "red";
					else e.target.style.borderColor = "";
				},
			},
		},
		openCensorConfig: {
			name: "Open Censors Config",
			element: "button",
			category: SETTINGS_CATEGORIES.censor,
			attributes: { onclick: createCensorsConfigModal },
		},
		...Object.fromEntries(
			Object.entries(KEY_BINDINGS).map(([id, binding]) => [
				`keybind_${id}`,
				{
					name: `${binding.text}`,
					element: "input",
					category: SETTINGS_CATEGORIES.keybinds,
					value: () => keybinds[id] || "",
					...(binding.defaultKey && {
						reset: (elm) => {
							elm.value = binding.defaultKey;
							elm.dispatchEvent(new Event("change"));
						},
					}),
					attributes: {
						type: "keybind",
						onclick: (e) => e.target.select(),
						onchange: (e) => {
							saveKeybind(id, e.target.value.trim().toLowerCase());
							validateInput(e.target);
						},
					},
				},
			])
		),
	};
	//#endregion Global variables

	//#region Utils
	function log(lvl, ...args) {
		console.log(
			`%c[GeoPixels++] %c[${lvl.label}]`,
			"color: mediumvioletred;",
			`color:${lvl.color};`,
			...args
		);
	}

	function isJsonString(str) {
		try {
			JSON.parse(str);
		} catch {
			return false;
		}
		return true;
	}

	function screenPointToGrid(canvas, mX, mY) {
		const rect = canvas.getBoundingClientRect();

		// Convert screen coordinates to map coordinates
		const lngLat = map.unproject([mX - rect.left, mY - rect.top]);
		const merc = turf.toMercator([lngLat.lng, lngLat.lat]);

		return {
			gridX: Math.round(merc[0] / gridSize),
			gridY: Math.round(merc[1] / gridSize),
		};
	}

	function validateInput(elm) {
		elm.style.borderColor = "green";
		const td = parseFloat(getComputedStyle(elm).transitionDuration) * 2;
		setTimeout(() => (elm.style.borderColor = ""), (td ?? 0.5) * 1000);
	}

	function getAllCoordsBetween(a, b, size = 1) {
		const coords = [];
		for (let x = Math.min(a.x, b.x); x <= Math.max(a.x, b.x); x += size)
			for (let y = Math.min(a.y, b.y); y <= Math.max(a.y, b.y); y += size) coords.push({ x, y });
		return coords;
	}

	const snapToTile = (value) => Math.floor(value / SYNC_TILE_SIZE) * SYNC_TILE_SIZE;
	//#endregion Utils

	//#region Color Utils
	const cToHex = (c) => (+c).toString(16).padStart(2, "0");
	const hexToC = (h) => parseInt(h, 16);
	function toFullHex(hex) {
		hex = hex.toLowerCase();
		if (hex.length === 4 || hex.length === 5)
			hex = "#" + [...hex.slice(1)].map((c) => c + c).join("");
		if (hex.length === 7) hex += "ff";
		return hex;
	}
	const toOutputHex = (hex) => toFullHex(hex).slice(0, 7);
	const rgbaToHex = (r, g, b, a = 255) => "#" + cToHex(r) + cToHex(g) + cToHex(b) + cToHex(a);
	const rgbToRgbaString = ({ r, g, b }) => `rgba(${r},${g},${b},1)`;
	function hexToRgba(hex) {
		const h = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})?$/i.exec(hex);
		return { r: hexToC(h[1]), g: hexToC(h[2]), b: hexToC(h[3]), a: h[4] ? hexToC(h[4]) : 255 };
	}
	function parseColor(colorStr) {
		const h = /^#?([a-f\d]{6}(?:[a-f\d]{2})?|[a-f\d]{3,4})/i.exec(colorStr);
		if (h) return toFullHex(`#${h[1]}`);
		log(LOG_LEVELS.error, "Invalid color format:", colorStr);
		return null;
	}
	Array.prototype.toOutputString = function () {
		return this.map((c) => toOutputHex(c)).join("\n");
	};
	function colorsStringToHexArray(colorsString) {
		return colorsString
			.trim()
			.split("\n")
			.filter(Boolean)
			.map((c) => parseColor(c))
			.filter(Boolean);
	}
	//#endregion Color Utils

	//#region User Palette Functions
	const getUserPalette = () => Colors.slice(0, -1).map(toFullHex);

	function isInUserPalette(hex) {
		const is = getUserPalette().includes(hex);
		if (!is) log(LOG_LEVELS.warn, "Color not in user palette:", toOutputHex(hex));
		return is;
	}

	function setEnabledUserPalette(hexArray) {
		const colors = getUserPalette();
		activeColors = hexArray.map((h) => colors.indexOf(h)).filter((i) => i !== -1);
		activeColors.push(Colors.length - 1);

		localStorage.setItem("activeColors", JSON.stringify(activeColors));

		SetColorsProfile();
		SetColors();
		if (activeColors.length > 0 && (!pixelColor || !colors.includes(toFullHex(pixelColor))))
			changeColor(Colors[activeColors[0]]);

		log(LOG_LEVELS.info, "Enabled palette updated with", activeColors.length, "colors");
	}
	//#endregion User Palette Functions

	//#region Ghost Image Palette Functions
	function getGhostImageHexColors() {
		return ghostPaletteColors.map(({ hex }) => toFullHex(hex));
	}

	function getAvailableGhostColors() {
		const colors = getUserPalette();
		return getGhostImageHexColors().filter((c) => colors.includes(c));
	}

	function getEnabledGhostPalette() {
		if (ghostActivePaletteColors && ghostActivePaletteColors.size > 0)
			return Array.from(ghostActivePaletteColors).map((rgba) => rgbaToHex(...rgba.match(/\d+/g)));

		log(LOG_LEVELS.info, "No ghost colors enabled");
		return "";
	}

	function isInGhostPalette(hex) {
		const is = getGhostImageHexColors().includes(hex);
		if (!is) log(LOG_LEVELS.warn, "Color not in ghost palette:", hex);
		return is;
	}

	function setEnabledGhostPalette(hexArray) {
		if (!getGhostImageHexColors() || getGhostImageHexColors().length === 0)
			return log(LOG_LEVELS.warn, "No ghost image loaded");

		ghostActivePaletteColors = new Set(hexArray.map((h) => rgbToRgbaString(hexToRgba(h))));
		populateColorPaletteUI();
		regenerateGhostCanvas();
		drawGhostImageOnCanvas();

		log(LOG_LEVELS.info, "Ghost palette updated with", hexArray.length, "enabled colors");
	}
	//#endregion Ghost Image Palette Functions

	//#region Navigation
	function gotoCoords({ x, y }, zoom = 16) {
		const gSize = usw.gridSize || gridSize || 25;

		if (isNaN(x) || isNaN(y)) {
			log(LOG_LEVELS.error, "Invalid coordinates");
			return;
		}

		const mercX = x * gSize;
		const mercY = y * gSize;
		const lngLat = turf.toWgs84([mercX, mercY]);

		map.setCenter([lngLat[0], lngLat[1]]);
		map.setZoom(zoom);

		log(LOG_LEVELS.debug, `Moved to (${x}, ${y})`);
	}

	function gotoFromInput(input = "") {
		let coordString;

		if (!input.length) {
			const urlParams = new URLSearchParams(window.location.search);
			coordString = urlParams.get("coords") || urlParams.get("key");
			if (!coordString) {
				log(LOG_LEVELS.error, "No coordinates provided and none found in URL");
				return;
			}
			log(LOG_LEVELS.debug, `Using coords from page URL: ${coordString}`);
		} else if (input.includes("http") || input.includes("?coords=") || input.includes("?key=")) {
			try {
				const url = new URL(input.includes("http") ? input : "https://www.geopixels.net/" + input);
				coordString = url.searchParams.get("coords") || url.searchParams.get("key");
				if (!coordString) {
					log(LOG_LEVELS.error, "No coords parameter found in URL");
					return;
				}
				log(LOG_LEVELS.debug, `Extracted coords from URL: ${coordString}`);
			} catch (e) {
				log(LOG_LEVELS.log, "Invalid URL format");
				return;
			}
		} else {
			coordString = input;
		}

		const parts = coordString.trim().split(",");
		if (parts.length !== 2) {
			log(LOG_LEVELS.error, 'Invalid format. Use: "gridX,gridY"');
			return;
		}

		gotoCoords({ x: +parts[0], y: +parts[1] });
	}
	//#endregion Navigation

	//#region Sound
	function bto64(blob) {
		return new Promise((resolve) => {
			const reader = new FileReader();
			reader.onloadend = () => resolve(reader.result);
			reader.readAsDataURL(blob);
		});
	}

	function setBlobAsSound(s, blob) {
		blob
			.arrayBuffer()
			.then((arrayBuffer) => audioContext.decodeAudioData(arrayBuffer))
			.then((decodedAudio) => {
				eval(`${s.variable} = decodedAudio`);
			})
			.catch((err) => (LOG_LEVELS.error, "Error loading audio:", err));
	}

	function saveBlobSound(s, blob) {
		setBlobAsSound(s, blob);
		bto64(blob).then((b64s) => localStorage.setItem(`geo++_${s.variable}`, b64s));
	}

	function loadBlobSounds() {
		for (const s of SOUNDS) {
			let storageItem = localStorage.getItem(`geo++_${s.variable}`);
			if (storageItem) {
				fetch(storageItem)
					.then((res) => res.blob())
					.then((blob) => setBlobAsSound(s, blob));
			}
		}
	}
	loadBlobSounds();

	function waitForSounds(callback) {
		let tries = 0;
		function check() {
			if (SOUNDS.every((s) => eval(`${s.variable} !== null`))) callback();
			else if (tries++ < 100) setTimeout(check, 100);
		}
		check();
	}

	waitForSounds(loadBlobSounds);
	//#endregion Sound

	//#region Censor
	function isValidCensorConfig(json) {
		return (
			Array.isArray(json) &&
			json.every(
				(obj) =>
					typeof obj === "object" &&
					obj !== null &&
					["gridX", "gridY", "width", "height"].every((key) => Number.isInteger(obj[key]))
			)
		);
	}

	censorCanvas = document.createElement("canvas");
	censorCanvas.id = "censor-canvas";
	censorCanvas.className = "inset-0 absolute pointer-events-none";
	document.body.appendChild(censorCanvas);

	//#region Drawing censor
	function enableCensorMode() {
		censorMode = true;
		censorCanvas.style.pointerEvents = "auto";
		censorCanvas.style.cursor = "crosshair";

		log(LOG_LEVELS.debug, "Censor mode enabled");
	}

	function disableCensorMode() {
		censorMode = false;
		isDraggingCensor = false;
		censorStartPoint = null;
		tempCensorRect = null;
		censorCanvas.style.pointerEvents = "none";
		log(LOG_LEVELS.debug, "Censor mode disabled");
	}

	function toggleCensorMode() {
		if (censorMode) disableCensorMode();
		else enableCensorMode();
	}

	function startCensorDraw() {
		isDraggingCensor = true;
		censorStartPoint = screenPointToGrid(censorCanvas, mouseX, mouseY);
	}

	function endCensorDraw() {
		if (tempCensorRect && tempCensorRect.width > 0 && tempCensorRect.height > 0)
			addCensorRect(tempCensorRect);

		isDraggingCensor = false;
		censorStartPoint = null;
		tempCensorRect = null;

		drawCensorRects();
	}

	censorCanvas.addEventListener("wheel", (e) =>
		map.getCanvas().dispatchEvent(new WheelEvent(e.type, e))
	);

	window.addEventListener("mousedown", (e) => {
		if (
			(!censorMode && !settings.doInteractCensor) ||
			(e.target !== censorCanvas && e.target !== map.getCanvas())
		)
			return;
		if (censorMode) {
			if (e.button === 0) startCensorDraw();
			else if (e.button === 1) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		}
		e.preventDefault();
		e.stopPropagation();
		if (e.button === 2) {
			const point = screenPointToGrid(censorCanvas, e.clientX, e.clientY);
			const rects = getCensorRects();
			const rectIdx = rects.findIndex(
				(rect) =>
					point.gridX >= rect.gridX &&
					point.gridX < rect.gridX + rect.width &&
					point.gridY >= rect.gridY &&
					point.gridY < rect.gridY + rect.height
			);
			if (rectIdx !== -1) {
				if (e.ctrlKey) {
					const rect = rects[rectIdx];
					const isTop = rect.gridY + rect.height / 2 > point.gridY;
					const isLeft = rect.gridX + rect.width / 2 > point.gridX;
					isDraggingCensor = true;
					censorStartPoint = {
						gridX: rect.gridX + (isLeft ? rect.width : 0),
						gridY: rect.gridY + (isTop ? rect.height : 0),
					};
					oldCensorRect = rects.splice(rectIdx, 1)[0];
					saveCensorRects(rects);
					window.dispatchEvent(new MouseEvent("mousemove", e));
				} else if (confirm(`Remove censor rect?`)) {
					rects.splice(rectIdx, 1);
					saveCensorRects(rects);
				}
			}
		}
	});

	window.addEventListener("mousemove", (e) => {
		if (censorMode && e.buttons == 4) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		if ((!censorMode && !settings.doInteractCensor) || !isDraggingCensor || !censorStartPoint)
			return;

		e.preventDefault();
		e.stopPropagation();

		const point = screenPointToGrid(censorCanvas, e.clientX, e.clientY);

		const minX = Math.min(censorStartPoint.gridX, point.gridX);
		const maxX = Math.max(censorStartPoint.gridX, point.gridX);
		const minY = Math.min(censorStartPoint.gridY, point.gridY);
		const maxY = Math.max(censorStartPoint.gridY, point.gridY);

		tempCensorRect = {
			gridX: minX,
			gridY: minY,
			width: maxX - minX + 1,
			height: maxY - minY + 1,
		};

		drawCensorRects();
	});

	window.addEventListener("mouseup", (e) => {
		if (censorMode && e.button === 1) map.getCanvas().dispatchEvent(new MouseEvent(e.type, e));
		if (
			(!censorMode && !settings.doInteractCensor) ||
			!isDraggingCensor ||
			(e.button !== 0 && e.button !== 2)
		)
			return;
		e.preventDefault();
		e.stopPropagation();
		endCensorDraw();
	});

	window.addEventListener("mouseleave", () => {
		if (!censorMode && !settings.doInteractCensor) return;
		if (isDraggingCensor) {
			if (oldCensorRect) addCensorRect(oldCensorRect);
			oldCensorRect = null;
			isDraggingCensor = false;
			censorStartPoint = null;
			tempCensorRect = null;
			drawCensorRects();
		}
	});

	function drawCensorRect(ctx, rect, gSize, color, a = 1) {
		const topLeftMerc = [(rect.gridX - 0.5) * gSize, (rect.gridY - 0.5 + rect.height) * gSize];
		const bottomRightMerc = [(rect.gridX - 0.5 + rect.width) * gSize, (rect.gridY - 0.5) * gSize];

		const topLeftScreen = map.project(turf.toWgs84(topLeftMerc));
		const bottomRightScreen = map.project(turf.toWgs84(bottomRightMerc));

		const screenWidth = Math.abs(bottomRightScreen.x - topLeftScreen.x);
		const screenHeight = Math.abs(bottomRightScreen.y - topLeftScreen.y);

		if (
			topLeftScreen.x + screenWidth < 0 ||
			topLeftScreen.x > ctx.canvas.width ||
			topLeftScreen.y + screenHeight < 0 ||
			topLeftScreen.y > ctx.canvas.height
		)
			return;

		ctx.fillStyle = color;
		ctx.globalAlpha = a;
		ctx.fillRect(topLeftScreen.x, topLeftScreen.y, screenWidth, screenHeight);
	}
	//#endregion Drawing censor

	function getCensorRects() {
		if (!Array.isArray(censorRects)) {
			const c = localStorage.getItem(STORAGE_KEYS.censor);
			censorRects = c && isJsonString(c) ? JSON.parse(c) : [];
		}
		return censorRects;
	}
	function saveCensorRects(r) {
		censorRects = r;
		localStorage.setItem(STORAGE_KEYS.censor, JSON.stringify(r));
		drawCensorRects();
		document.dispatchEvent(new CustomEvent("censorRectsChanged"));
	}
	const addCensorRect = (rect) => saveCensorRects([...getCensorRects(), rect]);

	function drawCensorRects() {
		const pixelCanvas = document.getElementById("pixel-canvas");
		if (!pixelCanvas) return;

		censorCanvas.width = pixelCanvas.width;
		censorCanvas.height = pixelCanvas.height;
		const ctx = censorCanvas.getContext("2d");

		ctx.clearRect(0, 0, censorCanvas.width, censorCanvas.height);

		const gSize = usw.gridSize || gridSize || 25;

		if ((censorMode || settings.doInteractCensor) && isDraggingCensor && tempCensorRect)
			drawCensorRect(ctx, tempCensorRect, gSize, settings.censorColor, settings.censorAlpha / 1.5);

		const rects = getCensorRects();
		if (!rects.length) return;
		rects.forEach((rect) =>
			drawCensorRect(ctx, rect, gSize, settings.censorColor, settings.censorAlpha)
		);
	}

	function waitForMap(callback) {
		let tries = 0;
		function check() {
			if (map && map.on && map.getContainer) callback();
			else if (tries++ < 100) setTimeout(check, 100);
		}
		check();
	}

	waitForMap(() => {
		["move", "rotate", "zoom"].forEach((ev) => map.on(ev, drawCensorRects));
		new ResizeObserver(drawCensorRects).observe(map.getContainer());
		map.once("load", drawCensorRects);
	});
	//#endregion Censor

	//#region keybind
	document.addEventListener("mousemove", (e) => {
		mouseX = e.clientX;
		mouseY = e.clientY;
	});

	function isValidKeybindsConfig(json, doReturn = false) {
		const isValid =
			json &&
			typeof json === "object" &&
			Object.keys(json).every((key) => KEY_BINDINGS[key] && json[key].length <= 1);
		return doReturn ? (isValid ? json : false) : isValid;
	}

	let keybinds =
		isValidKeybindsConfig(JSON.parse(localStorage.getItem(STORAGE_KEYS.keybinds)), true) ||
		Object.fromEntries(
			Object.entries(KEY_BINDINGS)
				.filter(([_, v]) => v.defaultKey)
				.map(([id, { defaultKey }]) => [id, defaultKey])
		);

	Object.keys(KEY_BINDINGS).forEach((id) => {
		if (KEY_BINDINGS[id].defaultKey && !keybinds.hasOwnProperty(id))
			keybinds[id] = KEY_BINDINGS[id].defaultKey;
	});

	function saveKeybinds(kb) {
		keybinds = kb;
		localStorage.setItem(STORAGE_KEYS.keybinds, JSON.stringify(kb));
	}
	function saveKeybind(id, key) {
		keybinds[id] = key;
		saveKeybinds(keybinds);
	}

	document.getElementById("toggleKeybinds").addEventListener("click", () => {
		for (const key in keybinds) {
			const element = document.getElementById(`gpp_keybind-${key}`);
			if (element) element.value = keybinds[key];
		}
	});

	function handleKeyBindPress(e) {
		// Ignore shortcuts if typing in an input field.
		const aElm = document.activeElement;
		if (aElm && (aElm.tagName === "INPUT" || aElm.tagName === "TEXTAREA" || aElm.isContentEditable))
			return;

		const id = Object.keys(keybinds).find((key) => keybinds[key] === e.key.toLowerCase());
		if (id && KEY_BINDINGS[id][e.type]) {
			KEY_BINDINGS[id][e.type](e);
			e.preventDefault();
		}
	}

	document.addEventListener("keydown", handleKeyBindPress);
	document.addEventListener("keyup", handleKeyBindPress);

	document
		.querySelector("#keybindsPanel>div.flex>button.bg-blue-500")
		.addEventListener("click", () => {
			saveKeybinds(
				Object.fromEntries(
					Object.keys(KEY_BINDINGS).map((id) => [
						id,
						document.querySelector(`[id^='gpp_keybind-${id}']`).value.trim().toLowerCase(),
					])
				)
			);
		});
	//#endregion keybind

	//#region settings
	function isValidSettingsConfig(json, doReturn = false) {
		const isValid =
			json && typeof json === "object" && Object.keys(json).every((key) => SETTINGS[key]);
		return doReturn ? (isValid ? json : false) : isValid;
	}

	let settings =
		isValidSettingsConfig(JSON.parse(localStorage.getItem(STORAGE_KEYS.settings)), true) ||
		Object.fromEntries(
			Object.entries(SETTINGS)
				.filter(([_, v]) => v.default)
				.map(([id, { default: d }]) => [id, d])
		);

	Object.keys(SETTINGS).forEach((id) => {
		if (SETTINGS[id].default !== undefined && !settings.hasOwnProperty(id))
			settings[id] = SETTINGS[id].default;
	});

	function saveSetting(id, val) {
		settings[id] = val;
		localStorage.setItem(STORAGE_KEYS.settings, JSON.stringify(settings));
	}

	for (const [id, val] of Object.entries(settings))
		if (SETTINGS[id] && SETTINGS[id].apply) SETTINGS[id].apply(val);

	const SETTINGS_CLASSES = {
		select: "rounded-md border-2 border-gray-200 sm:text-sm p-2",
		textarea:
			"w-full h-32 rounded-md border-2 border-gray-200 p-2 font-mono resize-vertical outline-none transition",
		button:
			"px-4 py-2 rounded-lg shadow transition cursor-pointer bg-blue-500 hover:bg-blue-600 text-white",
		input: {
			color: "rounded-md border-2 border-gray-200 p-1 cursor-pointer",
			range: "py-1.5 cursor-pointer",
			keybind: "keybind-input",
			checkbox: "cursor-pointer h-6 w-6",
		},
	};
	function makeSettingUI(key, s) {
		const baseClass =
			typeof SETTINGS_CLASSES[s.element] === "string"
				? SETTINGS_CLASSES[s.element]
				: SETTINGS_CLASSES[s.element]?.[s.attributes.type] ?? "";

		const input = Object.assign(document.createElement(s.element), {
			className: baseClass,
			...s.attributes,
			[s.attributes.type === "checkbox" ? "checked" : "value"]: s.value?.() ?? settings[key],
		});

		if (s.init) s.init(input);

		if (s.element == "button") {
			input.innerText = s.name;
			return input;
		}

		if (s.element == "select") {
			input.append(
				...s.options.map(({ value, text }) =>
					Object.assign(document.createElement("option"), {
						value,
						innerText: text,
					})
				)
			);
		}

		const label = document.createElement("label");
		label.className = "text-sm font-medium text-gray-700 flex";

		const isKeybind = s.attributes.type == "keybind";
		const isRow = isKeybind || s.attributes.type == "checkbox";

		if (isKeybind) {
			input.maxLength = 1;
			label.style.flex = "0 1 calc(50% - var(--spacing))";
		}
		if (isRow) {
			label.className += " flex-row items-center gap-1";
			label.append(input, s.name);
		} else {
			label.className += " flex-col items-start";
			label.append(s.name, input);
		}

		if (s.element == "textarea") label.className += " w-full";

		if (s.reset || s.default !== undefined) {
			const resetButton = makeButton("⟲", () => {
				if (s.reset) s.reset(input);
				else {
					input.value = s.default;
					input.dispatchEvent(new Event("change"));
				}
			});
			resetButton.className = "cursor-pointer absolute top-0 h-5 w-5 text-xl";
			resetButton.style.lineHeight = "var(--text-xl)";
			resetButton.title = "reset";
			label.appendChild(resetButton);
			label.className += " relative";
			if (isKeybind) {
				label.style.paddingLeft = "calc(var(--text-xl))";
				resetButton.className += " left-0";
			} else {
				label.style.paddingRight = "calc(var(--text-xl) + 5px)";
				resetButton.className += " right-0";
			}
		}
		return label;
	}
	//#endregion settings

	//#region Ghost colors progress
	function updateGhostPaletteProgress() {
		if (!ghostImage || !ghostImageOriginalData || !ghostImageTopLeft) return;

		const { gridX, gridY } = ghostImageTopLeft;
		const { width, height } = ghostImage;

		const mapCanvas = Object.assign(document.createElement("canvas"), { width, height });
		const mapCtx = mapCanvas.getContext("2d");

		mapCtx.setTransform(1, 0, 0, -1, -gridX, gridY + 1);

		for (const { x, y } of getAllCoordsBetween(
			{ x: snapToTile(gridX), y: snapToTile(gridY) },
			{ x: snapToTile(gridX + width), y: snapToTile(gridY - height) },
			SYNC_TILE_SIZE
		)) {
			const tileData = tileImageCache.get(`${x},${y}`);
			if (tileData?.colorBitmap) mapCtx.drawImage(tileData.colorBitmap, x, y);
		}

		mapCtx.setTransform(1, 0, 0, 1, 0, 0);

		const mapData = mapCtx.getImageData(0, 0, width, height).data;
		const ghostData = ghostImageOriginalData.data;
		const matchingColorMap = new Map();

		for (let i = 0; i < ghostData.length; i += 4) {
			if (ghostData[i + 3] <= 1) continue;
			const mapHex = rgbaToHex(mapData[i], mapData[i + 1], mapData[i + 2], mapData[i + 3]);
			const hex = rgbaToHex(ghostData[i], ghostData[i + 1], ghostData[i + 2], ghostData[i + 3]);
			if (hex == mapHex) matchingColorMap.set(hex, (matchingColorMap.get(hex) ?? 0) + 1);
		}

		const container = document.getElementById("ghostColorPalette");

		for (const { rgba, hex, totalCount } of ghostPaletteColors) {
			const swatch = container.querySelector(`[data-color-rgba="${rgba}"]`);
			if (!swatch) continue;
			swatch.style.position = "relative";

			const completed = matchingColorMap.get(toFullHex(hex)) ?? 0;
			const progress = completed / totalCount;
			const percent = Math.floor(progress * 1000) / 10;

			swatch.title = `${hex}\n${completed} / ${totalCount} pixels\n${percent}%`;

			let icon = swatch.querySelector("span");
			if (!icon) {
				icon = document.createElement("span");
				icon.className =
					"absolute h-3 w-3 rounded-full border-2 border-white text-white font-bold flex justify-center items-center";
				Object.assign(icon.style, { top: "-3px", right: "-3px", fontSize: "0.6rem" });
				swatch.appendChild(icon);
			}

			icon.textContent = progress === 1 ? "✓" : "";
			icon.style.backgroundColor =
				progress === 0 ? "#000" : `hsl(${360 - progress * 240}, 100%, 50%)`;
		}
	}

	const populateColorPaletteUI_old = usw.populateColorPaletteUI;
	usw.populateColorPaletteUI = function () {
		populateColorPaletteUI_old();
		updateGhostPaletteProgress();
	};
	const calculateGhostProgress_old = usw.calculateGhostProgress;
	usw.calculateGhostProgress = function () {
		calculateGhostProgress_old();
		updateGhostPaletteProgress();
	};
	//#endregion Ghost colors progress

	//#region UI Helpers
	function copyToClipboard(text) {
		log(LOG_LEVELS.info, "Copied text to clipboard:\n", text);
		navigator.clipboard
			.writeText(text)
			.then(() => {
				alert("Copied to clipboard!");
			})
			.catch((err) => {
				log(LOG_LEVELS.error, "Failed to copy:", err);
				alert("Failed to copy to clipboard");
			});
	}

	function createModal(title, inner, footer) {
		const modalCloseTimeout = 0.15;

		const modalContainer = document.createElement("div");
		modalContainer.className = "fixed inset-0 z-50 bg-black/50 flex items-center justify-center";

		const modal = document.createElement("div");
		modal.className =
			"relative bg-white rounded-2xl shadow-2xl p-6 flex flex-col gap-6 max-h-[90vh]";
		modal.style.transition = `transform ${modalCloseTimeout}s ease-out`;
		modal.style.minWidth = "min(500px,90vw)";
		modal.style.maxWidth = "90vw";

		modal.close = () => {
			document.removeEventListener("keydown", escHandler);
			modal.style.transform = "scale(0)";
			setTimeout(() => {
				document.body.removeChild(modalContainer);
			}, modalCloseTimeout * 1000);
		};

		const modalCrossBtn = document.createElement("button");
		modalCrossBtn.className =
			"absolute top-4 right-4 w-10 h-10 bg-white shadow rounded-full hover:bg-gray-100 cursor-pointer";
		modalCrossBtn.innerText = "✕";
		modalCrossBtn.onclick = () => modal.close();

		const modalTitle = document.createElement("h2");
		modalTitle.className = "text-2xl font-semibold text-gray-800";
		modalTitle.innerText = title;

		inner = Array.isArray(inner) ? inner : [inner];
		modal.append(modalCrossBtn, modalTitle, ...inner);

		if (footer) {
			const footerWrapper = document.createElement("div");
			footerWrapper.className = "flex justify-end gap-3";
			footer = Array.isArray(footer) ? footer : [footer];
			footerWrapper.append(...footer);
			modal.appendChild(footerWrapper);
		}
		modalContainer.appendChild(modal);

		modal.onclick = (e) => e.stopPropagation();
		modalContainer.onclick = () => modal.close();
		const escHandler = (e) => e.key === "Escape" && modal.close();
		document.addEventListener("keydown", escHandler);

		document.body.appendChild(modalContainer);
		return modal;
	}

	function promptForColors(onSubmit, title = "Enter colors") {
		const textarea = document.createElement("textarea");
		textarea.placeholder =
			"Enter colors (one per line)\nFormat: '#?RRGGBB(.+)?'\nExample:\nFAECCE\n#FAECCE55\n#faecce\nFAECCE55 randomTEXTatgfdljj";
		textarea.className =
			"w-full p-3 border-2 border-gray-200 rounded-lg font-mono text-sm resize-vertical box-border";
		textarea.style.height = "200px";

		const modal = createModal(
			title,
			textarea,
			makeBasicButton("Submit", COLOR_CLASS.blue, () => {
				const value = textarea.value.trim();
				log(LOG_LEVELS.debug, "Submitted colors:\n", value);
				if (value) onSubmit(colorsStringToHexArray(value));
				modal.close();
			})
		);

		textarea.focus();
	}

	function makeButton(innerText, onClick) {
		const button = document.createElement("button");
		button.innerText = innerText;
		button.onclick = onClick;
		return button;
	}
	const COLOR_CLASS = {
		red: "bg-red-500 hover:bg-red-600 text-white",
		green: "bg-green-500 hover:bg-green-600 text-white",
		blue: "bg-blue-500 hover:bg-blue-600 text-white",
		gray: "bg-gray-200 hover:bg-gray-300 text-gray-700",
	};
	function makeBasicButton(innerText, classes = COLOR_CLASS.blue, onClick) {
		if (typeof classes === "function") {
			onClick = classes;
			classes = COLOR_CLASS.blue;
		}
		const button = makeButton(innerText, onClick);
		button.className = `px-4 py-2 rounded-lg shadow transition cursor-pointer ${classes}`;
		return button;
	}
	function makeSelectButton(innerText, onClick) {
		const button = makeButton(innerText, onClick);
		button.className =
			"px-2 py-1 bg-white rounded-lg shadow hover:bg-gray-300 border border-gray-300 transition cursor-pointer";
		return button;
	}
	function makeMenuButton(innerText, title, onClick) {
		const button = makeButton(innerText, onClick);
		button.className =
			"w-10 h-10 bg-white shadow rounded-full flex items-center justify-center hover:bg-gray-100 cursor-pointer transition-all";
		button.title = title;
		return button;
	}
	function makeSelectMenuButton(innerText, title, options, optionsTitle = "") {
		const wrapper = document.createElement("div");
		const select = document.createElement("div");
		const menuButton = makeMenuButton(innerText, title, () => select.toggle());

		wrapper.className = "relative";
		wrapper.onclick = (e) => e.stopPropagation();

		if (optionsTitle.length) select.innerHTML = `<h2>${optionsTitle}</h2>`;
		select.append(...options.map((opt) => makeSelectButton(opt.innerText, opt.onClick)));
		select.classList =
			"bg-white shadow rounded-xl p-2 border border-gray-300 transition-all absolute w-auto top-0 overflow-hidden flex flex-col gap-1";
		select.style.height = "auto";
		select.style.whiteSpace = "nowrap";
		select.close = () => {
			select.dataset.isOpened = "false";
			select.style.maxWidth = "40px";
			select.style.maxHeight = "40px";
			select.style.zIndex = "-1";
			select.style.left = "0px";
			menuButton.classList.remove("rounded-full");
			menuButton.classList.add("rounded-xl");
		};
		select.open = () => {
			select.dataset.isOpened = "true";
			select.style.maxWidth = "500px";
			select.style.maxHeight = (optionsTitle.length ? 25 : 0) + 16 + options.length * 40 + "px";
			select.style.zIndex = "1";
			select.style.left = "50px";
			menuButton.classList.add("rounded-full");
			menuButton.classList.remove("rounded-xl");
		};
		select.toggle = () => {
			if (select.dataset.isOpened == "true") select.close();
			else select.open();
		};
		select.close();
		window.addEventListener("click", (e) => e.target !== menuButton && select.close(), true);

		wrapper.append(menuButton, select);
		return wrapper;
	}
	function makeMenuGroupButton(innerText, title, id, parent) {
		const div = document.createElement("div");
		div.className = "relative";
		const button = makeMenuButton(innerText, title, (e) => {
			e.stopPropagation();
			closeAllDropdowns();
			toggleDropdown(document.getElementById(`${id}Dropdown`));
		});
		button.id = `${id}Btn`;
		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 hidden";
		dropdown.id = `${id}Dropdown`;
		div.append(button, dropdown);
		parent.appendChild(div);
		return dropdown;
	}

	function addGPPButtonToggle(parent, ...elms) {
		const button = makeButton("✚₊", () => button.toggle());
		button.open = () => {
			button.dataset.expanded = "true";
			button.style.transform = "rotateZ(-180deg)";
			elms.forEach((b) => {
				b.style.display = "";
			});
		};
		button.close = () => {
			button.dataset.expanded = "false";
			button.style.transform = "rotateZ(0)";
			elms.forEach((b) => {
				b.style.display = "none";
			});
		};
		button.toggle = () => {
			if (button.dataset.expanded === "true") button.close();
			else button.open();
		};
		button.close();
		button.className =
			"text-white shadow transition cursor-pointer bg-yellow-500 hover:bg-yellow-600 rounded-full h-10 w-10";

		parent.append(button, ...elms);
	}
	//#endregion UI Helpers

	//#region UI
	function createCensorsConfigModal() {
		const censorList = document.createElement("div");
		censorList.className = "flex flex-col overflow-y-auto gap-1";

		createModal("Censor Configurations", censorList, [
			makeBasicButton("Add Censor Rect", COLOR_CLASS.green, () => {
				addCensorRect({ gridX: 0, gridY: 0, width: 1, height: 1 });
				refreshCensorList();
			}),
			makeBasicButton("Clear All", COLOR_CLASS.red, () => {
				if (confirm("Are you sure you want to clear all censor rectangles?")) {
					saveCensorRects();
					refreshCensorList();
				}
			}),
		]);

		function refreshCensorList() {
			const rects = getCensorRects();
			if (rects.length === 0) {
				censorList.innerHTML = "No censor rectangles defined.";
				return;
			}
			const rectDatas = ["gridX", "gridY", "width", "height"];
			censorList.innerHTML = "";
			censorList.append(
				...rects.map((r, idx) => {
					const row = document.createElement("div");
					row.className =
						"flex justify-between items-center flex-wrap p-2 border border-gray-300 rounded-lg gap-3";
					row.append(
						...rectDatas.map((prop) => {
							const label = document.createElement("label");
							label.innerText = `${prop}:`;
							const input = document.createElement("input");
							input.type = "number";
							input.value = r[prop];
							input.className = "w-28 border border-gray-300 rounded-md px-1 outline-none";
							input.onchange = (e) => {
								const rects = getCensorRects();
								if (rects[idx] && parseInt(e.target.value)) {
									rects[idx][prop] = parseInt(e.target.value);
									saveCensorRects(rects);
								}
							};
							label.append(input);
							return label;
						}),
						Object.assign(
							makeButton("🗑️", () => {
								const rects = getCensorRects();
								if (rects[idx]) {
									rects.splice(idx, 1);
									saveCensorRects(rects);
									refreshCensorList();
								}
							}),
							{ className: `p-1 rounded-lg transition cursor-pointer ${COLOR_CLASS.red}` }
						)
					);
					return row;
				})
			);
		}
		refreshCensorList();
	}

	function createSettingsModal() {
		const groupedSettings = {};
		for (const [key, setting] of Object.entries(SETTINGS)) {
			if (!groupedSettings[setting.category]) groupedSettings[setting.category] = [];
			groupedSettings[setting.category].push({ key, setting });
		}

		const div = document.createElement("div");
		div.className = "flex flex-wrap gap-2 items-end max-w-2xl overflow-y-auto";
		Object.entries(groupedSettings).forEach(([category, sets]) => {
			const categoryTitle = document.createElement("h3");
			categoryTitle.className =
				"w-full text-xl font-semibold text-gray-700 mt-4 mb-2 border-b border-gray-300";
			categoryTitle.innerText = category;
			div.append(categoryTitle, ...sets.map(({ key, setting }) => makeSettingUI(key, setting)));
		});

		createModal("Settings", div);
	}

	// Add buttons to ghost UI
	addGPPButtonToggle(
		document.querySelector("#ghostColorPaletteContainer>div"),
		makeBasicButton("Enable Only Owned Ghost Colors", () =>
			setEnabledGhostPalette(getAvailableGhostColors())
		),
		makeBasicButton("Get Ghost Colors", () =>
			copyToClipboard(
				getGhostImageHexColors()
					.map((hex) => toOutputHex(hex))
					.join("\n")
			)
		),
		makeBasicButton("Get Enabled Ghost Colors", () =>
			copyToClipboard(getEnabledGhostPalette().toOutputString())
		),
		makeBasicButton("Set Enabled Ghost Colors", () =>
			promptForColors(
				(t) => setEnabledGhostPalette(t.filter(isInGhostPalette)),
				"Set Enabled Ghost Colors"
			)
		)
	);

	// Add buttons to profile
	const shopButtonsContainer = document.querySelector("#userColorsContainer + div");
	if (shopButtonsContainer) {
		shopButtonsContainer.style.display = "flex";
		shopButtonsContainer.style.flexWrap = "wrap";
		shopButtonsContainer.style.gap = "8px";

		addGPPButtonToggle(
			shopButtonsContainer,
			makeBasicButton("Get User Colors", () => copyToClipboard(getUserPalette().toOutputString())),
			makeBasicButton("Get Enabled Colors", () =>
				copyToClipboard(
					getUserPalette()
						.filter((c, idx) => activeColors.includes(idx))
						.toOutputString()
				)
			),
			makeBasicButton("Set Enabled Colors", () =>
				promptForColors(
					(t) => setEnabledUserPalette(t.filter(isInUserPalette)),
					"Set Enabled User Colors"
				)
			)
		);
	}

	//create hidden file input
	const inputSoundFile = document.createElement("input");
	inputSoundFile.type = "file";
	inputSoundFile.style.display = "none";
	inputSoundFile.accept = "audio/*";
	inputSoundFile.onchange = () => {
		let blob = new Blob([inputSoundFile.files[0]]);
		saveBlobSound(SOUNDS[soundToChangeIdx], blob);
	};
	document.body.appendChild(inputSoundFile);

	// Add GeoPixels++ menu group to main UI
	makeMenuGroupButton(
		"✚₊",
		"GeoPixels++",
		"geopixels-plusplus",
		document.getElementById("controls-left")
	).append(
		makeMenuButton("🎯", "Go to Coordinates", () => {
			const input = prompt("Enter coordinates (gridX,gridY) or GeoPixels URL:");
			if (input) gotoFromInput(input);
		}),
		makeMenuButton("🧪", "Set Both Palettes", () =>
			promptForColors((s) => {
				setEnabledUserPalette(s.filter(isInUserPalette));
				setEnabledGhostPalette(s.filter(isInGhostPalette));
			})
		),
		makeSelectMenuButton(
			"🎵",
			"Change Sound",
			SOUNDS.map((s, i) => {
				return {
					innerText: s.name,
					onClick: () => {
						soundToChangeIdx = i;
						inputSoundFile.click();
					},
				};
			}),
			"Choose a sound to change"
		),
		makeSelectMenuButton("🚫", "Censor", [
			{
				innerText: "Toggle censor mode",
				onClick: toggleCensorMode,
			},
			{
				innerText: "Open censors config",
				onClick: createCensorsConfigModal,
			},
		]),
		makeMenuButton("⚙️", "Settings", createSettingsModal)
	);

	//Add to the keybind Panel
	const gpp_keybind_div = document.createElement("div");
	gpp_keybind_div.innerHTML = `
			<h3 class="text-lg font-semibold text-gray-700 mb-2">GeoPixels++</h3>
			<div class="space-y-2">
				${Object.keys(KEY_BINDINGS)
					.map(
						(id) => `
							<div class="flex items-center justify-between p-2">
								<label for="gpp_keybind-${id}" class="text-gray-600">${KEY_BINDINGS[id].text}</label>
								<input id="gpp_keybind-${id}" type="text" maxlength="1" class="keybind-input">
							</div>
						`
					)
					.join("")}
			</div>
	`;
	document.querySelector("#keybindsPanel>div.grid>div:nth-child(2)").appendChild(gpp_keybind_div);

	//fix some styles
	const style = document.createElement("style");
	style.innerHTML = `input[type="number"]{appearance:auto;}`;
	document.head.appendChild(style);

	//#endregion UI

	log(LOG_LEVELS.info, "GeoPixels++ loaded");
})();